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
18 changes: 18 additions & 0 deletions .changeset/adr-0070-d4-duplicate-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

feat(objectql): duplicate a writable base — ADR-0070 D4 ("duplicate base")

`protocol.duplicatePackage` clones every ACTIVE item a base owns into a NEW
package, **re-namespacing** object names (the blueprint prefixes a base's object
names with its namespace, e.g. `iojn_repair_ticket`, and `sys_metadata` keys on
`(type,name,org)` so a same-name copy would collide with the source) and
**rewriting every intra-package reference** (lookup `reference`, view `object`,
expressions, …) to the new names via a longest-first, identifier-boundary
replace. Exposed as `POST /packages/:id/duplicate` (body
`{ targetPackageId, targetName?, targetNamespace? }`).

Completes ADR-0070 D4 (package = lifecycle unit): delete-cascade and export
already shipped; this adds the duplicate gesture.
74 changes: 74 additions & 0 deletions packages/objectql/src/protocol-package-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,77 @@ describe('protocol.deletePackage', () => {
expect(res).toMatchObject({ success: false, deletedCount: 0 });
});
});

describe('protocol.duplicatePackage (ADR-0070 D4)', () => {
function makeProtocol() {
const rows = [
{
type: 'object', name: 'iojn_repair_ticket', state: 'active',
metadata: JSON.stringify({
name: 'iojn_repair_ticket', label: 'Ticket',
fields: { title: { type: 'text' }, customer: { type: 'lookup', reference: 'iojn_customer' } },
}),
},
{
type: 'object', name: 'iojn_customer', state: 'active',
metadata: JSON.stringify({ name: 'iojn_customer', label: 'Customer', fields: { full_name: { type: 'text' } } }),
},
{
type: 'view', name: 'iojn_repair_ticket.all', state: 'active',
metadata: JSON.stringify({ name: 'iojn_repair_ticket.all', object: 'iojn_repair_ticket', viewKind: 'list' }),
},
];
const installPackage = vi.fn();
const engine = {
find: vi.fn(async () => rows),
registry: {
getPackage: vi.fn(() => ({
manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application', scope: 'environment' },
})),
installPackage,
},
};
const protocol = new ObjectStackProtocolImplementation(engine as never);
const saveMetaItem = vi.spyOn(protocol, 'saveMetaItem' as never);
(saveMetaItem as any).mockResolvedValue({ success: true } as never);
return { protocol, saveMetaItem, installPackage };
}

it('clones items into a new package, re-namespacing names AND rewriting references', async () => {
const { protocol, saveMetaItem, installPackage } = makeProtocol();
const res = await protocol.duplicatePackage({
sourcePackageId: 'app.iojn', targetPackageId: 'app.iojn2', targetNamespace: 'iojn2',
});

// target package installed as a writable copy (new id + namespace, scope kept)
expect(installPackage).toHaveBeenCalledWith(
expect.objectContaining({ id: 'app.iojn2', namespace: 'iojn2', scope: 'environment' }),
);

const calls = (saveMetaItem as any).mock.calls.map((c: any) => c[0]);
const ticket = calls.find((c: any) => c.name === 'iojn2_repair_ticket');
const customer = calls.find((c: any) => c.name === 'iojn2_customer');
const view = calls.find((c: any) => c.name === 'iojn2_repair_ticket.all');

expect(ticket).toBeTruthy();
expect(ticket.packageId).toBe('app.iojn2');
expect(ticket.mode).toBe('publish');
// the lookup reference was rewritten to the cloned object's new name
expect(ticket.item.fields.customer.reference).toBe('iojn2_customer');
expect(ticket.item.name).toBe('iojn2_repair_ticket');
expect(customer).toBeTruthy();
// the view's object binding + name were re-namespaced too
expect(view.item.object).toBe('iojn2_repair_ticket');

expect(res).toMatchObject({ success: true, copiedCount: 3, failedCount: 0, targetPackageId: 'app.iojn2' });
});

it('does NOT rewrite a same-prefix-but-distinct token incorrectly (boundary-safe)', async () => {
const { protocol, saveMetaItem } = makeProtocol();
await protocol.duplicatePackage({ sourcePackageId: 'app.iojn', targetPackageId: 'app.iojn2', targetNamespace: 'iojn2' });
const calls = (saveMetaItem as any).mock.calls.map((c: any) => c[0]);
// iojn_customer → iojn2_customer (exact), never iojn2_customer-with-leftover
expect(calls.some((c: any) => c.name === 'iojn2_customer')).toBe(true);
expect(calls.some((c: any) => /iojn_/.test(c.name))).toBe(false); // no un-renamed leftovers
});
});
116 changes: 116 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4464,6 +4464,122 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
};
}

/**
* ADR-0070 D4 — duplicate a writable base into a NEW package (the Airtable
* "duplicate base" gesture). Clones every ACTIVE item the source owns into
* `targetPackageId`, RE-NAMESPACING object names — the blueprint prefixes a
* base's object names with its namespace (e.g. `iojn_repair_ticket`), and
* `sys_metadata` keys on (type,name,org), so a same-name copy would collide
* with the source — and rewriting every intra-package reference (lookup
* `reference`, view `object`, expressions, etc.) to the new names. Per-item
* best-effort; one failure never aborts the whole clone.
*/
async duplicatePackage(request: {
sourcePackageId: string;
targetPackageId: string;
targetName?: string;
targetNamespace?: string;
organizationId?: string;
actor?: string;
}): Promise<{
success: boolean;
copiedCount: number;
failedCount: number;
targetPackageId: string;
copied: Array<{ type: string; name: string }>;
failed: Array<{ type: string; name: string; error: string }>;
}> {
const registry: any = (this.engine as any).registry;
const srcPkg = registry?.getPackage?.(request.sourcePackageId);
const sourceNs: string =
(srcPkg?.manifest?.namespace as string) ?? (request.sourcePackageId.split('.').pop() ?? '');
const targetNs: string =
request.targetNamespace ?? (request.targetPackageId.split('.').pop() ?? request.targetPackageId);

const where: Record<string, unknown> = { package_id: request.sourcePackageId, state: 'active' };
if (request.organizationId) where.organization_id = request.organizationId;
const rows = (await this.engine.find('sys_metadata', { where })) as any[];

// Map only OBJECT names that carry the source namespace prefix; views/etc.
// are renamed by the same prefix swap and reference-rewritten via the map.
const renameName = (name: string): string =>
sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`)
? `${targetNs}_${name.slice(sourceNs.length + 1)}`
: name;
const renameMap = new Map<string, string>();
for (const row of rows) {
if (row?.type === 'object') {
const nn = renameName(row.name);
if (nn !== row.name) renameMap.set(row.name, nn);
}
}
// Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts
// `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
const olds = [...renameMap.keys()].sort((a, b) => b.length - a.length);
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = olds.length ? new RegExp(`(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g') : null;
const deepRewrite = (v: any): any => {
if (typeof v === 'string') return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
if (Array.isArray(v)) return v.map(deepRewrite);
if (v && typeof v === 'object') {
const o: any = {};
for (const [k, val] of Object.entries(v)) o[k] = deepRewrite(val);
return o;
}
return v;
};

if (srcPkg?.manifest && typeof registry?.installPackage === 'function') {
try {
registry.installPackage({
...srcPkg.manifest,
id: request.targetPackageId,
name: request.targetName ?? `${srcPkg.manifest.name ?? request.sourcePackageId} (copy)`,
namespace: targetNs,
});
} catch {
/* best-effort — the per-item package binding still works without a manifest row */
}
}

const copied: Array<{ type: string; name: string }> = [];
const failed: Array<{ type: string; name: string; error: string }> = [];
for (const row of rows) {
const newName = renameName(row.name);
let item: any;
try {
item = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {});
} catch {
failed.push({ type: row.type, name: row.name, error: 'unparseable metadata' });
continue;
}
const rewritten = deepRewrite(item);
if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) rewritten.name = newName;
try {
await this.saveMetaItem({
type: row.type,
name: newName,
item: rewritten,
mode: 'publish',
packageId: request.targetPackageId,
...(request.organizationId ? { organizationId: request.organizationId } : {}),
...(request.actor ? { actor: request.actor } : {}),
});
copied.push({ type: row.type, name: newName });
} catch (e: any) {
failed.push({ type: row.type, name: row.name, error: e?.message ?? 'copy failed' });
}
}
return {
success: failed.length === 0 && copied.length > 0,
copiedCount: copied.length,
failedCount: failed.length,
targetPackageId: request.targetPackageId,
copied,
failed,
};
}

// ─────────────────────────────────────────────────────────────────────
// ADR-0067 — package-scoped commit history & rollback
// ─────────────────────────────────────────────────────────────────────
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,35 @@ export class HttpDispatcher {
return { handled: true, response: this.success(manifest) };
}

// 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? }.
if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') {
const id = decodeURIComponent(parts[0]);
const protocol = await this.resolveService('protocol');
if (!protocol || typeof (protocol as any).duplicatePackage !== 'function') {
return { handled: true, response: this.error('Package duplication not supported', 501) };
}
const targetPackageId = typeof body?.targetPackageId === 'string' ? body.targetPackageId.trim() : '';
if (!targetPackageId) {
return { handled: true, response: this.error('Body { targetPackageId } is required', 400) };
}
try {
const organizationId = await this.resolveActiveOrganizationId(_context);
const result = await (protocol as any).duplicatePackage({
sourcePackageId: id,
targetPackageId,
...(typeof body?.targetName === 'string' ? { targetName: body.targetName } : {}),
...(typeof body?.targetNamespace === 'string' ? { targetNamespace: body.targetNamespace } : {}),
...(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) };
}
}

// GET /packages/:id → get package
if (parts.length === 1 && m === 'GET') {
const id = decodeURIComponent(parts[0]);
Expand Down