From 14d5045e476d17b3c145d20af8751c7b61f4f72f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:01:26 +0000 Subject: [PATCH 1/4] fix(metadata-protocol): the orphan adoption keeps overlays of packaged items, and the registry follows each view row it rebinds ADR-0070 keeps an overlay of an artifact item bound to the item it customises, so reassignOrphanedMetadata skips a package-less row for which isArtifactBacked holds, the predicate saveMetaItem sets override-artifact by. Each view row it rebinds goes through the write-through with the binding it left, so reconcileViewExpansions moves the row's record and registers its expansion at once. The closing check's bypass row is gone. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 102 ++++++++++++- ...ol.view-container-reset-expansions.test.ts | 138 +++++++++++++++++- ...iew-expansion-reconcile-call-sites.test.ts | 30 +--- 3 files changed, 238 insertions(+), 32 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fed6417cdea..d2367e10bc7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1992,6 +1992,10 @@ type ShippingPackages = { * revert, a replica's convergence, the boot, the list read). `binding` is * the row's package binding, `undefined` for the package-less row, and * `expansion` is everything that row's body expands to now. + * `reboundFrom` is set when the write moved the row from another binding + * (an orphan adoption): `reboundFrom.binding` is the binding it left, + * `undefined` for the package-less row. That binding's record moves to + * `binding`, so the row reconciles against what it registered under either. * - `removed: 'every-row'`: a row of the name was removed (a reset, a delete, * a revert's removal, a replica's removal). The heal is handed no binding, * so it addresses every row of the name, and the set they imply is empty. @@ -2001,9 +2005,29 @@ type ViewContainerWrite = readonly container: string; readonly binding: string | undefined; readonly expansion: ReadonlyArray>; + readonly reboundFrom?: { readonly binding: string | undefined }; } | { readonly container: string; readonly removed: 'every-row' }; +/** + * What a hydration of one stored row is told about it: + * `ObjectStackProtocolImplementation.hydrateOverlayIntoRegistry` and the + * expansion hydration it hands the row to. + * + * - `packageId`: the row's package binding. + * - `organizationId`: the row's scope, `null` for an environment-wide row + * (#6602). + * - `reboundFrom`: [#23198] set only when the write that is being hydrated + * moved the row from another binding (an orphan adoption). `packageId` is + * the binding it left, `null` for the package-less row. See + * {@link ViewContainerWrite}. + */ +type HydrationOptions = { + packageId?: string | null; + organizationId: string | null; + reboundFrom?: { readonly packageId: string | null }; +}; + /** * ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope * onto a returned item so artifact-level lock/packageId/provenance @@ -19031,7 +19055,7 @@ export class ObjectStackProtocolImplementation implements private async hydrateOverlayIntoRegistry( type: string, data: unknown, - options: { packageId?: string | null; organizationId: string | null }, + options: HydrationOptions, ): Promise { // [#9111] See the header: the mint door asserts, the caller folds. // Placed FIRST, ahead of every no-op return below, so the contract is @@ -19525,12 +19549,15 @@ export class ObjectStackProtocolImplementation implements private async hydrateExpandedViewItems( type: string, data: unknown, - options: { packageId?: string | null; organizationId: string | null }, + options: HydrationOptions, ): Promise { await this.reconcileViewExpansions(type, { container: String((data as { name?: unknown }).name), binding: boundPackageOf(options.packageId), expansion: this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true }), + ...(options.reboundFrom === undefined + ? {} + : { reboundFrom: { binding: boundPackageOf(options.reboundFrom.packageId) } }), }, options.organizationId); } @@ -19552,7 +19579,9 @@ export class ObjectStackProtocolImplementation implements * 2. Replace the record of the rows the write addresses with the names * registered in step 1. The old names come out of the record before any * heal runs, so a write that lands meanwhile reconciles against the new - * set. + * set. A rebind (an orphan adoption, {@link reassignOrphanedMetadata}) + * addresses the row under both bindings: the record of the binding it + * left is removed, and its names count as the row's old names. * 3. Heal each old name the new set lacks through the delete's per-name * walk ({@link restoreArtifactRegistryView}): a packaged view of that * name is un-shadowed, a MetadataService baseline is re-registered, and @@ -19604,6 +19633,13 @@ export class ObjectStackProtocolImplementation implements } else { const row = write.binding ?? ''; for (const name of rows.get(row) ?? []) previous.add(name); + // A rebind: what the row registered under the binding it left is + // its own too, and that record row is gone with the binding. + if (write.reboundFrom !== undefined) { + const left = write.reboundFrom.binding ?? ''; + for (const name of rows.get(left) ?? []) previous.add(name); + rows.delete(left); + } if (registered.size > 0) rows.set(row, registered); else rows.delete(row); } @@ -19717,6 +19753,8 @@ export class ObjectStackProtocolImplementation implements * [#23198] Every caller awaits it: a republish of a view container heals * the members it dropped ({@link reconcileViewExpansions}) before the * write answers, so the reads that follow it serve the published set. + * The orphan adoption ({@link reassignOrphanedMetadata}) calls it for each + * view row it rebinds, with `reboundFrom`. */ private async applyRegistryWriteThrough(request: { type: string; @@ -19725,6 +19763,12 @@ export class ObjectStackProtocolImplementation implements packageId?: string | null; /** The row's org scope — `null` for an env-wide row. [#6602] */ organizationId: string | null; + /** + * [#23198] The binding the write moved the row from, when it moved it + * (an orphan adoption): `null` for the package-less row. See + * {@link HydrationOptions}. + */ + reboundFrom?: { readonly packageId: string | null }; }): Promise { // [#8862] The `|| request.type === 'objects'` limb is GONE — this // branch admitted the plural that {@link applyObjectRegistryMutation} @@ -19772,6 +19816,7 @@ export class ObjectStackProtocolImplementation implements await this.hydrateOverlayIntoRegistry(request.type, request.item, { packageId: request.packageId ?? undefined, organizationId: request.organizationId, + ...(request.reboundFrom === undefined ? {} : { reboundFrom: request.reboundFrom }), }); } catch (err: any) { // Best-effort, exactly like the object branch: the row is already @@ -25938,8 +25983,27 @@ export class ObjectStackProtocolImplementation implements * every such orphan to `targetPackageId` so the env converges on the * package-first model and the "Local / Custom" migration scope can be * retired. Owned rows (already bound to a real package) are left untouched. - * Updates the durable column; the in-memory registry picks the new binding - * up on the next metadata reload. + * + * ## [#23198] An overlay of an artifact item is not an orphan + * + * ADR-0070's kernel section: "Overlays of artifact items (`intent = + * override-artifact`, `allowOrgOverride`) are **unchanged** — they keep + * their binding to the packaged item they customise." An orphan is a + * runtime-created item. So a package-less row for which + * {@link isArtifactBacked} holds — the predicate {@link saveMetaItem} sets + * `intent: 'override-artifact'` by — is not adopted, and it stays as + * stored. Adopted, a package-less overlay of a packaged view container + * expanded under its own name in the base, so the packaged view lost the + * customisation and two views were added beside it; an overlay of one + * packaged view was served twice under its name. Both took effect at the + * next restart, and the registry kept the old answer until then. + * + * Each view row it does rebind goes through the write-through, with the + * binding it left ({@link applyRegistryWriteThrough}, `reboundFrom`), so + * the registry follows the row at once: a view container's expansions are + * reconciled for the new binding, and the record of the old one moves + * with it ({@link reconcileViewExpansions}). Rows of other types are + * rebound in storage only, as before. */ async reassignOrphanedMetadata(request: { targetPackageId: string; @@ -25966,7 +26030,10 @@ export class ObjectStackProtocolImplementation implements // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; const orphans = rows.filter( - (r) => r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata', + (r) => (r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata') + // [#23198] An overlay of an artifact item keeps its binding + // (ADR-0070); see the header. The type folds as the save's does. + && !this.isArtifactBacked(PLURAL_TO_SINGULAR[r.type] ?? r.type, String(r.name)), ); const reassigned: Array<{ type: string; name: string }> = []; @@ -26006,7 +26073,30 @@ export class ObjectStackProtocolImplementation implements // silence changes. refusedCount += 1; if (firstRefusal === '') firstRefusal = e?.message ?? String(e); + continue; + } + // [#23198] The registry follows the rebound row now, not at the + // next restart; see the header. ⛔ Views only. + const type = canonicalMetaType(String(row.type)); + if (type !== 'view') continue; + let rebound: { name: string; data: unknown }; + try { + // The row as the list read hydrates it, at its new binding. + [rebound] = this.storedOverlayEntries({ type }, [{ ...row, package_id: request.targetPackageId }]); + } catch (err: any) { + // Best-effort, as the write-through's own failure is: the row + // is rebound, and the next list read hydrates it. + console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`); + continue; } + await this.applyRegistryWriteThrough({ + type, + name: rebound.name, + item: rebound.data, + packageId: request.targetPackageId, + organizationId: null, + reboundFrom: { packageId: row.package_id ?? null }, + }); } if (refusedCount > 0) { // `error` and not `warn`, by the one question AGENTS.md turns this diff --git a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts index e1004709aaa..07a8321c9fb 100644 --- a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts +++ b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts @@ -57,6 +57,21 @@ * the `?object=` list and the by-name read, plus the two rows of one container * name. `protocol.view-expansion-reconcile-call-sites.test.ts` pins that no * write reaches the registry around the reconcile. + * + * ## [#23198] The orphan adoption + * + * `POST /packages/:id/adopt-orphans` (`reassignOrphanedMetadata`) rebound + * every package-less row into the base, the overlays of packaged items + * included, and touched no registry entry. Measured over the real + * `SchemaRegistry`: after a restart, an adopted overlay of the packaged + * container expanded under its own name in the base (the packaged + * `showcase_task.default` served again, `showcase_task.showcase_task` added), + * and an adopted overlay of one packaged view was listed twice under its name. + * ADR-0070 keeps an overlay of an artifact item bound to the item it + * customises, so the third block pins that the adoption leaves both overlays as + * they are, in the same process and after a restart (`boot` over the same + * rows, then `loadMetaFromDb`), and that a true orphan is adopted with the + * registry following its row at once, its record moved to the new binding. */ import { describe, expect, it } from 'vitest'; import { @@ -199,8 +214,8 @@ function makeRegistry() { }; } -function makeEngine() { - const rows = new Map(); +/** `rows` is the store to open: a fresh one, or a restart's, which the last process wrote. */ +function makeEngine(rows = new Map()) { const historyRows: Array> = []; const auditRows: Array> = []; let nextId = 0; @@ -277,8 +292,8 @@ function makeEngine() { * registers the package: the container under the object's name and each view it * expands to under its own, both in the package's slot. */ -function boot(opts: { otherPackageShipsInProgress?: boolean } = {}) { - const { engine, registry, rows } = makeEngine(); +function boot(opts: { otherPackageShipsInProgress?: boolean; rows?: Map } = {}) { + const { engine, registry, rows } = makeEngine(opts.rows); const stamp = (item: T, pkg = PKG) => ({ ...item, _packageId: pkg, _packageVersion: '1.0.0', _provenance: 'package' }); registry.registerItem('view', stamp({ name: OBJ, ...shippedContainer() }), 'name', PKG); @@ -624,3 +639,118 @@ describe('[#23198] after every write the registry holds the expansion set the st expect(await doors(protocol, B_ONLY)).toEqual(servedOnEveryDoor('B only', 1)); }); }); + +const BASE = 'app.home'; +/** What the adopted overlay of the container expanded to, under its own name in the base. */ +const OWN_NAME_VIEW = `${OBJ}.${OBJ}`; +const RT_DEFAULT = `${RT_OBJ}.default`; +const RT_OPEN = `${RT_OBJ}.open`; +const RT_CLOSED = `${RT_OBJ}.closed`; + +/** The package binding of each active row stored under `name`. */ +const storedBindings = (rows: Map, name: string) => + [...rows.values()].filter((r) => r.name === name && r.state === 'active').map((r) => r.package_id ?? null); + +/** A restart: the package registered again, then the stored rows hydrated, over the rows the last process wrote. */ +async function restart(rows: Map) { + const next = boot({ rows }); + expect(await next.protocol.loadMetaFromDb()).toMatchObject({ errors: 0 }); + return next; +} + +/** The record list's View Config Save on one packaged view: read it, relabel it, save a draft, publish. */ +async function publishViewOverlay(protocol: ObjectStackProtocolImplementation, name: string, label: string) { + const read: any = await protocol.getMetaItem({ type: 'view', name }); + const view = stripInternal(read.item) as Record; + view.label = label; + if (view.config) view.config = { ...view.config, label }; + expect(await protocol.saveMetaItem({ type: 'view', name, item: view, mode: 'draft' })).toMatchObject({ success: true }); + expect(await protocol.publishMetaItem({ type: 'view', name })).toMatchObject({ success: true }); +} + +describe('[#23198] the orphan adoption rebinds runtime-created items only, and the registry follows the row at once', () => { + it('reading A: a package-less overlay of the packaged container is not adopted, and keeps serving its customisation', async () => { + const { protocol, rows } = boot(); + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + + const adopted: any = await protocol.reassignOrphanedMetadata({ targetPackageId: BASE }); + + expect(adopted).toMatchObject({ success: false, reassignedCount: 0, reassigned: [] }); + expect(storedBindings(rows, OBJ)).toEqual([null]); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + expect(await doors(protocol, OWN_NAME_VIEW)).toEqual(NOT_SERVED); + + const next = await restart(rows); + expect(await doors(next.protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + expect(await doors(next.protocol, OWN_NAME_VIEW)).toEqual(NOT_SERVED); + }); + + it("reading B: a package-less overlay of one packaged view (the record list's View Config Save) is not adopted, and is served once", async () => { + const { protocol, rows } = boot(); + await publishViewOverlay(protocol, IN_PROGRESS_VIEW, 'In Progress (X)'); + + const adopted: any = await protocol.reassignOrphanedMetadata({ targetPackageId: BASE }); + + expect(adopted).toMatchObject({ success: false, reassignedCount: 0, reassigned: [] }); + expect(storedBindings(rows, IN_PROGRESS_VIEW)).toEqual([null]); + expect(await doors(protocol, IN_PROGRESS_VIEW)).toEqual(servedOnEveryDoor('In Progress (X)', 2)); + + const next = await restart(rows); + expect(await doors(next.protocol, IN_PROGRESS_VIEW)).toEqual(servedOnEveryDoor('In Progress (X)', 2)); + }); + + it('control D: a true orphan beside them is adopted, and the registry serves it from the base before any read', async () => { + const { protocol, registry, rows } = boot(); + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + expect(await protocol.saveMetaItem({ + type: 'view', name: RT_OBJ, item: rtContainer({ open: rtMember('Open') }), + })).toMatchObject({ success: true }); + expect(registry.bare('view', RT_OPEN)?._packageId).toBeUndefined(); + + const adopted: any = await protocol.reassignOrphanedMetadata({ targetPackageId: BASE }); + + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: RT_OBJ }] }); + expect(storedBindings(rows, RT_OBJ)).toEqual([BASE]); + expect(storedBindings(rows, OBJ)).toEqual([null]); + // No read in between: the container and each view it expands to are the base's. + for (const name of [RT_OBJ, RT_DEFAULT, RT_OPEN]) { + expect(registry.bare('view', name)?._packageId, name).toBe(BASE); + } + expect(await doors(protocol, RT_OPEN, RT_OBJ)).toEqual(servedOnEveryDoor('Open', 1)); + expect(await doors(protocol, RT_DEFAULT, RT_OBJ)).toEqual(servedOnEveryDoor('All Tickets', 2)); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + + const next = await restart(rows); + for (const name of [RT_OBJ, RT_DEFAULT, RT_OPEN]) { + expect(next.registry.bare('view', name)?._packageId, name).toBe(BASE); + } + expect(await doors(next.protocol, RT_OPEN, RT_OBJ)).toEqual(servedOnEveryDoor('Open', 1)); + expect(await doors(next.protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + }); + + it("the rebind moves the row's record: a package-less row of that name, written later, heals only what it registered", async () => { + const { protocol, registry, rows } = boot(); + expect(await protocol.saveMetaItem({ + type: 'view', name: RT_OBJ, item: rtContainer({ open: rtMember('Open'), closed: rtMember('Closed') }), + })).toMatchObject({ success: true }); + await protocol.reassignOrphanedMetadata({ targetPackageId: BASE }); + + // A package-less row of the adopted name (ADR-0048: two rows of one + // name), with no members, and the write the door makes after storing it. + const later = rtContainer({}); + const row: Row = { + id: 'later', type: 'view', name: RT_OBJ, organization_id: null, + package_id: null, state: 'active', metadata: JSON.stringify(later), + }; + rows.set(keyOf(row), row); + await (protocol as any).applyRegistryWriteThrough({ + type: 'view', name: RT_OBJ, item: later, packageId: null, organizationId: null, + }); + + // The base row's members are still registered: the package-less + // record left with the adoption, so this write healed none of them. + expect(labelOf(registry.bare('view', RT_OPEN))).toBe('Open'); + expect(labelOf(registry.bare('view', RT_CLOSED))).toBe('Closed'); + expect(registry.bare('view', RT_OPEN)?._packageId).toBe(BASE); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts index ee260c5be06..77d9a5ef5bf 100644 --- a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -28,12 +28,12 @@ * call is pinned with its member in `protocol.register-item-call-sites.test.ts`, * where the expansion's registration sits in the reconcile. * - * One write is recorded as NOT reaching the registry: the orphan adoption's - * rebinding (`reassignOrphanedMetadata`, `POST /packages/:id/adopt-orphans`). - * It changes a row's package binding, which changes what a view container - * expands to, and the registry keeps the old registrations until a restart. - * It is reported for its own card. The row is shrink-only: when that write is - * routed, this turns red until the row moves to a route. + * Every write is routed; the census has no exception kind. The last one, the + * orphan adoption's rebinding (`reassignOrphanedMetadata`, + * `POST /packages/:id/adopt-orphans`), now writes each rebound view row + * through, with the binding it left, so the reconcile moves the row's record + * to its new binding at once. A new write with no route turns this red, and + * the only way to green it is to route that write. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -99,9 +99,7 @@ type Route = /** The member hands the row to its callers, and each of them calls `then`, which calls the write-through. */ | { readonly handedTo: 'callers'; readonly then: 'runPublishSideEffects' } /** An organization-scoped row, which never enters the registry (#6602): nothing to reconcile. */ - | { readonly organizationScoped: true } - /** ⛔ Not reconciled. Shrink-only: a finding, reported for its own card. */ - | { readonly bypass: string }; + | { readonly organizationScoped: true }; const WRITES: ReadonlyArray<{ readonly member: string; readonly verb: string; readonly route: Route }> = [ { member: 'saveMetaItem', verb: 'repo.put(', route: { sameMember: 'applyRegistryWriteThrough' } }, @@ -112,10 +110,7 @@ const WRITES: ReadonlyArray<{ readonly member: string; readonly verb: string; re { member: 'rollbackMetaItem', verb: 'repo.restoreVersion(', route: { sameMember: 'applyRegistryWriteThrough' } }, { member: 'deleteMetaItem', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, { member: 'deleteMetaItem', verb: 'this.engine.delete(\'sys_metadata\'', route: { sameMember: 'restoreArtifactRegistryView' } }, - { - member: 'reassignOrphanedMetadata', verb: 'this.engine.update(\'sys_metadata\'', - route: { bypass: 'rebinds a row to a package; the registry keeps the old binding\'s registrations until a restart' }, - }, + { member: 'reassignOrphanedMetadata', verb: 'this.engine.update(\'sys_metadata\'', route: { sameMember: 'applyRegistryWriteThrough' } }, ]; /** The verb as `WRITES` spells it: no whitespace after its open paren. */ @@ -167,15 +162,6 @@ describe('[#23198] every stored-row write in protocol.ts reaches the reconcile', } }); - it('a recorded bypass still reaches nothing: route it, then move its row', () => { - for (const row of WRITES.filter((r) => 'bypass' in r.route)) { - const body = bodyOf(row.member); - expect(body).not.toContain('this.applyRegistryWriteThrough('); - expect(body).not.toContain('this.restoreArtifactRegistryView('); - expect(body).not.toContain('this.hydrateOverlayIntoRegistry('); - } - }); - it('a repository is bound under the one spelling the census reads', () => { // A repository bound under any other name would write past the census. const ACQUIRE = /this\.(?:getOverlayRepo\(\)|legacyOrganizationRepo\(|repositoryFor\()|new SysMetadataRepository\(/; From ed6d9fbe81b90b5032da1cfd379d137564d13ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:10:01 +0000 Subject: [PATCH 2/4] test(metadata-protocol): after a restart, pin the adopted orphan's expansions, not the container body the boot hydrates as stored Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- .../src/protocol.view-container-reset-expansions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts index 07a8321c9fb..55d3985a3fd 100644 --- a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts +++ b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts @@ -720,8 +720,11 @@ describe('[#23198] the orphan adoption rebinds runtime-created items only, and t expect(await doors(protocol, RT_DEFAULT, RT_OBJ)).toEqual(servedOnEveryDoor('All Tickets', 2)); expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + // After a restart, the views it expands to are the base's too. (The + // boot hydrates the container's own body as stored, without the + // binding stamped on it, as it hydrates every bound row.) const next = await restart(rows); - for (const name of [RT_OBJ, RT_DEFAULT, RT_OPEN]) { + for (const name of [RT_DEFAULT, RT_OPEN]) { expect(next.registry.bare('view', name)?._packageId, name).toBe(BASE); } expect(await doors(next.protocol, RT_OPEN, RT_OBJ)).toEqual(servedOnEveryDoor('Open', 1)); From 6998a4bf8cbe96fd3ef2bca6497e68abe45cf03a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:18:19 +0000 Subject: [PATCH 3/4] chore(changeset): the orphan adoption keeps overlays of packaged items Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- .changeset/23198-orphan-adoption-keeps-overlays.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/23198-orphan-adoption-keeps-overlays.md diff --git a/.changeset/23198-orphan-adoption-keeps-overlays.md b/.changeset/23198-orphan-adoption-keeps-overlays.md new file mode 100644 index 00000000000..bda2ee8fa53 --- /dev/null +++ b/.changeset/23198-orphan-adoption-keeps-overlays.md @@ -0,0 +1,14 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +Adopting loose items into a base no longer moves an overlay of a packaged item, and the views it does move are served from the base at once + +Clause-②: no + +- `POST /api/v1/packages/ID/adopt-orphans` rebound every metadata row bound to no package into the base. That included a row that overlays an item a package ships, such as a list view edited and saved from the record list, or an edited copy of a package's view container. Once bound to the base, such a row stopped customising the packaged item. After a restart, an adopted overlay of a package's view container added views under its own name, and the package's own view was served again. An adopted overlay of one packaged view was listed twice under its name. +- ADR-0070 keeps an overlay of a packaged item bound to the item it customises, so the adoption now leaves such a row as it is stored. A row is skipped exactly when a save of it would be an overlay (`override-artifact`). An environment whose only rows bound to no package are such overlays now answers `reassignedCount: 0` and `success: false`. +- Each view row the adoption does rebind is now written through to the process registry with the binding it left. So `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read serve it from the base right away, not after the next restart. +- A view container's views are reconciled the way a publish reconciles them. What the row registered before the move counts as its own, so a later write of a row of that name bound to no package no longer removes the moved row's views. +- Unchanged: the request and the response shape (`success`, `reassignedCount`, `reassigned`, `targetPackageId`). Also unchanged: rows already bound to a package, organization-scoped rows, and rows of other types, which are rebound in storage only. +- There is no public export, signature or schema change. From 7853bd6c137087d5da44301ee77226b737c30f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:30:17 +0000 Subject: [PATCH 4/4] test(metadata-protocol): the write-through caller set gains the orphan adoption, which writes views through and objects not at all Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude --- ...ct-registry-write-through-spelling.test.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts b/packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts index c4f7d4e6af2..b3756dbe4b3 100644 --- a/packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts +++ b/packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts @@ -38,7 +38,7 @@ // The trace, measured on `origin/main` — all four routes fold at the producer // --------------------------------------------------------------------------- // `applyObjectRegistryMutation` has exactly ONE caller -// (`applyRegistryWriteThrough`), which has exactly FIVE: +// (`applyRegistryWriteThrough`), which has exactly SIX: // // 1. `saveMetaItem` passes `singularTypeForRepo`, and the method // already ran `canonicalizeMetaRequestType`. @@ -60,6 +60,12 @@ // so this caller folds rather than trusting // the wire, even though every in-tree // publisher emits the already-folded singular. +// 6. `reassignOrphanedMetadata` (#23198) folds the STORED row type at the +// call site through `canonicalMetaType`, and +// writes through only a row that folds to +// `'view'`. It never passes `'object'`, so it +// never reaches `applyObjectRegistryMutation`: +// an orphaned object is rebound in storage only. // // Both fold maps resolve the plural — `PLURAL_TO_SINGULAR.objects === 'object'` // and `canonicalMetaUrlType('objects') === 'object'` — so no route can deliver @@ -515,6 +521,24 @@ describe('[#8862] every object registry write-through registers under the singul expect.objectContaining({ type: 'objects' }), ); }); + + it('the orphan adoption writes a stored `views` row through as `view`, and an orphaned `objects` row not at all', async () => { + // An unscoped kernel: the only one on which a view row is written through. + const h = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map()) as any; + await seedRowVerbatim(protocol, { type: 'objects', name: 'ticket', body: objectBody('ticket'), state: 'active' }); + await seedRowVerbatim(protocol, { + type: 'views', name: 'ticket_grid', state: 'active', + body: { name: 'ticket_grid', label: 'Tickets', type: 'grid', data: { provider: 'object', object: 'ticket' } }, + }); + h.registeredItems.length = 0; + + const res = await protocol.reassignOrphanedMetadata({ targetPackageId: PKG }); + + expect(res.reassigned).toEqual([{ type: 'objects', name: 'ticket' }, { type: 'views', name: 'ticket_grid' }]); + expect(h.registeredItems).toEqual([{ type: 'view', name: 'ticket_grid' }]); + expect(h.registeredObjects).toEqual([]); + }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -543,9 +567,9 @@ describe('[#8862] the `applyRegistryWriteThrough` caller set stays closed', () = 'utf8', ); - it('has exactly five call sites, all traced in this file’s header', () => { + it('has exactly six call sites, all traced in this file’s header', () => { const callSites = source.match(/this\.applyRegistryWriteThrough\(/g) ?? []; - expect(callSites).toHaveLength(5); + expect(callSites).toHaveLength(6); }); it('`applyObjectRegistryMutation` is reached only through the write-through', () => {