diff --git a/.changeset/23646-adopt-orphans-object-registry-owner.md b/.changeset/23646-adopt-orphans-object-registry-owner.md new file mode 100644 index 00000000000..871b1e3d205 --- /dev/null +++ b/.changeset/23646-adopt-orphans-object-registry-owner.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): an orphan adoption moves each adopted object's registry owner and definition to the target package in the same request (ADR-0070 D5) + +Clause-②: no + +`reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, rebinds every package-less `sys_metadata` row to the target package. For an object, only the stored row moved. The registry kept the owner that the package-less row had registered (the `sys_metadata` sentinel) and the pre-adoption definition until the next restart. Until then, a save of the object naming the target package answered success and never went live: its new fields were not on the data plane. + +Each adopted object row now goes through the same registry write-through a save uses, in the same request. The package-less row's own registration leaves the registry first (`SchemaRegistry.unregisterObject`, the verb the delete heal already uses), so the object keeps exactly one owner (ADR-0029 D3). Right after the adoption, the target package owns the object, the data plane serves its definition stamped with that package, and the package's object list includes it. A save naming the target package then goes live at once, and the next boot registers the same owner. + +What stays as before: + +- The adoption's request and response shapes, and which rows it rebinds. +- A package-less row of an object that another package's row owns is rebound in storage only, and the registry keeps that owner. The next boot judges the two rows, as before. +- If another package extends the object, the registry refuses to drop the old owner. The adoption then logs one `error` naming the object, and the registry keeps the old owner until the next restart. +- Views follow their rebound row as before. Other types are rebound in storage only. 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 a40a9a051ec..30d1c434996 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 @@ -63,9 +63,11 @@ // 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. +// `'view'` or [#23646] `'object'` — the latter +// once `releaseAdoptedObjectOwner` has handed +// the adopted row the name. A legacy row stored +// as `'objects'` therefore reaches +// `applyObjectRegistryMutation` as `'object'`. // 7. `restoreArtifactRegistryView` (#23297) folds at the call site through // `canonicalMetaType`, the key it also reads // the stored rows under, and registers a row @@ -528,7 +530,11 @@ describe('[#8862] every object registry write-through registers under the singul ); }); - it('the orphan adoption writes a stored `views` row through as `view`, and an orphaned `objects` row not at all', async () => { + // [#23646] The orphaned object used to be rebound in storage only, and + // this case pinned that (`registeredObjects` empty). It now pins the fold + // the adoption's object limb applies: a stored `objects` row registers + // under `object`, never under the plural it is stored as. + it('the orphan adoption writes a stored `views` row through as `view`, and a stored `objects` row as `object`', 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; @@ -542,8 +548,9 @@ describe('[#8862] every object registry write-through registers under the singul 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([]); + expect(h.registeredItems).toEqual([{ type: 'object', name: 'ticket' }, { type: 'view', name: 'ticket_grid' }]); + expect(h.registeredItems).not.toContainEqual(expect.objectContaining({ type: 'objects' })); + expect(h.registeredObjects).toEqual(['ticket']); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4d7097ccde6..3a0c26243aa 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -20527,7 +20527,8 @@ export class ObjectStackProtocolImplementation implements * 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`. + * view row it rebinds, with `reboundFrom`, and [#23646] for each object + * row, once {@link releaseAdoptedObjectOwner} has handed it the name. */ private async applyRegistryWriteThrough(request: { type: string; @@ -27607,7 +27608,25 @@ export class ObjectStackProtocolImplementation implements * 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 + * with it ({@link reconcileViewExpansions}). + * + * ## [#23646] …and so does each object row it rebinds + * + * An object row used to be rebound in storage only. The registry kept the + * owner the package-less row had registered (the `'sys_metadata'` + * sentinel) and the pre-adoption definition until the next restart, and + * every save of the object naming the target package answered success + * without going live: the write-through's `own` claim under the target met + * the registry's single-owner rule (ADR-0029 D3) and landed in + * {@link applyObjectRegistryMutation}'s best-effort warn. Measured over + * `POST /api/v1/packages/:id/adopt-orphans` on a booted CRM example: the + * data plane refused the saved object's new field with `400 INVALID_FIELD`. + * So the sentinel's contribution leaves the registry first + * ({@link releaseAdoptedObjectOwner}), and the row registers at its new + * binding through the same write-through, in the same request; the next + * boot registers the same owner. Only an ACTIVE object row is registered, + * the rows the boot registers: an adopted draft is rebound in storage + * only, because a draft never reaches the runtime. Rows of other types are * rebound in storage only, as before. */ async reassignOrphanedMetadata(request: { @@ -27681,9 +27700,9 @@ export class ObjectStackProtocolImplementation implements continue; } // [#23198] The registry follows the rebound row now, not at the - // next restart; see the header. ⛔ Views only. + // next restart; see the header. [#23646] A view, and an object. const type = canonicalMetaType(String(row.type)); - if (type !== 'view') continue; + if (type !== 'view' && type !== 'object') continue; let rebound: { name: string; data: unknown }; try { // The row as the list read hydrates it, at its new binding. @@ -27694,6 +27713,16 @@ export class ObjectStackProtocolImplementation implements console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`); continue; } + // [#23646] An object's one registry owner moves with its row. Only + // an active row's: the boot registers no other ({@link + // loadMetaFromDb}), and a draft never reaches the runtime. + if ( + type === 'object' + && (row.state !== 'active' + || !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId)) + ) { + continue; + } await this.applyRegistryWriteThrough({ type, name: rebound.name, @@ -27734,6 +27763,67 @@ export class ObjectStackProtocolImplementation implements }; } + /** + * [#23646, ADR-0070 D5] Hand an adopted object's registry name over to its + * rebound row: answers whether {@link reassignOrphanedMetadata} registers + * the row at its new binding through {@link applyRegistryWriteThrough}. + * + * An object has ONE owner per name in the registry (ADR-0029 D3), and a + * package-less row registers as its owner under the `'sys_metadata'` + * sentinel (the save's write-through and the boot's hydration both key a + * package-less row so). A registration at the new binding while that owner + * stands is a second package's `own` claim, which `registerObject` refuses. + * So the sentinel's contribution leaves first, through + * `SchemaRegistry.unregisterObject` — the verb + * {@link restoreArtifactRegistryView} already hands an object's name to a + * remaining row with — and the write-through then registers the row as a + * save of it does. Both run before the adoption answers, with no `await` + * between them, so no request sees the name unowned. No new registry verb + * exists for this, and none is needed. + * + * | the name's registered owner | answer | + * |:--|:--| + * | the sentinel (the adopted row's own registration) | unregistered; `true` | + * | none | `true`: the row registers as the next boot would register it | + * | another package's row | `false`: the adopted row never held the name; the registry keeps that owner, as before, and the next boot judges both rows | + * + * A code-shipped object is never adopted ({@link isArtifactBacked} filters + * it out first), so a packaged owner does not reach this table. + * + * `unregisterObject` refuses an object another package extends (its + * ADR-0029 guard). The registry then keeps the sentinel's owner and the + * pre-adoption definition until the next restart while storage says + * otherwise, which is why that refusal is logged at `error`, once per + * object, with the consequence and the remedy. + * + * `false` for a registry with no object half (a metadata-only double) and + * for a body with no name, which register nothing. + */ + private releaseAdoptedObjectOwner(name: string, item: unknown, targetPackageId: string): boolean { + const registry: any = (this.engine as any)?.registry; + if (typeof registry?.registerObject !== 'function') return false; + if (!item || typeof item !== 'object' || typeof (item as { name?: unknown }).name !== 'string') return false; + const owner: { packageId?: unknown } | undefined = typeof registry.getObjectOwner === 'function' + ? registry.getObjectOwner(name) + : undefined; + if (owner === undefined) return true; + if (owner.packageId !== 'sys_metadata' || typeof registry.unregisterObject !== 'function') return false; + try { + registry.unregisterObject(name); + return true; + } catch (err: any) { + console.error( + `[Protocol] reassignOrphanedMetadata: object '${name}' was adopted into package ` + + `'${targetPackageId}' in sys_metadata, but the registry keeps its package-less definition ` + + `(owner 'sys_metadata') until the next restart: ${err?.message ?? err}. Until then the data ` + + `plane serves the pre-adoption definition, and a save of the object naming '${targetPackageId}' ` + + 'answers success without going live. Fix: restart the server; the boot registers the object ' + + `under '${targetPackageId}'.`, + ); + return false; + } + } + // ───────────────────────────────────────────────────────────────────── // ADR-0067 — package-scoped commit history & rollback // ───────────────────────────────────────────────────────────────────── diff --git a/packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts b/packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts new file mode 100644 index 00000000000..4bbcb82f2e2 --- /dev/null +++ b/packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts @@ -0,0 +1,367 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23646, ADR-0070 D5 — an orphan adoption leaves the registry agreeing with + * storage for every object it adopts, in the same request, with no restart. + * + * ## The defect + * + * `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans` + * calls) rebinds each package-less `sys_metadata` row to the target package Q + * in storage. Its registry write-through followed view rows only, so an + * adopted runtime object kept the owner its package-less row registered (the + * `'sys_metadata'` sentinel) and its pre-adoption definition until the next + * boot. Meanwhile a save naming Q answered `200` and never went live: the + * write-through's `registerObject(…, Q, …, 'own')` met the registry's + * single-owner rule (ADR-0029 D3) and landed in a best-effort warn. Measured + * on `origin/main` `57994c311f` at protocol level and over the HTTP route on a + * booted CRM example. + * + * ## The fix the pins hold + * + * For each adopted object row, the orphan's own contribution leaves the + * registry (`SchemaRegistry.unregisterObject`, the verb the delete heal hands + * an object's name to a remaining row with) and the row registers at its new + * binding through the one write-through every save uses. The registry holds + * one owner per name throughout; no new registry verb exists. + * + * ## Why a real kernel, and three 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; another kernel over the same store is a boot. + * + * ## The pins + * + * - adopt, then read the owner and the served definition: Q at once, for an + * object saved in this process and for one hydrated at boot; + * - a save naming Q after the adoption goes live; + * - a boot after that agrees; + * - an adopted draft object row registers nothing, as the boot registers + * none; + * - an object another package extends keeps its old owner (the registry + * refuses to drop it), said once at `error`; + * - CONTROL: an adoption of non-object items leaves the object registry + * untouched, and the view write-through follows the view as before; + * - CONTROL: a package-less row of an object another package's row owns is + * rebound in storage and the registry keeps that owner, as before. + * + * The negative control (the object limb removed, these pins red) is a one-off + * ablation recorded in the PR body, not a test. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +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; +} + +/** The data plane read, as the system reads it: typed, never erased (`check:query-options-erasure`). */ +const SYSTEM_READ: EngineReadOptions = { context: { isSystem: true } }; + +const Q = 'com.qa.q'; +const R = 'com.qa.r'; +const NAME = 'qa_probe_gadget'; +const VIEW = 'qa_probe_gadget_grid'; +const SENTINEL = 'sys_metadata'; + +/** A runtime object body; `label` tells the writes apart, `extra` adds a field. */ +const gadgetBody = (label: string, extra?: string) => ({ + name: NAME, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + ...(extra ? { [extra]: { name: extra, type: 'text', label: 'Extra' } } : {}), + }, +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; +} + +describe('#23646 — an orphan adoption moves an adopted object\'s registry owner with its row', () => { + const live: ObjectKernel[] = []; + afterEach(async () => { + vi.restoreAllMocks(); + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + }); + + /** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */ + async function boot(stores: Stores, drops: string[]): 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); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + return { kernel, engine, protocol: kernel.getService('protocol') }; + } + + /** The bindings of `name`'s stored rows of `type`, as the driver holds them. */ + const storedBindings = (stores: Stores, type = 'object', name = NAME) => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === type && r.name === name) + .map((r) => r.package_id ?? null); + + const save = (k: Booted, packageId: string | undefined, label: string, extra?: string) => + k.protocol.saveMetaItem({ + type: 'object', name: NAME, ...(packageId ? { packageId } : {}), item: gadgetBody(label, extra), + }); + + /** + * The answers that must agree with storage: the registry's owner, the + * definition the data plane dispatches on (the registry's object, its label + * and its package stamp), the metadata read's served row, and the records. + */ + async function served(k: Booted) { + const read = await k.protocol.getMetaItem({ type: 'object', name: NAME }); + const records = await k.engine.find(NAME, {}, SYSTEM_READ); + const object = k.engine.registry.getObject(NAME) as { label?: string; _packageId?: string } | undefined; + return { + owner: k.engine.registry.getObjectOwner(NAME)?.packageId, + dataPlaneLabel: object?.label, + dataPlanePackage: object?._packageId, + metaReadLabel: (read?.item ?? read)?.label, + records: records.length, + }; + } + + /** The object saved naming no package, with two records in its table: the sentinel owns it. */ + async function seedPackagelessObject(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' }, { context: { isSystem: true } } as any); + await k.engine.insert(NAME, { name: 'rec-2' }, { context: { isSystem: true } } as any); + expect(storedBindings(stores)).toEqual([null]); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + return k; + } + + it.each([ + ['saved in this process', false], + ['hydrated at boot', true], + ])('an adopted object (%s): Q owns it at once, a save naming Q goes live, and a boot agrees', async (_how, reboot) => { + const stores: Stores = new Map(); + const drops: string[] = []; + let k = await seedPackagelessObject(stores, drops); + if (reboot) { + await k.kernel.shutdown(); + k = await boot(stores, drops); + expect(await served(k)).toMatchObject({ owner: SENTINEL, dataPlanePackage: SENTINEL }); + } + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'object', name: NAME }] }); + expect(storedBindings(stores)).toEqual([Q]); + // The same request: the registry agrees with storage, with no restart. + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget unbound', dataPlanePackage: Q, metaReadLabel: 'Gadget unbound', records: 2, + }); + // One owner, and Q's package filter lists it (the sidebar's read). + expect(k.engine.registry.getObjectContributors(NAME).filter((c) => c.ownership === 'own')).toHaveLength(1); + expect(k.engine.registry.getAllObjects(Q).map((o) => o.name)).toContain(NAME); + + // A save naming Q goes live: its new field is on the data plane at once. + expect((await save(k, Q, 'Gadget Q', 'serial')).success).toBe(true); + expect(storedBindings(stores)).toEqual([Q]); + expect(await served(k)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', dataPlanePackage: Q, metaReadLabel: 'Gadget Q', records: 2, + }); + await k.engine.insert(NAME, { name: 'rec-3', serial: 'S-3' }, { context: { isSystem: true } } as any); + + // A boot after that agrees. + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(await served(k2)).toEqual({ + owner: Q, dataPlaneLabel: 'Gadget Q', dataPlanePackage: Q, metaReadLabel: 'Gadget Q', records: 3, + }); + expect(drops).toEqual([]); + }); + + it('an adopted DRAFT object row is rebound in storage only: a draft never reaches the registry', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + // A package-less draft, as a pre-package-first stopgap left it. + await k.engine.insert('sys_metadata', { + type: 'object', name: NAME, state: 'draft', package_id: null, + metadata: JSON.stringify(gadgetBody('Gadget draft')), + }, { context: { isSystem: true } } as any); + const registerObject = vi.spyOn(k.engine.registry, 'registerObject'); + const registerItem = vi.spyOn(k.engine.registry, 'registerItem'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ reassignedCount: 1, reassigned: [{ type: 'object', name: NAME }] }); + expect(storedBindings(stores)).toEqual([Q]); + expect(registerObject).not.toHaveBeenCalled(); + expect(registerItem.mock.calls.filter(([type]) => type === 'object')).toEqual([]); + expect(k.engine.registry.getObject(NAME)).toBeUndefined(); + }); + + it('an object another package extends keeps its old owner, said once at `error` naming the object', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await seedPackagelessObject(stores, drops); + // A code package's extension of the runtime object: `unregisterObject`'s + // ADR-0029 guard refuses to drop an owner another package extends. + k.engine.registry.registerObject({ + name: NAME, fields: { ext_note: { name: 'ext_note', type: 'text', label: 'Note' } }, + } as any, R, undefined, 'extend'); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ success: true, reassignedCount: 1 }); + expect(storedBindings(stores)).toEqual([Q]); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(SENTINEL); + expect(k.engine.registry.getObjectContributors(NAME).map((c) => c.ownership).sort()).toEqual(['extend', 'own']); + const said = error.mock.calls.map(([line]) => String(line)).filter((line) => line.includes(`'${NAME}'`)); + expect(said).toHaveLength(1); + expect(said[0]).toContain(`'${Q}'`); + }); + + it('CONTROL: an adoption of non-object items leaves the object registry untouched, and the view follows as before', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + // A package-less view row, as a pre-package-first stopgap left it. + await k.engine.insert('sys_metadata', { + type: 'view', name: VIEW, state: 'active', package_id: null, + metadata: JSON.stringify({ + name: VIEW, label: 'Gadgets', type: 'grid', data: { provider: 'object', object: NAME }, + }), + }, { context: { isSystem: true } } as any); + const registerObject = vi.spyOn(k.engine.registry, 'registerObject'); + const unregisterObject = vi.spyOn(k.engine.registry, 'unregisterObject'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] }); + expect(storedBindings(stores, 'view', VIEW)).toEqual([Q]); + expect(registerObject).not.toHaveBeenCalled(); + expect(unregisterObject).not.toHaveBeenCalled(); + // The view write-through (#23198), unchanged: the registry serves the + // view at its new binding. + expect((k.engine.registry.getItem('view', VIEW) as { _packageId?: string } | undefined)?._packageId).toBe(Q); + }); + + it('CONTROL: a package-less row of an object another package\'s row owns is rebound in storage, and that owner stays', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + expect((await save(k, R, 'Gadget R')).success).toBe(true); + // Legacy residue beside R's row: a package-less row no door writes today. + await k.engine.insert('sys_metadata', { + type: 'object', name: NAME, state: 'active', package_id: null, + metadata: JSON.stringify(gadgetBody('Gadget residue')), + }, { context: { isSystem: true } } as any); + const registerObject = vi.spyOn(k.engine.registry, 'registerObject'); + const registerItem = vi.spyOn(k.engine.registry, 'registerItem'); + const unregisterObject = vi.spyOn(k.engine.registry, 'unregisterObject'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ reassignedCount: 1, reassigned: [{ type: 'object', name: NAME }] }); + expect(storedBindings(stores).sort()).toEqual([Q, R].sort()); + // Nothing of the residue reaches the registry, in either half. + expect(unregisterObject).not.toHaveBeenCalled(); + expect(registerObject).not.toHaveBeenCalled(); + expect(registerItem.mock.calls.filter(([type]) => type === 'object')).toEqual([]); + expect(k.engine.registry.getObjectOwner(NAME)?.packageId).toBe(R); + const object = k.engine.registry.getObject(NAME) as { label?: string; _packageId?: string } | undefined; + expect({ label: object?.label, pkg: object?._packageId }).toEqual({ label: 'Gadget R', pkg: R }); + }); +});