From eae4610aa6f867277dea14c46576d8933773444d Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 00:01:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(objectql):=20adopt=20orphaned=20metadata?= =?UTF-8?q?=20into=20a=20base=20=E2=80=94=20ADR-0070=20D5=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/adr-0070-d5-orphan-migration.md | 14 ++++++ .../src/protocol-package-lifecycle.test.ts | 29 +++++++++++ packages/objectql/src/protocol.ts | 48 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 22 +++++++++ 4 files changed, 113 insertions(+) create mode 100644 .changeset/adr-0070-d5-orphan-migration.md diff --git a/.changeset/adr-0070-d5-orphan-migration.md b/.changeset/adr-0070-d5-orphan-migration.md new file mode 100644 index 0000000000..97223ea592 --- /dev/null +++ b/.changeset/adr-0070-d5-orphan-migration.md @@ -0,0 +1,14 @@ +--- +"@objectstack/objectql": patch +"@objectstack/runtime": patch +--- + +feat(objectql): adopt orphaned metadata into a base — ADR-0070 D5 migration + +`protocol.reassignOrphanedMetadata` bulk-rebinds every package-less orphan +(`package_id` null / `""` / the `sys_metadata` sentinel left by the pre- +package-first stopgaps) onto a target base, leaving already-owned rows +untouched. Exposed as `POST /packages/:id/adopt-orphans`. This is the migration +affordance behind retiring the "Local / Custom" scope (D5): once an env has no +orphans, that scope can be dropped from the selector. Pairs with the kernel's +`writable_package_required` (D1) so no NEW orphans are created. diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts index 11cd5baca8..7695a575fb 100644 --- a/packages/objectql/src/protocol-package-lifecycle.test.ts +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -177,3 +177,32 @@ describe('protocol.duplicatePackage (ADR-0070 D4)', () => { expect(calls.some((c: any) => /iojn_/.test(c.name))).toBe(false); // no un-renamed leftovers }); }); + +describe('protocol.reassignOrphanedMetadata (ADR-0070 D5)', () => { + it('rebinds package-less orphans (null / "" / sys_metadata) into the target base; leaves owned rows', async () => { + const rows = [ + { id: 'r1', type: 'object', name: 'loose_a', package_id: null }, + { id: 'r2', type: 'view', name: 'loose_v', package_id: 'sys_metadata' }, + { id: 'r3', type: 'object', name: 'blank', package_id: '' }, + { id: 'r4', type: 'object', name: 'owned', package_id: 'app.existing' }, + ]; + const update = vi.fn(async () => ({ id: 'x' })); + const engine = { find: vi.fn(async () => rows), update }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.home' }); + + expect(res).toMatchObject({ success: true, reassignedCount: 3, targetPackageId: 'app.home' }); + const movedIds = update.mock.calls.map((c: any) => c[2].where.id); + expect(movedIds).toEqual(['r1', 'r2', 'r3']); // the 3 orphans, not the owned r4 + expect(update).toHaveBeenCalledWith('sys_metadata', { package_id: 'app.home' }, { where: { id: 'r1' } }); + expect(res.reassigned.some((x) => x.name === 'owned')).toBe(false); + }); + + it('no orphans → reassignedCount 0, success false', async () => { + const engine = { find: vi.fn(async () => [{ id: 'r1', type: 'object', name: 'x', package_id: 'app.a' }]), update: vi.fn() }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.home' }); + expect(res).toMatchObject({ success: false, reassignedCount: 0 }); + expect((engine.update as any)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 9b7dd3171e..62125506c4 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -4580,6 +4580,54 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } + /** + * ADR-0070 D5 — adopt orphaned (package-less) metadata into a base. The + * pre-package-first stopgaps left runtime-authored items with + * `package_id = null` (or the `sys_metadata` sentinel). This bulk-rebinds + * 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. + */ + async reassignOrphanedMetadata(request: { + targetPackageId: string; + organizationId?: string; + actor?: string; + }): Promise<{ + success: boolean; + reassignedCount: number; + reassigned: Array<{ type: string; name: string }>; + targetPackageId: string; + }> { + const where: Record = {}; + if (request.organizationId) where.organization_id = request.organizationId; + const rows = (await this.engine.find('sys_metadata', { where })) as any[]; + const orphans = rows.filter( + (r) => r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata', + ); + + const reassigned: Array<{ type: string; name: string }> = []; + for (const row of orphans) { + try { + await this.engine.update( + 'sys_metadata', + { package_id: request.targetPackageId }, + { where: { id: row.id } }, + ); + reassigned.push({ type: row.type, name: row.name }); + } catch { + /* skip a row that fails to update; report only what moved */ + } + } + return { + success: reassigned.length > 0, + reassignedCount: reassigned.length, + reassigned, + targetPackageId: request.targetPackageId, + }; + } + // ───────────────────────────────────────────────────────────────────── // ADR-0067 — package-scoped commit history & rollback // ───────────────────────────────────────────────────────────────────── diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index dae9ac59c5..43c0f37076 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1912,6 +1912,28 @@ export class HttpDispatcher { return { handled: true, response: this.success(manifest) }; } + // POST /packages/:id/adopt-orphans → bulk-rebind package-less (legacy + // null / 'sys_metadata') metadata INTO this base (ADR-0070 D5 migration; + // lets the env retire the "Local / Custom" scope once it has no orphans). + if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { + const id = decodeURIComponent(parts[0]); + const protocol = await this.resolveService('protocol'); + if (!protocol || typeof (protocol as any).reassignOrphanedMetadata !== 'function') { + return { handled: true, response: this.error('Orphan adoption not supported', 501) }; + } + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const result = await (protocol as any).reassignOrphanedMetadata({ + targetPackageId: id, + ...(organizationId ? { organizationId } : {}), + ...(body?.actor ? { actor: body.actor } : {}), + }); + return { handled: true, response: this.success(result) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + // POST /packages/:id/duplicate → clone this base into a NEW writable // package, re-namespacing objects + rewriting references (ADR-0070 D4 // "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }.