diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/23514-sentinel-owner-save.md new file mode 100644 index 00000000000..38cb0ce987a --- /dev/null +++ b/.changeset/23514-sentinel-owner-save.md @@ -0,0 +1,34 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a save naming a package of a runtime object whose owner is its package-less definition is refused before any write, with a way out that moves the object into that package (ADR-0029 D9.9) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +An object has exactly one registry entry, so it has exactly one owner (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). A runtime object first saved naming no package is owned by that package-less row. A save naming package `Q` beside it was written and answered `200`, but the registry refused `Q`'s row into a warning and kept the package-less definition. The metadata read then served `Q`'s row while the data plane served the package-less one, before and after a boot, and at boot `Q`'s row was counted in `errors`. The package-less state is no authoring destination (ADR-0070), so the refusal does not say "bind it to `sys_metadata`". It names the way into `Q` that is true in the state it answers. + +What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written. The error carries `packageId: Q` and `ownerPackageId: null`. + +- With no package's row stored beside the package-less one: FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q" with a `Q`-bound row stored that nothing served, TO the refusal: "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records … then send this save again. …". +- FROM the same save as a draft (`PUT /api/v1/meta/object/NAME?package=Q&mode=draft`, Studio's designer) answering `200` with a `Q` draft that could never go live, TO the refusal: "Move object 'NAME' into package 'Q' first: … then save it naming 'Q' in publish mode, which serves it at once, and draft after that. …". +- Where rows bound to packages are already stored beside the package-less one (the rows this defect wrote before the refusal existed, bound to `Q` or to any other package `P`), every refusal names each such row's own `DELETE …?package=` before the package-less `DELETE`. A `DELETE` naming no package over those rows does not reach the package-less definition: over one package's row it removes that row (its receipt says "(package=P), and it still exists"), and over two it is refused `409 RESOURCE_CONFLICT` ("Name the package this delete is for …"). Two saves change: + - FROM `PUT /api/v1/meta/object/NAME?package=Q` (publish or draft) answering `200`, TO the refusal: "Object 'NAME' is served by its package-less definition; its definitions bound to 'P', 'Q' are never served. To move it into 'Q': DELETE /api/v1/meta/object/NAME?package=P, then ?package=Q, then the same DELETE with no package and no dropStorage (the table and records stay), then save it naming 'Q' in publish mode. …" (with one row, its own package only). + - FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal, where one package's row is stored. The door addresses a save naming no package to the item's one package binding, so the edit was written to that row and never served. Over two bindings this save is refused `409` before, as now. The refusal reads: "A save naming no package of object 'NAME' is addressed to its stored definition bound to 'Q', which is never served: … Delete the 'Q' row first (REST: DELETE /api/v1/meta/object/NAME?package=Q), then send this save again: it then saves the package-less definition. …". +- FROM promoting `Q`'s draft of the object (`POST /api/v1/meta/object/NAME/publish?package=Q`, or `POST /api/v1/packages/Q/publish-drafts`) answering success with `Q`'s active row stored beside the package-less one and never served, TO the refusal. This reaches a draft stored before the package-less row existed, or one a draft save naming no package wrote over the rows above. The per-item publish answers `422`; `publish-drafts` answers `200` with `outcome: 'refused'` and the draft in `failed[]` with this code (ADR-0067 D2). The text reads "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage … then POST /api/v1/meta/object/NAME/publish?package=Q: the draft then serves the object at once. …". Where any package's active row is also stored, it names deleting each such row first, as above, then the same `POST …/publish?package=Q`. The publish step always names the draft's package: a publish naming none resolves by the rows the deletes leave, and over `[package-less, P]` with both `P`'s draft and a draft bound to no package it would then promote the other draft. The draft stays stored. + - A publish naming NO package of a draft bound to no package, over one package's row `P`, is addressed into `P` (the draft doors adopt the unbound draft into the one binding), and is refused the same way. No publish moves that draft into `P`: with `P`'s row deleted it lands in the package-less definition, and a publish naming `P` answers `404 NO_DRAFT`. So its text says that: "This draft of object 'NAME' is bound to no package; a publish naming no package is addressed into 'P', whose row is never served. DELETE /api/v1/meta/object/NAME?package=P, then publish again: the draft lands in the package-less definition. To move it into 'P' then: the same DELETE with no package and no dropStorage (records stay), then save it naming 'P' in publish mode. …". +- The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts each package's row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, naming every package's row, and ends "The row bound to 'Q' is not registered.". It carried the registry's "already owned by package \"sys_metadata\"" text. + +**Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no package's row stored: `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then `PUT /api/v1/meta/object/NAME?package=Q` in publish mode. That publish-mode save makes `Q`'s row the owner at once and at every boot; a draft save registers nothing, so draft only after it. With rows bound to packages stored (`Q` or any other): first `DELETE /api/v1/meta/object/NAME?package=PKG` for each such package, then as above. Where the one stored row is the one a save naming no package was addressed to, after its delete that save may instead be sent again, and it edits the package-less definition. For a refused publish of `Q`'s draft (whether the request named `Q` or the address resolved to it): the same deletes for its state, then `POST /api/v1/meta/object/NAME/publish?package=Q`; the draft's promotion makes `Q`'s row the owner. For a refused publish naming no package of a draft bound to no package: `DELETE …?package=P`, then the same publish (the draft lands package-less), then the package-less `DELETE` and a publish-mode save naming `P` if the object belongs in `P`. Never rely on a `DELETE` naming no package while a package's row is stored: it removes that row, or is refused over two. No step drops a table or a record. + +**Unchanged.** + +- A save naming no package where only the package-less row is stored (its own re-save), in either mode. +- The D9.9 refusal for an owner bound to a real package: same code, status and prescription. +- An object with no owner, and an object a code package ships. +- Every type except `object`. +- Stored rows are not rewritten on upgrade. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bca2b16b867..b0219f45671 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2131,6 +2131,41 @@ type ViewContainerWrite = } | { readonly container: string; readonly removed: 'every-row' }; +/** + * [#23514, ADR-0029 D9.9] The state a refusal of a package's object row beside + * a package-less owner (the `'sys_metadata'` sentinel) answers, which decides + * the way out its prescription names + * (`ObjectStackProtocolImplementation.unboundOwnerPackageRefusal`). + */ +interface UnboundOwnerRefusalState { + /** The save's mode; a draft save registers nothing. */ + mode?: 'draft' | 'publish'; + /** + * The packages whose ACTIVE rows of the object are stored beside the + * package-less one (none, the package the request names, or others): + * each must be deleted by naming its package, because a delete naming no + * package resolves to the one binding present, or is refused 409 over two + * ({@link ObjectStackProtocolImplementation.packagelessAddress}). + */ + storedPackages?: readonly string[]; + /** The caller named no package, and the save's address resolved to the package's row. */ + namedNoPackage?: boolean; + /** + * A publish naming no package of a draft bound to NO package, which the + * draft doors' address adopts into the one package binding stored + * (`packagelessDraftAddress`: `key: null`, `into: P`). No publish moves that + * draft into `P`: with `P`'s row gone it promotes into the package-less + * definition, and one naming `P` finds no `P` draft (`NO_DRAFT`). + */ + adoptedDraft?: boolean; + /** + * Where the refusal is raised: the save door (default), a draft promotion + * (the per-item publish and `publish-drafts`), or a registration of a + * stored row (boot, write-through). + */ + at?: 'save' | 'promote' | 'registry'; +} + /** * What a hydration of one stored row is told about it: * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the @@ -19390,7 +19425,7 @@ export class ObjectStackProtocolImplementation implements * rather than reading it back from the caller. Same sentence the boot * re-hydration already writes for the same rows. */ - private applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): void { + private async applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): Promise { // [#8862] The `&& request.type !== 'objects'` limb that stood here is // GONE. It did not merely admit a plural, it registered under one — // `registerItem` below mints the entry under the spelling that arrived @@ -19419,6 +19454,15 @@ export class ObjectStackProtocolImplementation implements // back into the disagreement D9.9 exists to prevent. throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + // [#23514] The row this registers is stored, and so is every + // other package's row the way out deletes (the one read this + // method awaits, on this refusal's branch only). + { + at: 'registry', + storedPackages: layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress('object', request.name)).candidates + : [], + }, ); } this.engine.registry.registerObject( @@ -19485,10 +19529,26 @@ export class ObjectStackProtocolImplementation implements * | `P` empty / absent | `own`, unchanged — a direct save naming no package is addressed to `R`'s row before this runs ({@link packagelessAddress}). | * | `P == Q`, some other package | **mismatch** — refused loudly, the code owner's refusal and prescription. | * - * Only an owner bound to a real package is judged here. An owner keyed by - * the `'sys_metadata'` sentinel (a package-less runtime object) or by no - * package at all is left to today's `own` verdict: it names no package the - * prescription could direct a save to. + * ## [#23514] …and against an owner keyed by the `'sys_metadata'` sentinel + * + * A runtime object first saved naming no package is owned by that + * package-less row, keyed by the sentinel. A save naming package `Q` beside + * it was classified `own` and written, and the write-through's + * `registerObject(…, Q, …, 'own')` met the registry's single-owner rule and + * landed in the best-effort warn: the save answered `200`, the metadata + * read served `Q`'s row, and the registry and the data plane kept the + * package-less definition, before and after a boot. So the sentinel owner + * takes the same `mismatch` verdict, with the sentinel as `ownerPackageId`; + * {@link overlayPackageMismatchError} gives it its own prescription, since + * "bind it to `'sys_metadata'`" names no package (ADR-0070 TL;DR 1: the + * package-less state is no authoring destination). + * + * | case | verdict | + * |:--|:--| + * | `P` empty / absent | `own`, unchanged — the package-less row's own re-save. | + * | `P == Q`, any package | **mismatch** — refused loudly, with the move-into-a-package prescription. | + * + * An object with no owner at all is left to the `own` verdict. * * Falls back to `'own'` — today's behaviour — for a registry that does not * offer the discriminator, so a duck-typed engine double is never made to @@ -19514,11 +19574,11 @@ export class ObjectStackProtocolImplementation implements ? registry.getObjectOwner(name) : undefined; const runtimeOwnerPackageId: unknown = runtimeOwner?.packageId; + // [#23514] The `'sys_metadata'` sentinel owner is judged too: see the header. if ( binding !== '' && typeof runtimeOwnerPackageId === 'string' && runtimeOwnerPackageId !== '' - && runtimeOwnerPackageId !== 'sys_metadata' && binding !== runtimeOwnerPackageId ) { return { kind: 'mismatch', packageId: binding, ownerPackageId: runtimeOwnerPackageId }; @@ -19546,7 +19606,14 @@ export class ObjectStackProtocolImplementation implements */ private static overlayPackageMismatchError( name: string, rowPackageId: string, ownerPackageId: string, + /** [#23514] The state the refusal answers, for a `'sys_metadata'` sentinel owner only. */ + unboundOwnerState: UnboundOwnerRefusalState = {}, ): Error { + // [#23514] An owner keyed by the sentinel is owned by no package, so + // this prescription's "bind it to the owner" has nothing to name. + if (ownerPackageId === 'sys_metadata') { + return ObjectStackProtocolImplementation.unboundOwnerPackageRefusal(name, rowPackageId, unboundOwnerState); + } const err: any = new Error( `Cannot layer object '${name}': the overlay is bound to package ` + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` @@ -19562,6 +19629,113 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * [#23514, ADR-0029 D9.9, ADR-0070 TL;DR 1] The D9.9 refusal for an object + * whose owner is its package-less definition (the `'sys_metadata'` + * sentinel), so no package owns it: a definition bound to package `Q` + * beside it is one the one registry entry never serves. + * + * Its prescription names the way out that is true in the state it + * answers, each measured on a real kernel across a boot, with no table + * dropped and every record kept: + * + * | state | the way out | + * |:--|:--| + * | a publish save naming `Q`, no package's row stored | delete the package-less definition WITHOUT `dropStorage` (the delete door's default, which keeps the table), then send the save again: `Q`'s row is the owner at once and at every boot | + * | a DRAFT save naming `Q`, no package's row stored | the same delete, then a PUBLISH-mode save naming `Q` (it serves the object at once), then draft as before. A draft save registers nothing, so a draft sent right after the delete leaves the object unserved until it is published | + * | rows bound to packages (`Q` or any other) are already stored beside the package-less one (the rows the save door wrote before this refusal existed) | delete each of them by naming its package, then the package-less definition, then a publish-mode save naming `Q`. Each row is named because a delete naming no package resolves to the one binding present, or is refused `409` over two ({@link packagelessAddress}), never to the package-less definition | + * | one such row, a publish save naming NO package | it is addressed to that row ({@link packagelessAddress}); delete the row, then the same save edits the package-less definition. Over two bindings the save is refused `409` before this refusal | + * | a promotion of `Q`'s draft (the per-item publish, `publish-drafts`), a draft stored before the package-less row or by a draft save the door addressed to `Q` | the deletes above for the state, then the same publish: the draft stays stored, and its promotion registers it | + * | a publish naming no package of a draft bound to NO package, which the address adopts into the one stored binding `P` | no publish moves that draft into `P` (measured: with `P`'s row gone it promotes into the package-less definition; one naming `P` answers `NO_DRAFT`). Delete `P`'s row, publish again (it updates the package-less definition), then the package-less DELETE and a publish-mode save naming `P` move the object | + * + * The boot (`loadMetaFromDb`) and the write-through + * ({@link applyObjectRegistryMutation}) meet the third state: the row + * they register is stored, and each names every package's row. + * + * ADR-0070 D5's `POST /api/v1/packages/:id/adopt-orphans` also hands an + * adopted object's registry owner to `Q` in the same request, but it + * rebinds EVERY package-less row of the environment, not only this + * object, so no prescription names it: each names the narrowest way out. + * + * The code is D9.9's, not a new one: the ledger's meaning (a row bound to a + * package that does not own the object) holds, and the caller's branch is + * the same one; only the way out differs, and the message carries it. The + * prescription first: a 4xx message crosses the REST boundary bounded at + * 500 characters with its tail truncated. + */ + private static unboundOwnerPackageRefusal( + name: string, rowPackageId: string, state: UnboundOwnerRefusalState = {}, + ): Error { + const q = rowPackageId; + const path = `/api/v1/meta/object/${name}`; + const tail = state.at === 'registry' + ? ` The row bound to '${q}' is not registered.` + : ' Nothing was written.'; + let message: string; + // [#23514] Every row bound to a package stored beside the package-less + // one is named by its own `?package=` delete, before the delete naming + // no package: over such a row that request removes it (one binding) or + // is refused 409 (two), never the package-less definition. + const rows = [...new Set(state.storedPackages ?? [])].sort(); + const rowDeletes = rows.map((pkg, i) => (i === 0 ? `DELETE ${path}?package=${pkg}` : `?package=${pkg}`)) + .join(', then '); + const rowsServed = rows.length === 1 + ? `its definition bound to '${rows[0]}' is never served` + : `its definitions bound to ${rows.map((pkg) => `'${pkg}'`).join(', ')} are never served`; + if (rows.length > 0 && state.at === 'promote' && state.adoptedDraft) { + // Measured: the publish then lands the draft package-less, so the + // text says so, and names the move into the package as the next step. + message = `This draft of object '${name}' is bound to no package; a publish naming no package is ` + + `addressed into '${q}', whose row is never served. DELETE ${path}?package=${q}, then publish again: ` + + `the draft lands in the package-less definition. To move it into '${q}' then: the same DELETE with no ` + + `package and no dropStorage (records stay), then save it naming '${q}' in publish mode.` + tail; + } else if (rows.length > 0 && state.namedNoPackage) { + // A save naming no package is addressed to the one binding present. + message = `A save naming no package of object '${name}' is addressed to its stored definition bound to ` + + `'${q}', which is never served: the object is served by its definition bound to no package. ` + + `Delete the '${q}' row first (REST: DELETE ${path}?package=${q}), then send this save again: it ` + + 'then saves the package-less definition.' + tail; + } else if (rows.length > 0) { + // [#23514] A promotion's last step NAMES the draft's package: a + // publish naming none resolves by the rows the deletes leave, and + // can then address another draft (`packagelessDraftAddress`). + const finish = state.at === 'promote' + ? `POST ${path}/publish?package=${q}` + : `save it naming '${q}' in publish mode`; + // Every step is a request, in order, so the whole way out fits the + // REST message bound with two packages' rows and long names. + message = `Object '${name}' is served by its package-less definition; ${rowsServed}. To move it into ` + + `'${q}': ${rowDeletes}, then the same DELETE with no package and no dropStorage (the table and ` + + `records stay), then ${finish}.` + tail; + } else if (state.at === 'promote') { + // The draft is stored and stays stored: after the delete, its + // promotion is the save that moves the object (it registers). + message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package ` + + `parameter), then POST ${path}/publish?package=${q}: the draft then serves the object at once.` + tail; + } else if (state.mode === 'draft') { + message = `Move object '${name}' into package '${q}' first: delete its package-less definition without ` + + `dropStorage, which keeps its table and records (REST: DELETE ${path} with no package parameter), ` + + `then save it naming '${q}' in publish mode, which serves it at once, and draft after that. A draft ` + + 'save registers nothing, so a draft sent first leaves the object unserved until it is published.' + + tail; + } else { + message = `Move object '${name}' into package '${q}' first: delete its package-less definition ` + + 'without dropStorage, which keeps its table and records (REST: DELETE ' + + `${path} with no package parameter; in process: deleteMetaItem without ` + + 'packageId), then send this save again. Nothing was written: the object is held by a definition ' + + `bound to no package, and an object has one registry entry, so a definition bound to ` + + `'${q}' would never be served.`; + } + const err: any = new Error(message); + err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; + err.status = 422; + err.packageId = rowPackageId; + err.ownerPackageId = null; + err.docs = 'docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md'; + return err; + } + /** * Register ONE active overlay body into the engine's SchemaRegistry. * @@ -20569,7 +20743,7 @@ export class ObjectStackProtocolImplementation implements // would make a runtime-created object unreachable for data CRUD // rather than merely un-listed. This branch has never carried the // kernel-scope gate below either, for the same reason. - this.applyObjectRegistryMutation(request); + await this.applyObjectRegistryMutation(request); return; } // [#23298] Whose registry this is — never `environmentId` alone. @@ -22285,6 +22459,9 @@ export class ObjectStackProtocolImplementation implements // // Several bindings is a name more than one package holds, and a save // naming none of them is refused, not guessed. + // [#23514] Whether the CALLER named a package, read before the address + // below may name one for it: the D9.9 refusal's prescription differs. + const namedNoPackage = request.packageId == null; if ( mode === 'publish' && request.packageId == null @@ -22317,11 +22494,24 @@ export class ObjectStackProtocolImplementation implements // runtime had discarded (#6995, the silent write-side divergence). // The receipt and the registry now agree because the write never // happens: this precedes `ensureOverlayIndex` and every `put`. + // + // [#23514] The same holds for an object its package-less definition + // owns (the `'sys_metadata'` sentinel): a save naming a package beside + // it is refused here, with the move-into-a-package prescription + // ({@link unboundOwnerPackageRefusal}). if (singularType === 'object') { const layer = this.classifyObjectContribution(request.name, request.packageId); if (layer.kind === 'mismatch') { + // [#23514] For a sentinel owner the way out depends on the + // state: the mode, whether the caller named a package, and + // which packages' rows are already stored beside the + // package-less one (any package, not only the one named). + const storedPackages = layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress(singularType, request.name)).candidates + : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + { mode, storedPackages, namedNoPackage }, ); } } @@ -24508,8 +24698,20 @@ export class ObjectStackProtocolImplementation implements if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { const layer = this.classifyObjectContribution(request.name, promoteInto); if (layer.kind === 'mismatch') { + // [#23514] A package-less owner (the `'sys_metadata'` sentinel): + // the way out is a promotion, after a delete of each row bound to + // a package (any package) stored beside it. + const storedPackages = layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress(singularType, request.name)).candidates + : []; throw ObjectStackProtocolImplementation.overlayPackageMismatchError( request.name, layer.packageId, layer.ownerPackageId, + { + at: 'promote', + storedPackages, + // The package-unbound draft the address adopted into the one binding. + adoptedDraft: !('packageId' in request) && draftKey === null && promoteInto !== null, + }, ); } } @@ -30246,6 +30448,14 @@ export class ObjectStackProtocolImplementation implements if (layer.kind === 'mismatch') { throw ObjectStackProtocolImplementation.overlayPackageMismatchError( String(record.name), layer.packageId, layer.ownerPackageId, + // [#23514] The row this hydrates is stored, and so is + // every other package's row the way out deletes. + { + at: 'registry', + storedPackages: layer.ownerPackageId === 'sys_metadata' + ? (await this.packagelessAddress('object', String(record.name))).candidates + : [], + }, ); } this.engine.registry.registerObject( diff --git a/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts new file mode 100644 index 00000000000..a069a176e3c --- /dev/null +++ b/packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts @@ -0,0 +1,878 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23514, ADR-0029 D9.9 — a save naming package Q of a runtime object whose + * owner is its PACKAGE-LESS definition (the `'sys_metadata'` sentinel) is + * refused before any write, and the refusal names a way out that works. + * + * ## The defect + * + * `classifyObjectContribution` judged a runtime owner only when it was bound to + * a real package (#23416), so a row bound to Q beside a package-less owner was + * classified `own` and written. The write-through's + * `registerObject(…, Q, …, 'own')` met the registry's single-owner rule and + * landed in `applyObjectRegistryMutation`'s best-effort warn: the save answered + * `200`, the metadata read served Q's row, and the registry (so the data plane) + * kept the package-less definition, before and after a boot. Measured at + * `cb9841aa25` per the card, and again at `23e689dc7d` through + * `PUT /api/v1/meta/object/NAME?package=Q` on a SQLite store. + * + * ## The way out the refusal prescribes, and why it is that one + * + * Delete the package-less definition WITHOUT `dropStorage` (the delete door's + * default), which keeps the table and its records, then save naming Q: Q's row + * becomes the owner at once and at every later boot. ADR-0070 D5's + * `adopt-orphans` door also hands an adopted object's registry owner to Q (its + * own pins, `protocol-adopt-orphans-object-registry.test.ts`, run beside these), + * but it rebinds EVERY package-less row of the environment, so the refusal + * names the narrower way out. + * + * ## Why a real kernel, and two of them + * + * The disagreement is between the `sys_metadata` rows the driver holds and the + * `SchemaRegistry` the data plane dispatches on, and the boot half is + * `loadMetaFromDb`'s. `ObjectQLPlugin` binds the real protocol to the real + * engine and registry; a second kernel over the same store is the boot. + * + * ## The pins + * + * - the card's sequence: Q's save is refused (`code` + `status`), nothing is + * stored for Q, and the registry, the data plane and the metadata read agree + * on the package-less definition, before and after a boot; + * - #23630 member 2's sequence: saves naming P and then Q are both refused, so + * the rows `[P, Q, package-less]` cannot be produced; + * - the prescription, followed: the object moves into Q with its table and + * records, and Q's row is the owner and the served definition across a boot, + * for an object saved in this process and for one HYDRATED AT BOOT, on the + * kernel's fallback metadata and with `MetadataPlugin` (the delete heal for + * a boot-hydrated object is what makes the second half hold); + * - a DRAFT save naming Q (Studio's designer door) is refused too, with its + * own order: the delete, then a PUBLISH-mode save naming Q (a draft save + * registers nothing), then drafting, publishing and a boot agree; + * - the rows the save door stored before this refusal, `[package-less, Q]` + * (Q's row never served): the boot names the way out for them, a save + * naming no package (addressed to Q's row) and one naming Q are refused + * with the way out true for those rows, and that way out walks: delete Q's + * row by naming Q, then the save naming no package edits the package-less + * definition, or the object moves into Q, with its records, across a boot; + * - the same for rows bound to ANY package (`[package-less, P]`, + * `[package-less, P, Q]`): the refusal names each row's delete by its + * package, since a delete naming no package resolves to the one binding + * present or is refused 409 over two, and that walk converges; + * - a draft bound to NO package over `[package-less, P]`, published naming no + * package (the address adopts it into P): the text says the publish lands + * package-less after P's row is deleted, and names the move into P after + * it, and that walk ends where the text says; + * - the promote doors (ADR-0029 D9.9 there too): Q's draft stored before the + * package-less row existed is refused at publish with the order true for + * a promotion (the delete, then the same publish), and that order walks; + * - CONTROL: a save naming no package over the package-less owner is unchanged. + * + * The D9.9 refusal for an owner bound to a real package is pinned, unchanged, + * by `protocol-runtime-owner-overlay-refusal.test.ts`. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectQL, type EngineReadOptions } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** + * In-memory driver, copied from `protocol-runtime-owner-overlay-refusal.test.ts`; + * equality-only WHERE. `dropTable` records the tables a run drops, so a pin can + * say none was. + */ +function makeMemoryDriver(stores: Stores, drops: string[]) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + async dropTable(object: string) { drops.push(object); stores.delete(object); }, + }; + return driver; +} + +const P = 'com.qa.p'; +const Q = 'com.qa.q'; +const NAME = 'qa_probe_gadget'; +const SENTINEL = 'sys_metadata'; +/** The data plane's reads and writes in these pins run as the system. */ +const SYSTEM: EngineReadOptions = { context: { isSystem: true } }; + +/** A runtime object body; `label` tells the writes apart. */ +const gadgetBody = (label: string) => ({ + name: NAME, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + }, +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; +} + +describe('#23514 — a save naming a package beside an object its package-less definition owns', () => { + const live: ObjectKernel[] = []; + const roots: string[] = []; + afterEach(async () => { + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); + }); + + /** + * One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run + * by the plugin's start. `withMetadataPlugin` adds the `metadata` service + * (`MetadataPlugin`, an empty temp root, `watch: false`), into which + * `ObjectQLPlugin.start()` copies every object it hydrated: the composition + * whose boot-hydrated delete kept the object registered until the delete + * heal for boot-hydrated objects landed. + */ + async function boot(stores: Stores, drops: string[], withMetadataPlugin = false): Promise { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores, drops), true); + if (withMetadataPlugin) { + const root = mkdtempSync(join(tmpdir(), 'os-23514-')); + roots.push(root); + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + } + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + return { kernel, engine, protocol: kernel.getService('protocol') }; + } + + /** The bindings of the object's stored rows, as the driver holds them. */ + const storedBindings = (stores: Stores, table: 'sys_metadata' | 'sys_metadata_history' = 'sys_metadata') => + Array.from(stores.get(table)?.values() ?? []) + .filter((r) => r.type === 'object' && r.name === NAME) + .map((r) => r.package_id ?? null); + + const save = (k: Booted, packageId: string | undefined, label: string) => + k.protocol.saveMetaItem({ + type: 'object', name: NAME, ...(packageId ? { packageId } : {}), item: gadgetBody(label), + }); + + const refusalOf = (k: Booted, packageId: string, label: string) => + save(k, packageId, label).then(() => null, (e: any) => e); + + /** + * The three answers that must agree: the registry's owner, the definition + * the data plane dispatches on (the registry's object), and the metadata + * read's served row; plus the records the data plane serves. + */ + async function served(k: Booted) { + const read = await k.protocol.getMetaItem({ type: 'object', name: NAME }); + const records = await k.engine.find(NAME, {}, SYSTEM); + return { + owner: k.engine.registry.getObjectOwner(NAME)?.packageId, + dataPlaneLabel: (k.engine.registry.getObject(NAME) as { label?: string } | undefined)?.label, + metaReadLabel: (read?.item ?? read)?.label, + records: records.length, + }; + } + + /** The object saved naming no package, with two records in its table. */ + async function seedPackagelessOwner(stores: Stores, drops: string[]): Promise { + const k = await boot(stores, drops); + const res = await save(k, undefined, 'Gadget unbound'); + expect(res.success).toBe(true); + await k.engine.insert(NAME, { name: 'rec-1' }, SYSTEM); + await k.engine.insert(NAME, { name: 'rec-2' }, SYSTEM); + expect(storedBindings(stores)).toEqual([null]); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + expect(k.engine.registry.getPackagedObjectOwner(NAME)).toBeUndefined(); + return k; + } + + /** The refusal's envelope, and the first sentence the ruling binds: a way out, never "bind it to sys_metadata". */ + function expectRefusal(err: any): void { + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch( + new RegExp(`^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its ` + + 'package-less definition without dropStorage, which keeps its table and records '), + ); + expect(err.message).not.toContain(`'${SENTINEL}'`); + } + + it('the card\'s sequence: Q\'s save is refused before any write, and the stores agree, before and after a boot', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + const historyBefore = storedBindings(stores, 'sys_metadata_history'); + + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + // Nothing stored for Q: no row, and no history entry either. + expect(storedBindings(stores)).toEqual([null]); + expect(storedBindings(stores, 'sys_metadata_history')).toEqual(historyBefore); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + // The refusal holds across the boot. + expectRefusal(await refusalOf(k2, Q, 'Gadget Q')); + expect(storedBindings(stores)).toEqual([null]); + expect(drops).toEqual([]); + }); + + it('#23630 member 2: saves naming P and then Q are both refused, so `[P, Q, package-less]` cannot be produced', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + + const pErr = await refusalOf(k1, P, 'Gadget P'); + expect(pErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(pErr?.status).toBe(422); + expect(pErr?.packageId).toBe(P); + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + }); + + it('the prescription is a way out: the object moves into Q with its table and records, and Q\'s row is the owner across a boot', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + expectRefusal(await refusalOf(k1, Q, 'Gadget Q')); + + // The prescription, as written: the delete names no package and no dropStorage… + const del = await k1.protocol.deleteMetaItem({ type: 'object', name: NAME }); + expect(del.success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(drops).toEqual([]); + expect(stores.get(NAME)?.size).toBe(2); + // …and the same save, sent again. + const res = await save(k1, Q, 'Gadget Q'); + expect(res.success).toBe(true); + + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k1)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + // Q's own re-save after the boot goes live. + expect((await save(k2, Q, 'Gadget Q2')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q2', metaReadLabel: 'Gadget Q2', records: 2, + }); + expect(drops).toEqual([]); + }); + + for (const { composition, withMetadataPlugin } of [ + { composition: 'the kernel\'s fallback metadata', withMetadataPlugin: false }, + { composition: 'MetadataPlugin', withMetadataPlugin: true }, + ]) { + it(`the prescription is a way out for an object HYDRATED AT BOOT too (${composition}): gone at once after the delete, then Q's row is the owner with the table and records kept, and a boot agrees`, async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + + // The object is now hydrated at boot, not saved in this process. + const k2 = await boot(stores, drops, withMetadataPlugin); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + expectRefusal(await refusalOf(k2, Q, 'Gadget Q')); + expect(storedBindings(stores)).toEqual([null]); + + // The prescription's first call: the delete names no package and no dropStorage. + const del = await k2.protocol.deleteMetaItem({ type: 'object', name: NAME }); + expect(del.success).toBe(true); + // The registry owner, the metadata read and the data plane agree it is gone at once; + // the table keeps its records. + expect(storedBindings(stores)).toEqual([]); + expect(k2.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect(k2.engine.registry.getObject(NAME)).toBeUndefined(); + expect((await k2.protocol.getMetaItem({ type: 'object', name: NAME }))?.item).toBeUndefined(); + await expect(k2.engine.find(NAME, {}, SYSTEM)) + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); + expect(stores.get(NAME)?.size).toBe(2); + + // The second call: the same save, sent again. + expect((await save(k2, Q, 'Gadget Q')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + await k2.engine.insert(NAME, { name: 'rec-3' }, SYSTEM); + + await k2.kernel.shutdown(); + const k3 = await boot(stores, drops, withMetadataPlugin); + expect(await served(k3)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 3, + }); + expect(storedBindings(stores)).toEqual([Q]); + expect(drops).toEqual([]); + }); + } + + it('a DRAFT save naming Q is refused too, and its way out is the delete, then a PUBLISH-mode save naming Q, then drafting', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + const k2 = await boot(stores, drops); + + const err = await k2.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + }).then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less ` + + `definition without dropStorage, .*then save it naming '${Q.replace(/\./g, '\\.')}' in publish mode`, + )); + expect(storedBindings(stores)).toEqual([null]); + + // The way out, in the order the refusal names. + expect((await k2.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await save(k2, Q, 'Gadget Q')).success).toBe(true); + // The publish-mode save serves it at once… + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + // …and drafting naming Q goes on as before, live once published. + expect((await k2.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + })).success).toBe(true); + expect((await k2.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + + await k2.kernel.shutdown(); + const k3 = await boot(stores, drops); + expect(await served(k3)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + expect(drops).toEqual([]); + }); + + describe('the rows the save door stored before this refusal: `[package-less, Q]`, Q\'s row never served', () => { + /** The package-less owner with two records, plus each package's row stored beside it, as the pre-refusal save wrote it. */ + async function seedLegacyRows(stores: Stores, drops: string[], packages: string[] = [Q]): Promise { + const k1 = await seedPackagelessOwner(stores, drops); + await k1.kernel.shutdown(); + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()).find((r) => r.type === 'object' && r.name === NAME)!; + const body = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + for (const pkg of packages) { + body.label = `Gadget ${pkg} legacy`; + rows.set(`legacy_row_${pkg}`, { + ...unbound, + id: `legacy_row_${pkg}`, + package_id: pkg, + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : { ...body }, + }); + } + const warn = vi.spyOn(console, 'warn'); + try { + const k2 = await boot(stores, drops); + // The boot keeps the package-less owner and, for each package's row, + // names the way out: every package's row deleted by name. + const hydrateWarns = warn.mock.calls.map((c) => String(c[0])) + .filter((line) => line.includes(`Failed to hydrate object/${NAME}`)); + expect(hydrateWarns).toHaveLength(packages.length); + for (const line of hydrateWarns) { + expect(line).toContain(`Object '${NAME}' is served by its package-less definition; its definition`); + expect(line).toContain(`DELETE /api/v1/meta/object/${NAME}?package=${[...packages].sort()[0]}`); + for (const pkg of packages) expect(line).toContain(`?package=${pkg}`); + expect(line).toContain('then the same DELETE with no package and no dropStorage'); + expect(line).toContain('(code=OBJECT_OVERLAY_PACKAGE_MISMATCH)'); + } + expect(storedBindings(stores)).toEqual([null, ...packages]); + expect(k2.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + expect((k2.engine.registry.getObject(NAME) as { label?: string }).label).toBe('Gadget unbound'); + return k2; + } finally { + warn.mockRestore(); + } + } + + it('a save naming no package is addressed to Q\'s row and refused with the way out for these rows; the way out walks', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops); + + const err = await save(k, undefined, 'Gadget unbound v2').then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.message).toMatch(new RegExp( + `^A save naming no package of object '${NAME}' is addressed to its stored definition bound to ` + + `'${Q.replace(/\./g, '\\.')}', which is never served`, + )); + expect(storedBindings(stores)).toEqual([null, Q]); + + // The way out: delete Q's row by naming Q; the package-less definition stays served. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + expect((await served(k)).records).toBe(2); + // …then the same save edits the package-less definition. + expect((await save(k, undefined, 'Gadget unbound v2')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('a save naming Q is refused with the move for these rows, and the move walks: Q\'s row, then the package-less one, then the save', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its package-less definition; its definition bound to ` + + `'${Q.replace(/\./g, '\\.')}' is never served\\. To move it into '${Q.replace(/\./g, '\\.')}': ` + + `DELETE /api/v1/meta/object/${NAME}\\?package=${Q.replace(/\./g, '\\.')}, then the same DELETE ` + + 'with no package and no dropStorage', + )); + expect(storedBindings(stores)).toEqual([null, Q]); + + // The move, in the order the refusal names: Q's row by naming Q… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + // …then the package-less definition, without dropStorage… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect(stores.get(NAME)?.size).toBe(2); + // …then the save naming Q, in publish mode. + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('another package\'s row `[package-less, P]`: a save naming Q is refused naming P\'s delete, and the walk in that order moves the object into Q', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its package-less definition; its definition bound to ` + + `'${P.replace(/\./g, '\\.')}' is never served\\. To move it into '${Q.replace(/\./g, '\\.')}': ` + + `DELETE /api/v1/meta/object/${NAME}\\?package=${P.replace(/\./g, '\\.')}, then the same DELETE ` + + 'with no package and no dropStorage', + )); + expect(storedBindings(stores)).toEqual([null, P]); + + // P's row by naming P… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + // …then the package-less definition, without dropStorage… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect(stores.get(NAME)?.size).toBe(2); + // …then the save naming Q. + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(storedBindings(stores)).toEqual([Q]); + expect(drops).toEqual([]); + }); + + it('another package\'s row `[package-less, P]`: a DRAFT save naming Q and a publish of Q\'s stored draft are refused naming P\'s delete, and the publish walks', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + const pRowDelete = new RegExp( + `To move it into '${Q.replace(/\./g, '\\.')}': DELETE /api/v1/meta/object/${NAME}\\?package=` + + `${P.replace(/\./g, '\\.')}, then the same DELETE with no package and no dropStorage`, + ); + + const draftErr = await k.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + }).then(() => null, (e: any) => e); + expect(draftErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(draftErr?.status).toBe(422); + expect(draftErr?.message).toMatch(pRowDelete); + expect(draftErr?.message).toContain(`then save it naming '${Q}' in publish mode.`); + + // Q's draft as an earlier door stored it, beside P's active row. + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()).find((r) => r.type === 'object' && r.name === NAME && r.package_id == null)!; + rows.set('legacy_q_draft', { ...unbound, id: 'legacy_q_draft', package_id: Q, state: 'draft' }); + const promoteErr = await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q }) + .then(() => null, (e: any) => e); + expect(promoteErr?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(promoteErr?.status).toBe(422); + expect(promoteErr?.message).toMatch(pRowDelete); + expect(promoteErr?.message).toContain( + `then POST /api/v1/meta/object/${NAME}/publish?package=${Q}.`, + ); + + // The walk the promote text names: P's row, the package-less definition, then the publish naming Q. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect((await served(k)).owner).toBe(Q); + expect((await served(k)).records).toBe(2); + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect((await served(k2)).owner).toBe(Q); + expect((await served(k2)).records).toBe(2); + expect(drops).toEqual([]); + }); + + it('another package\'s row `[package-less, P]` with BOTH P\'s draft and a draft bound to no package, published naming no package: the text names P in its publish step, and that walk promotes P\'s draft', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()) + .find((r) => r.type === 'object' && r.name === NAME && r.package_id == null && r.state === 'active')!; + const asDraft = (id: string, pkg: string | null, label: string) => { + const body = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + body.label = label; + rows.set(id, { + ...unbound, id, package_id: pkg, state: 'draft', + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(body) : body, + }); + }; + asDraft('unbound_draft', null, 'Gadget unbound draft'); + asDraft('p_draft', P, 'Gadget P draft'); + + // The address is P's draft (one active binding, and P's draft stored); D9.9 refuses it. + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(err?.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err?.status).toBe(422); + expect(err?.packageId).toBe(P); + expect(err?.message).toMatch(new RegExp(`^Object '${NAME}' is served by its package-less definition; `)); + expect(err?.message).toContain( + `then POST /api/v1/meta/object/${NAME}/publish?package=${P}.`, + ); + + // The walk, in the named order: P's row, the package-less definition, then the publish NAMING P. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + // P's draft is live in P; the unbound draft is left stored, unpublished. + expect(await served(k)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P draft', metaReadLabel: 'Gadget P draft', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('another package\'s row `[package-less, P]` and a draft bound to NO package, published naming no package: the text says the publish lands package-less, and names the move into P after it', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P]); + // The package-less draft, as a draft save naming no package stored it before P's row existed. + const rows = stores.get('sys_metadata')!; + const unbound = Array.from(rows.values()) + .find((r) => r.type === 'object' && r.name === NAME && r.package_id == null && r.state === 'active')!; + const draftBody = typeof unbound.metadata === 'string' + ? JSON.parse(unbound.metadata as string) + : { ...(unbound.metadata as Record) }; + draftBody.label = 'Gadget unbound draft'; + rows.set('packageless_draft', { + ...unbound, + id: 'packageless_draft', + state: 'draft', + metadata: typeof unbound.metadata === 'string' ? JSON.stringify(draftBody) : draftBody, + }); + + // The address adopts the unbound draft into P, the one binding; D9.9 refuses it. + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(P); + expect(err.message).toMatch(new RegExp( + `^This draft of object '${NAME}' is bound to no package; a publish naming no package is addressed ` + + `into '${P.replace(/\./g, '\\.')}', whose row is never served\\.`, + )); + expect(err.message).toContain( + `DELETE /api/v1/meta/object/${NAME}?package=${P}, then publish again: the draft lands in the ` + + 'package-less definition.', + ); + expect(err.message).toContain(`then save it naming '${P}' in publish mode.`); + expect(storedBindings(stores)).toEqual([null, P, null]); + + // The walk, in the order the text names: P's row, then the same publish… + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME })).success).toBe(true); + // …which lands package-less, as the text says. + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound draft', metaReadLabel: 'Gadget unbound draft', records: 2, + }); + expect(storedBindings(stores)).toEqual([null]); + // Then the move into P: the package-less DELETE, then a publish-mode save naming P. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await save(k, P, 'Gadget P')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P', metaReadLabel: 'Gadget P', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: P, dataPlaneLabel: 'Gadget P', metaReadLabel: 'Gadget P', records: 2, + }); + expect(storedBindings(stores)).toEqual([P]); + expect(drops).toEqual([]); + }); + + it('two packages\' rows `[package-less, P, Q]`: the delete naming no package is refused 409, so the refusal names each row\'s delete, and that walk converges', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedLegacyRows(stores, drops, [P, Q]); + + const err = await refusalOf(k, Q, 'Gadget Q'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.message).toMatch(new RegExp( + `^Object '${NAME}' is served by its package-less definition; its definitions bound to ` + + `'${P.replace(/\./g, '\\.')}', '${Q.replace(/\./g, '\\.')}' are never served\\. `, + )); + expect(err.message).toContain( + `DELETE /api/v1/meta/object/${NAME}?package=${P}, then ?package=${Q}, then the same DELETE with no ` + + `package and no dropStorage (the table and records stay), then save it naming '${Q}' in publish mode.`, + ); + + // Measured: the delete naming no package removes neither row here; it is refused. + const ambiguous = await k.protocol.deleteMetaItem({ type: 'object', name: NAME }).then(() => null, (e: any) => e); + expect(ambiguous?.code).toBe('RESOURCE_CONFLICT'); + expect(ambiguous?.status).toBe(409); + expect(storedBindings(stores)).toEqual([null, P, Q]); + + // The walk in the named order. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: P })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect((await served(k)).owner).toBe(SENTINEL); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(storedBindings(stores)).toEqual([]); + expect((await save(k, Q, 'Gadget Q')).success).toBe(true); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', metaReadLabel: 'Gadget Q', records: 2, + }); + expect(drops).toEqual([]); + }); + }); + + it('the promote door: Q\'s draft stored before the package-less row is refused at publish, and the delete, then the same publish, moves the object', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + // Q's draft, saved while no row held the object: judged against the owner of its moment, and stored. + expect((await k.protocol.saveMetaItem({ + type: 'object', name: NAME, packageId: Q, mode: 'draft', item: gadgetBody('Gadget Q draft'), + })).success).toBe(true); + // Then the package-less definition, which the sentinel owns, with two records. + expect((await save(k, undefined, 'Gadget unbound')).success).toBe(true); + await k.engine.insert(NAME, { name: 'rec-1' }, SYSTEM); + await k.engine.insert(NAME, { name: 'rec-2' }, SYSTEM); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + + const err = await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q }) + .then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(err.packageId).toBe(Q); + expect(err.ownerPackageId).toBeNull(); + expect(err.message).toMatch(new RegExp( + `^Move object '${NAME}' into package '${Q.replace(/\./g, '\\.')}' first: delete its package-less ` + + `definition without dropStorage, .*then POST /api/v1/meta/object/${NAME}/publish\\?package=` + + `${Q.replace(/\./g, '\\.')}: the draft then serves the object at once`, + )); + expect(await served(k)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound', metaReadLabel: 'Gadget unbound', records: 2, + }); + + // The order the refusal names: the delete, then the publish naming Q. + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(k.engine.registry.getObjectOwner(NAME)).toBeUndefined(); + expect((await k.protocol.publishMetaItem({ type: 'object', name: NAME, packageId: Q })).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q draft', metaReadLabel: 'Gadget Q draft', records: 2, + }); + expect(drops).toEqual([]); + }); + + it('CONTROL: a save naming no package over the package-less owner is unchanged', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k1 = await seedPackagelessOwner(stores, drops); + + const res = await save(k1, undefined, 'Gadget unbound v2'); + + expect(res.success).toBe(true); + expect(storedBindings(stores)).toEqual([null]); + expect(await served(k1)).toEqual({ + owner: SENTINEL, dataPlaneLabel: 'Gadget unbound v2', metaReadLabel: 'Gadget unbound v2', records: 2, + }); + }); +});