Skip to content

Commit c224e18

Browse files
os-zhuangclaude
andauthored
feat(objectql): adopt orphaned metadata into a base — ADR-0070 D5 migration (#2301)
protocol.reassignOrphanedMetadata bulk-rebinds package-less orphans (null/''/ sys_metadata) onto a target base, leaving owned rows untouched. + POST /packages/:id/adopt-orphans. +tests. Migration affordance to retire the Local/Custom stopgap scope (D5). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9363b4 commit c224e18

4 files changed

Lines changed: 113 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(objectql): adopt orphaned metadata into a base — ADR-0070 D5 migration
7+
8+
`protocol.reassignOrphanedMetadata` bulk-rebinds every package-less orphan
9+
(`package_id` null / `""` / the `sys_metadata` sentinel left by the pre-
10+
package-first stopgaps) onto a target base, leaving already-owned rows
11+
untouched. Exposed as `POST /packages/:id/adopt-orphans`. This is the migration
12+
affordance behind retiring the "Local / Custom" scope (D5): once an env has no
13+
orphans, that scope can be dropped from the selector. Pairs with the kernel's
14+
`writable_package_required` (D1) so no NEW orphans are created.

packages/objectql/src/protocol-package-lifecycle.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,32 @@ describe('protocol.duplicatePackage (ADR-0070 D4)', () => {
177177
expect(calls.some((c: any) => /iojn_/.test(c.name))).toBe(false); // no un-renamed leftovers
178178
});
179179
});
180+
181+
describe('protocol.reassignOrphanedMetadata (ADR-0070 D5)', () => {
182+
it('rebinds package-less orphans (null / "" / sys_metadata) into the target base; leaves owned rows', async () => {
183+
const rows = [
184+
{ id: 'r1', type: 'object', name: 'loose_a', package_id: null },
185+
{ id: 'r2', type: 'view', name: 'loose_v', package_id: 'sys_metadata' },
186+
{ id: 'r3', type: 'object', name: 'blank', package_id: '' },
187+
{ id: 'r4', type: 'object', name: 'owned', package_id: 'app.existing' },
188+
];
189+
const update = vi.fn(async () => ({ id: 'x' }));
190+
const engine = { find: vi.fn(async () => rows), update };
191+
const protocol = new ObjectStackProtocolImplementation(engine as never);
192+
const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.home' });
193+
194+
expect(res).toMatchObject({ success: true, reassignedCount: 3, targetPackageId: 'app.home' });
195+
const movedIds = update.mock.calls.map((c: any) => c[2].where.id);
196+
expect(movedIds).toEqual(['r1', 'r2', 'r3']); // the 3 orphans, not the owned r4
197+
expect(update).toHaveBeenCalledWith('sys_metadata', { package_id: 'app.home' }, { where: { id: 'r1' } });
198+
expect(res.reassigned.some((x) => x.name === 'owned')).toBe(false);
199+
});
200+
201+
it('no orphans → reassignedCount 0, success false', async () => {
202+
const engine = { find: vi.fn(async () => [{ id: 'r1', type: 'object', name: 'x', package_id: 'app.a' }]), update: vi.fn() };
203+
const protocol = new ObjectStackProtocolImplementation(engine as never);
204+
const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.home' });
205+
expect(res).toMatchObject({ success: false, reassignedCount: 0 });
206+
expect((engine.update as any)).not.toHaveBeenCalled();
207+
});
208+
});

packages/objectql/src/protocol.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4580,6 +4580,54 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
45804580
};
45814581
}
45824582

4583+
/**
4584+
* ADR-0070 D5 — adopt orphaned (package-less) metadata into a base. The
4585+
* pre-package-first stopgaps left runtime-authored items with
4586+
* `package_id = null` (or the `sys_metadata` sentinel). This bulk-rebinds
4587+
* every such orphan to `targetPackageId` so the env converges on the
4588+
* package-first model and the "Local / Custom" migration scope can be
4589+
* retired. Owned rows (already bound to a real package) are left untouched.
4590+
* Updates the durable column; the in-memory registry picks the new binding
4591+
* up on the next metadata reload.
4592+
*/
4593+
async reassignOrphanedMetadata(request: {
4594+
targetPackageId: string;
4595+
organizationId?: string;
4596+
actor?: string;
4597+
}): Promise<{
4598+
success: boolean;
4599+
reassignedCount: number;
4600+
reassigned: Array<{ type: string; name: string }>;
4601+
targetPackageId: string;
4602+
}> {
4603+
const where: Record<string, unknown> = {};
4604+
if (request.organizationId) where.organization_id = request.organizationId;
4605+
const rows = (await this.engine.find('sys_metadata', { where })) as any[];
4606+
const orphans = rows.filter(
4607+
(r) => r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata',
4608+
);
4609+
4610+
const reassigned: Array<{ type: string; name: string }> = [];
4611+
for (const row of orphans) {
4612+
try {
4613+
await this.engine.update(
4614+
'sys_metadata',
4615+
{ package_id: request.targetPackageId },
4616+
{ where: { id: row.id } },
4617+
);
4618+
reassigned.push({ type: row.type, name: row.name });
4619+
} catch {
4620+
/* skip a row that fails to update; report only what moved */
4621+
}
4622+
}
4623+
return {
4624+
success: reassigned.length > 0,
4625+
reassignedCount: reassigned.length,
4626+
reassigned,
4627+
targetPackageId: request.targetPackageId,
4628+
};
4629+
}
4630+
45834631
// ─────────────────────────────────────────────────────────────────────
45844632
// ADR-0067 — package-scoped commit history & rollback
45854633
// ─────────────────────────────────────────────────────────────────────

packages/runtime/src/http-dispatcher.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,6 +1912,28 @@ export class HttpDispatcher {
19121912
return { handled: true, response: this.success(manifest) };
19131913
}
19141914

1915+
// POST /packages/:id/adopt-orphans → bulk-rebind package-less (legacy
1916+
// null / 'sys_metadata') metadata INTO this base (ADR-0070 D5 migration;
1917+
// lets the env retire the "Local / Custom" scope once it has no orphans).
1918+
if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') {
1919+
const id = decodeURIComponent(parts[0]);
1920+
const protocol = await this.resolveService('protocol');
1921+
if (!protocol || typeof (protocol as any).reassignOrphanedMetadata !== 'function') {
1922+
return { handled: true, response: this.error('Orphan adoption not supported', 501) };
1923+
}
1924+
try {
1925+
const organizationId = await this.resolveActiveOrganizationId(_context);
1926+
const result = await (protocol as any).reassignOrphanedMetadata({
1927+
targetPackageId: id,
1928+
...(organizationId ? { organizationId } : {}),
1929+
...(body?.actor ? { actor: body.actor } : {}),
1930+
});
1931+
return { handled: true, response: this.success(result) };
1932+
} catch (e: any) {
1933+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
1934+
}
1935+
}
1936+
19151937
// POST /packages/:id/duplicate → clone this base into a NEW writable
19161938
// package, re-namespacing objects + rewriting references (ADR-0070 D4
19171939
// "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }.

0 commit comments

Comments
 (0)