diff --git a/.changeset/23690-adopt-orphans-draft-view.md b/.changeset/23690-adopt-orphans-draft-view.md new file mode 100644 index 00000000000..3db4eb4fdaa --- /dev/null +++ b/.changeset/23690-adopt-orphans-draft-view.md @@ -0,0 +1,16 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): an orphan adoption no longer makes a draft view live; an adopted draft view is rebound in storage only (ADR-0070 D5, ADR-0048) + +Clause-②: no + +`reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, sends each rebound view row through the registry write-through so the registry follows it at once. It admitted a view row whatever its `state`. So adopting a package-less DRAFT view registered its draft body as live, stamped with the target package, until the next restart. The boot registers active rows only, so it dropped the view again. A draft adopted beside a published row of the same view replaced the published body the runtime served. + +Only an active row now reaches the registry, for a view as for an object. An adopted draft view is rebound in storage only: the registry serves nothing for it until it is published, and the next boot agrees. + +What stays as before: + +- Which rows the adoption rebinds, the stored `state` of each, and the response (`reassignedCount`, `reassigned`). +- An adopted active view is served at its new binding at once, and an adopted active object moves its registry owner, as before. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7b25a896d02..bca2b16b867 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -27627,6 +27627,14 @@ export class ObjectStackProtocolImplementation implements * 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. + * + * ## [#23690] The active-only rule holds for a view too + * + * The view limb admitted a row by its type alone, so an adopted + * package-less DRAFT view was registered as live (its draft body, at the + * new binding) until the next boot dropped it again, against the + * write-through's own rule that a draft never leaks into the runtime + * (ADR-0048). One gate before both limbs now admits active rows only. */ async reassignOrphanedMetadata(request: { targetPackageId: string; @@ -27702,6 +27710,11 @@ export class ObjectStackProtocolImplementation implements // next restart; see the header. [#23646] A view, and an object. const type = canonicalMetaType(String(row.type)); if (type !== 'view' && type !== 'object') continue; + // [#23646, #23690] Only an ACTIVE row reaches the registry: the + // boot registers no other ({@link loadMetaFromDb}), and a draft + // never reaches the runtime. An adopted draft is rebound in + // storage only. + if (row.state !== 'active') continue; let rebound: { name: string; data: unknown }; try { // The row as the list read hydrates it, at its new binding. @@ -27712,13 +27725,10 @@ 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. + // [#23646] An object's one registry owner moves with its row. if ( type === 'object' - && (row.state !== 'active' - || !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId)) + && !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId) ) { continue; } diff --git a/packages/objectql/src/protocol-adopt-orphans-draft-view-registry.test.ts b/packages/objectql/src/protocol-adopt-orphans-draft-view-registry.test.ts new file mode 100644 index 00000000000..b5f559d8702 --- /dev/null +++ b/packages/objectql/src/protocol-adopt-orphans-draft-view-registry.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23690, ADR-0070 D5 / ADR-0048 — an orphan adoption never makes a DRAFT view + * live: an adopted draft view is rebound in storage only, as the boot treats it. + * + * ## The defect + * + * `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans` + * calls) sends each rebound view row through the registry write-through + * (#23198). The view limb admitted a row by its type alone, so a package-less + * draft view adopted into Q was registered as live (its draft body, stamped + * `_packageId` Q) until the next boot, which registers active rows only + * (`loadMetaFromDb`). `applyRegistryWriteThrough`'s own header: drafts "are a + * staging buffer and must never leak into the runtime". Measured on + * `origin/main` `4c21e57169` before the fix. + * + * ## The pins, on a real kernel (another kernel over the same store is a boot) + * + * - an adopted draft view: before and after the adoption the registry serves + * nothing for it, the stored row is Q's draft, and a boot afterwards agrees; + * - an adopted draft beside an adopted active row of the same view: the + * active body is served at Q, never the draft's; + * - CONTROL: an adopted active view is served at Q at once (#23198), and a + * boot afterwards agrees. + * + * The negative control (the gate removed, the draft 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 } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** + * In-memory driver, copied from `protocol-adopt-orphans-object-registry.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 Q = 'com.qa.q'; +const VIEW = 'qa_probe_gadget_grid'; + +/** A view body; `label` tells the rows apart. */ +const viewBody = (label: string) => ({ + name: VIEW, label, type: 'grid', data: { provider: 'object', object: 'qa_probe_gadget' }, +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; +} + +describe('#23690 — an orphan adoption rebinds a draft view in storage only', () => { + 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 { + // 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') }; + } + + /** A package-less view row, as a pre-package-first stopgap left it. */ + const insertOrphan = (k: Booted, state: 'active' | 'draft', label: string) => + k.engine.insert('sys_metadata', { + type: 'view', name: VIEW, state, package_id: null, metadata: JSON.stringify(viewBody(label)), + }, { context: { isSystem: true } } as any); + + /** The view's stored rows, as the driver holds them. */ + const storedRows = (stores: Stores) => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'view' && r.name === VIEW) + .map((r) => ({ state: r.state, packageId: r.package_id ?? null })); + + /** What the registry serves for the view: nothing, or its label and package stamp. */ + const served = (k: Booted) => { + const view = k.engine.registry.getItem('view', VIEW) as { label?: string; _packageId?: string } | undefined; + return view === undefined ? undefined : { label: view.label, packageId: view._packageId }; + }; + + it('an adopted DRAFT view: the registry serves nothing before, after, and at the next boot', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + await insertOrphan(k, 'draft', 'Draft gadgets'); + expect(served(k)).toBeUndefined(); + const registerItem = vi.spyOn(k.engine.registry, 'registerItem'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + // The storage rebind and its answer are unchanged. + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] }); + expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]); + // Nothing of the draft reaches the registry. + expect(registerItem.mock.calls.filter(([type]) => type === 'view')).toEqual([]); + expect(served(k)).toBeUndefined(); + + // A boot afterwards agrees. + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(served(k2)).toBeUndefined(); + expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]); + expect(drops).toEqual([]); + }); + + it('an adopted draft beside an adopted active row of the same view: the active body is served at Q, never the draft', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + await insertOrphan(k, 'active', 'Published gadgets'); + await insertOrphan(k, 'draft', 'Draft gadgets'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ success: true, reassignedCount: 2 }); + expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }, { state: 'draft', packageId: Q }]); + expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q }); + }); + + it('CONTROL: an adopted ACTIVE view is served at Q at once (#23198), and a boot afterwards agrees', async () => { + const stores: Stores = new Map(); + const drops: string[] = []; + const k = await boot(stores, drops); + await insertOrphan(k, 'active', 'Published gadgets'); + + const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }); + + expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] }); + expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }]); + expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q }); + + await k.kernel.shutdown(); + const k2 = await boot(stores, drops); + expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q }); + expect(drops).toEqual([]); + }); +});