Skip to content

Commit 266efe1

Browse files
committed
fix(metadata-protocol): an orphan adoption rebinds a draft view in storage only, never registering it live
reassignOrphanedMetadata's view write-through admitted a row by its type alone, so an adopted package-less draft view was served as live until the next boot. One active-only gate now runs before both the view and object limbs (the object limb's behaviour is unchanged). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVE8bSorrxjRHx7edWAdKm
1 parent 4c21e57 commit 266efe1

3 files changed

Lines changed: 264 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
`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.
10+
11+
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.
12+
13+
What stays as before:
14+
15+
- Which rows the adoption rebinds, the stored `state` of each, and the response (`reassignedCount`, `reassigned`).
16+
- An adopted active view is served at its new binding at once, and an adopted active object moves its registry owner, as before.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27624,6 +27624,14 @@ export class ObjectStackProtocolImplementation implements
2762427624
* the rows the boot registers: an adopted draft is rebound in storage
2762527625
* only, because a draft never reaches the runtime. Rows of other types are
2762627626
* rebound in storage only, as before.
27627+
*
27628+
* ## [#23690] The active-only rule holds for a view too
27629+
*
27630+
* The view limb admitted a row by its type alone, so an adopted
27631+
* package-less DRAFT view was registered as live (its draft body, at the
27632+
* new binding) until the next boot dropped it again, against the
27633+
* write-through's own rule that a draft never leaks into the runtime
27634+
* (ADR-0048). One gate before both limbs now admits active rows only.
2762727635
*/
2762827636
async reassignOrphanedMetadata(request: {
2762927637
targetPackageId: string;
@@ -27699,6 +27707,11 @@ export class ObjectStackProtocolImplementation implements
2769927707
// next restart; see the header. [#23646] A view, and an object.
2770027708
const type = canonicalMetaType(String(row.type));
2770127709
if (type !== 'view' && type !== 'object') continue;
27710+
// [#23646, #23690] Only an ACTIVE row reaches the registry: the
27711+
// boot registers no other ({@link loadMetaFromDb}), and a draft
27712+
// never reaches the runtime. An adopted draft is rebound in
27713+
// storage only.
27714+
if (row.state !== 'active') continue;
2770227715
let rebound: { name: string; data: unknown };
2770327716
try {
2770427717
// The row as the list read hydrates it, at its new binding.
@@ -27709,13 +27722,10 @@ export class ObjectStackProtocolImplementation implements
2770927722
console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`);
2771027723
continue;
2771127724
}
27712-
// [#23646] An object's one registry owner moves with its row. Only
27713-
// an active row's: the boot registers no other ({@link
27714-
// loadMetaFromDb}), and a draft never reaches the runtime.
27725+
// [#23646] An object's one registry owner moves with its row.
2771527726
if (
2771627727
type === 'object'
27717-
&& (row.state !== 'active'
27718-
|| !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId))
27728+
&& !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId)
2771927729
) {
2772027730
continue;
2772127731
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23690, ADR-0070 D5 / ADR-0048 — an orphan adoption never makes a DRAFT view
5+
* live: an adopted draft view is rebound in storage only, as the boot treats it.
6+
*
7+
* ## The defect
8+
*
9+
* `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans`
10+
* calls) sends each rebound view row through the registry write-through
11+
* (#23198). The view limb admitted a row by its type alone, so a package-less
12+
* draft view adopted into Q was registered as live (its draft body, stamped
13+
* `_packageId` Q) until the next boot, which registers active rows only
14+
* (`loadMetaFromDb`). `applyRegistryWriteThrough`'s own header: drafts "are a
15+
* staging buffer and must never leak into the runtime". Measured on
16+
* `origin/main` `4c21e57169` before the fix.
17+
*
18+
* ## The pins, on a real kernel (another kernel over the same store is a boot)
19+
*
20+
* - an adopted draft view: before and after the adoption the registry serves
21+
* nothing for it, the stored row is Q's draft, and a boot afterwards agrees;
22+
* - an adopted draft beside an adopted active row of the same view: the
23+
* active body is served at Q, never the draft's;
24+
* - CONTROL: an adopted active view is served at Q at once (#23198), and a
25+
* boot afterwards agrees.
26+
*
27+
* The negative control (the gate removed, the draft pins red) is a one-off
28+
* ablation recorded in the PR body, not a test.
29+
*/
30+
31+
import { randomUUID } from 'node:crypto';
32+
import { afterEach, describe, expect, it, vi } from 'vitest';
33+
import { ObjectKernel } from '@objectstack/core';
34+
import { ObjectQL } from './engine.js';
35+
import { ObjectQLPlugin } from './plugin.js';
36+
37+
type Stores = Map<string, Map<string, Record<string, unknown>>>;
38+
39+
/**
40+
* In-memory driver, copied from `protocol-adopt-orphans-object-registry.test.ts`;
41+
* equality-only WHERE. `dropTable` records the tables a run drops, so a pin can
42+
* say none was.
43+
*/
44+
function makeMemoryDriver(stores: Stores, drops: string[]) {
45+
const storeFor = (obj: string) => {
46+
let s = stores.get(obj);
47+
if (!s) { s = new Map(); stores.set(obj, s); }
48+
return s;
49+
};
50+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
51+
// driver ANDs them.
52+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
53+
if (!where || typeof where !== 'object') return true;
54+
for (const [k, v] of Object.entries(where)) {
55+
if (k === '$and' && Array.isArray(v)) {
56+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
57+
continue;
58+
}
59+
if (k === '$or' && Array.isArray(v)) {
60+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
61+
continue;
62+
}
63+
if (k.startsWith('$')) continue;
64+
const rowVal = row[k];
65+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
66+
const a = rowVal === undefined ? null : rowVal;
67+
const b = expected === undefined ? null : expected;
68+
if (a !== b) return false;
69+
}
70+
return true;
71+
};
72+
const driver: any = {
73+
name: 'memory', version: '0.0.0', supports: {} as any,
74+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
75+
async execute() { return null; },
76+
async find(object: string, ast: any) {
77+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
78+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
79+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
80+
},
81+
async findOne(object: string, ast: any) {
82+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
83+
return null;
84+
},
85+
async create(object: string, data: Record<string, unknown>) {
86+
const id = (data.id as string) ?? `r_${randomUUID()}`;
87+
const row = { ...data, id };
88+
storeFor(object).set(id, row);
89+
return row;
90+
},
91+
async update(object: string, id: string, data: Record<string, unknown>) {
92+
const s = storeFor(object);
93+
const cur = s.get(id);
94+
if (!cur) throw new Error(`not found: ${object}/${id}`);
95+
const updated = { ...cur, ...data, id };
96+
s.set(id, updated);
97+
return updated;
98+
},
99+
async upsert(object: string, data: Record<string, unknown>) {
100+
const id = data.id as string | undefined;
101+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
102+
return this.create(object, data);
103+
},
104+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
105+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
106+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
107+
return Promise.all(rows.map((r) => this.create(object, r)));
108+
},
109+
async bulkUpdate() { return []; }, async bulkDelete() {},
110+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
111+
async commit() {}, async rollback() {},
112+
async dropTable(object: string) { drops.push(object); stores.delete(object); },
113+
};
114+
return driver;
115+
}
116+
117+
/** The data plane read, as the system reads it: typed, never erased (`check:query-options-erasure`). */
118+
119+
const Q = 'com.qa.q';
120+
const VIEW = 'qa_probe_gadget_grid';
121+
122+
/** A view body; `label` tells the rows apart. */
123+
const viewBody = (label: string) => ({
124+
name: VIEW, label, type: 'grid', data: { provider: 'object', object: 'qa_probe_gadget' },
125+
});
126+
127+
interface Booted {
128+
kernel: ObjectKernel;
129+
engine: ObjectQL;
130+
protocol: any;
131+
}
132+
133+
describe('#23690 — an orphan adoption rebinds a draft view in storage only', () => {
134+
const live: ObjectKernel[] = [];
135+
afterEach(async () => {
136+
vi.restoreAllMocks();
137+
while (live.length) {
138+
const kernel = live.pop()!;
139+
if (kernel.getState() === 'running') await kernel.shutdown();
140+
}
141+
});
142+
143+
/** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */
144+
async function boot(stores: Stores, drops: string[]): Promise<Booted> {
145+
// gracefulShutdown:false keeps process signal handlers out of vitest's fork workers (#4250).
146+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
147+
live.push(kernel);
148+
const engine = new ObjectQL();
149+
engine.registerDriver(makeMemoryDriver(stores, drops), true);
150+
await kernel.use(new ObjectQLPlugin({ ql: engine }));
151+
await kernel.bootstrap();
152+
return { kernel, engine, protocol: kernel.getService('protocol') };
153+
}
154+
155+
/** A package-less view row, as a pre-package-first stopgap left it. */
156+
const insertOrphan = (k: Booted, state: 'active' | 'draft', label: string) =>
157+
k.engine.insert('sys_metadata', {
158+
type: 'view', name: VIEW, state, package_id: null, metadata: JSON.stringify(viewBody(label)),
159+
}, { context: { isSystem: true } } as any);
160+
161+
/** The view's stored rows, as the driver holds them. */
162+
const storedRows = (stores: Stores) =>
163+
Array.from(stores.get('sys_metadata')?.values() ?? [])
164+
.filter((r) => r.type === 'view' && r.name === VIEW)
165+
.map((r) => ({ state: r.state, packageId: r.package_id ?? null }));
166+
167+
/** What the registry serves for the view: nothing, or its label and package stamp. */
168+
const served = (k: Booted) => {
169+
const view = k.engine.registry.getItem('view', VIEW) as { label?: string; _packageId?: string } | undefined;
170+
return view === undefined ? undefined : { label: view.label, packageId: view._packageId };
171+
};
172+
173+
it('an adopted DRAFT view: the registry serves nothing before, after, and at the next boot', async () => {
174+
const stores: Stores = new Map();
175+
const drops: string[] = [];
176+
const k = await boot(stores, drops);
177+
await insertOrphan(k, 'draft', 'Draft gadgets');
178+
expect(served(k)).toBeUndefined();
179+
const registerItem = vi.spyOn(k.engine.registry, 'registerItem');
180+
181+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
182+
183+
// The storage rebind and its answer are unchanged.
184+
expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] });
185+
expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]);
186+
// Nothing of the draft reaches the registry.
187+
expect(registerItem.mock.calls.filter(([type]) => type === 'view')).toEqual([]);
188+
expect(served(k)).toBeUndefined();
189+
190+
// A boot afterwards agrees.
191+
await k.kernel.shutdown();
192+
const k2 = await boot(stores, drops);
193+
expect(served(k2)).toBeUndefined();
194+
expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]);
195+
expect(drops).toEqual([]);
196+
});
197+
198+
it('an adopted draft beside an adopted active row of the same view: the active body is served at Q, never the draft', async () => {
199+
const stores: Stores = new Map();
200+
const drops: string[] = [];
201+
const k = await boot(stores, drops);
202+
await insertOrphan(k, 'active', 'Published gadgets');
203+
await insertOrphan(k, 'draft', 'Draft gadgets');
204+
205+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
206+
207+
expect(adopted).toMatchObject({ success: true, reassignedCount: 2 });
208+
expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }, { state: 'draft', packageId: Q }]);
209+
expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q });
210+
211+
await k.kernel.shutdown();
212+
const k2 = await boot(stores, drops);
213+
expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q });
214+
});
215+
216+
it('CONTROL: an adopted ACTIVE view is served at Q at once (#23198), and a boot afterwards agrees', async () => {
217+
const stores: Stores = new Map();
218+
const drops: string[] = [];
219+
const k = await boot(stores, drops);
220+
await insertOrphan(k, 'active', 'Published gadgets');
221+
222+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
223+
224+
expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] });
225+
expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }]);
226+
expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q });
227+
228+
await k.kernel.shutdown();
229+
const k2 = await boot(stores, drops);
230+
expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q });
231+
expect(drops).toEqual([]);
232+
});
233+
});

0 commit comments

Comments
 (0)