Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/adr-0070-d5-orphan-migration.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/objectql/src/protocol-package-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
48 changes: 48 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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
// ─────────────────────────────────────────────────────────────────────
Expand Down
22 changes: 22 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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? }.
Expand Down