Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/32958-delete-extension-fill-stored-expressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/spec': minor
---

fix(metadata-protocol): deleting an object extension that re-declared a stored computed field fills the object's existing rows with the expression it hands back, as the save door does, and says so per field

Clause-②: yes (widening) — adds an optional `advisories` key to `DeleteMetaItemResponseSchema`
Metadata-protocol: added

`deleteMetaItem` (`DELETE /api/v1/meta/:type/:name`) removed the row and healed the registry, so the object served the definition beneath the removed one, but it ran no stored-expression fill. Deleting an own-package `object_extension` that re-declared a stored computed field (a non-`formula` field declaring `expression`) therefore left every existing row holding the value the removed expression computed, while the object now computed the owner's expression. Totals, filters and reports read those values with no error anywhere.

The delete now captures the object's stored computed fields before its registry heal and runs the same fill the save, publish, rollback and revert doors run: each stored computed field the removal makes compute something new is recomputed over the object's existing rows and reported per field on the answer's new optional `advisories` (`stored-expression-filled`, or `stored-expression-fill-incomplete` with what is left and the `os migrate stored-expressions` command that fills it). A draft discard fills nothing. The key is omitted when there is nothing to report, so a clean delete's answer is byte-identical to before. The delete itself never fails on its fill.
13 changes: 13 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **seq** | `integer` | optional | Monotonic sequence number of the metadata event this delete appended to the item history (sys_metadata_history.event_seq) — the ordering token the history/audit trail is read by, and the same key both write-verb siblings declare. Unlike `version` it is not an OCC token. Optional HERE, unlike on those siblings, because only the repository path's delete-ful branch appends an event: a no-op reset (no overlay row existed) and the control-plane legacy raw-engine path both answer without one. Absence means "this branch appended no history event", never "nothing was deleted" — read `reset` for that. |
| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` and `{@link PublishMetaItemResponseSchema}` carry, because the projector runs on all three verbs of this door: on a delete it re-reads the layered state and either retires the derived record or resets it to the artifact baseline. Present ONLY when a projector is registered for this metadata type AND a row was actually deleted, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. |
| **message** | `string` | optional | |
| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | What the delete did to the rows of the object it changed, in the same element shape and omitted-when-empty discipline as `SaveMetaItemResponseSchema.advisories`. Present ONLY when at least one entry was raised: an empty array is never emitted, so a clean answer's bytes are unchanged and a caller that ignores this key behaves exactly as before. A removal that hands an object back the definition beneath it (an `object_extension` that re-declared a field) can make a stored computed field (a non-`formula` field declaring `expression`) compute something new; per such field, the fill of the object's existing rows the save door also runs: `stored-expression-filled` (`info`, the rows written) or `stored-expression-fill-incomplete` (`warning`, what is left and how to fill it). The delete itself SUCCEEDED either way. |

### Nested Shape: `DeleteMetaItemResponse.projectionApplied`

Expand All @@ -757,6 +758,18 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **success** | `boolean` | ✅ | False when the projector threw; the metadata delete itself still succeeded. |
| **error** | `string` | optional | Projector failure message, present only when `success` is false. |

### Nested Shape: `DeleteMetaItemResponse.advisories[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **rule** | `string` | ✅ | Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. |
| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. The TOP-LEVEL collection entry follows one rule. The gate evaluates against a private per-write snapshot whose indexes no caller can resolve; a collection-resident write type is one whose collection that snapshot fills with the tenant's other stored items beside the written one, and there the entry is keyed by NAME, never by an array index — for example `objects.acme_invoice.sharingModel` (an `object` write) or `datasets.acme_invoice_metrics.dimensions[0].field` (a `dataset` write). Every other write type is the sole member of its own collection in that snapshot, so its `[0]` IS the written item and stays positional (for example `flows[0]...`). Which write types are collection-resident is derived, never listed: `@objectstack/lint`'s runtime gate computes the set (`NAME_KEYED_STACK_KEYS`) as the collections it carries as resolution context that a gated write type also lands in, so it changes with the gate, not with this text. An entry whose name cannot be spliced into a dotted path keeps its index. Nested positions inside one named item stay positional (`objects.acme_invoice.indexes[1]`): they index the author's own document. |
| **where** | `string` | ✅ | Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. |
| **message** | `string` | ✅ | What is wrong, in the rule author's own words. |
| **hint** | `string` | ✅ | How to fix it. |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | How the gate treated this finding. `error` means the write was REFUSED (these appear on the 422, never on a 2xx); `warning` / `info` are advisory — the write succeeded and the finding is FYI. |
| **userMessage** | `string` | optional | The finding's user-facing sentence, composed by its producer in the request's locale (else the workspace default locale, else `en`) — the same field name and producer-marked meaning as `ApiErrorSchema.userMessage`. Present only when the producer authors a user sentence for this `rule`; absent, a consumer renders `message` as before. Consumers render it as received; `rule` stays the stable id and `message` / `hint` stay the developer-facing text. |


---

Expand Down
32 changes: 32 additions & 0 deletions packages/metadata-protocol/src/protocol-diff-delete-load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import { diffShallow } from './protocol-write-face.js';
import type { MutationProjectionOutcome } from './protocol-mutation-events.js';
import type { DeleteMetaItemRequest } from './protocol-door-requests.js';
import { withRefusalAudit } from './protocol-refusal-audit.js';
import { captureStoredExpressionChange, fillStoredExpressionChange } from './protocol-stored-expression-fill.js';
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
import type { ProtocolHost } from './protocol-host.js';

export class ProtocolDiffDeleteLoadFacet {
Expand Down Expand Up @@ -383,6 +385,8 @@ export class ProtocolDiffDeleteLoadFacet {
seq?: number;
/** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */
projectionApplied?: MutationProjectionOutcome;
/** [#32958] The stored-expression fill of the object the removal changed; omitted when nothing to report. */
advisories?: RuntimeAuthoringIssue[];
}> {
// [#28355] A refusal that carries no row of its own leaves its `denied` row here.
return withRefusalAudit((entry) => host.recordMetadataAudit(entry), {
Expand All @@ -399,6 +403,8 @@ export class ProtocolDiffDeleteLoadFacet {
seq?: number;
/** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */
projectionApplied?: MutationProjectionOutcome;
/** [#32958] The stored-expression fill of the object the removal changed; omitted when nothing to report. */
advisories?: RuntimeAuthoringIssue[];
}> {
// [ADR-0131 D6] FIRST: an organization-scoped delete is refused.
// See {@link organizationScopedWriteRefusal}.
Expand Down Expand Up @@ -810,6 +816,17 @@ export class ProtocolDiffDeleteLoadFacet {
// overlay used to retire the plain-key entry belonging to the
// ENV-WIDE row, i.e. one tenant's "reset to default" blanked
// the item for every other tenant and the control plane.
//
// [#32958] The object's stored computed fields as served BEFORE
// the heal: removing an `object_extension` hands the object
// back the definition beneath it, which can give a stored
// computed field another expression.
// What the fill below diffs against, as the save door captures
// before its write-through and the revert door before its
// removal limb's heal. A draft discard changes nothing served.
const storedExpressions = targetState === 'active'
? captureStoredExpressionChange(host.engine, singularTypeForRepo, request.name)
: undefined;
if (targetState === 'active') {
// [#32366] For the package of the row it deleted.
await host.restoreArtifactRegistryView(request.type, request.name, null, packageId, member);
Expand Down Expand Up @@ -856,12 +873,27 @@ export class ProtocolDiffDeleteLoadFacet {
// [#32451] The package of the row it deleted, as the heal above.
...mutationEventPackage(packageId),
});
// [#32958] A stored computed field the removal made compute
// something new (the extended object's own expression handed
// back by a removed `object_extension`) is filled over the
// object's existing rows by the save door's fill
// (`protocol-stored-expression-fill.ts`, the one fill path),
// and reported per field on the omitted-when-empty
// `advisories`. After the audit row and the event, before the
// answer, as the save door orders it. A removed `object`
// serves no field afterwards, so it changes none and reads no
// row (its table may be gone with it). The delete itself is
// never failed by its fill.
const advisories = await fillStoredExpressionChange(storedExpressions);
const deletedPackage = packageId !== null ? ` (package=${packageId})` : '';
return {
success: true,
reset: true,
seq: result.seq,
...(deleteProjection ? { projectionApplied: deleteProjection } : {}),
// Omitted, never `[]`, when there is nothing to report: a
// clean delete's answer bytes are unchanged.
...(advisories.length > 0 ? { advisories } : {}),
// #5927 — the same split #5265/PR #5926 made on the save
// side, on the reset path. `artifactBacked` is exactly the
// difference between the two things a delete can be:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
* (`POST /api/v1/packages/:id/rollback`), a loop of `revertCommit`, whose
* answer carries every step's notices.
*
* [#32958] …and the delete door that hands an object back the definition
* beneath it (`protocol-diff-delete-load.ts`): `deleteMetaItem`
* (`DELETE /api/v1/meta/:type/:name`) removing an `object_extension` that
* re-declared a stored computed field captures before its registry heal and
* fills after it. Like the
* revert door's removal limb it runs no storage sync, so its answer carries
* no refused sync.
*
* A draft save makes nothing live and fills nothing. A peer applying another
* node's mutation (`applyRemoteMetadataMutation`) fills nothing either: the
* node whose door took the change filled the shared rows once.
Expand All @@ -53,7 +61,7 @@
* write, an object it could not read, or a storage sync that was refused,
* with what is left and how to fill it.
*
* The save, publish, rollback or revert itself is never failed by its fill:
* The save, publish, rollback, revert or delete itself is never failed by its fill:
* the definition is live and stays live, and the answer says what the rows hold.
*/

Expand Down
12 changes: 12 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11028,6 +11028,14 @@ export class ObjectStackProtocolImplementation implements
* next read falls through to the artifact-loaded default. Implements the
* "Reset to factory default" semantic from ADR-0005. Whitelist is shared
* with {@link saveMetaItem}.
*
* [#32958] A removal that hands an object back the definition beneath it
* (an `object_extension` that re-declared a stored computed field) fills
* every stored computed field it made compute something new over the
* object's existing rows, as
* the save door does (`protocol-stored-expression-fill.ts`), each reported
* on `advisories`, omitted when empty. An unfinished fill never fails the
* delete.
*/
async deleteMetaItem(request: DeleteMetaItemRequest): Promise<{
success: boolean;
Expand All @@ -11036,6 +11044,8 @@ export class ObjectStackProtocolImplementation implements
seq?: number;
/** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */
projectionApplied?: MutationProjectionOutcome;
/** [#32958] The stored-expression fill of the object the removal changed; omitted when nothing to report. */
advisories?: RuntimeAuthoringIssue[];
}> {
// [#29985 codemod] The body moved to `protocol-diff-delete-load.ts`; it reaches this class through `protocol-host.ts`.
return ProtocolDiffDeleteLoadFacet.deleteMetaItem(protocolHost(this), request);
Expand All @@ -11049,6 +11059,8 @@ export class ObjectStackProtocolImplementation implements
seq?: number;
/** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */
projectionApplied?: MutationProjectionOutcome;
/** [#32958] The stored-expression fill of the object the removal changed; omitted when nothing to report. */
advisories?: RuntimeAuthoringIssue[];
}> {
// [#29985 codemod] The body moved to `protocol-diff-delete-load.ts`; it reaches this class through `protocol-host.ts`.
return ProtocolDiffDeleteLoadFacet.deleteMetaItemUnaudited(protocolHost(this), request);
Expand Down
Loading
Loading