Skip to content

Commit 61d441f

Browse files
os-zhuangclaude
andauthored
feat(objectql): duplicatePackage — clone a base with re-namespacing + reference rewrite (ADR-0070 D4) (#2299)
protocol.duplicatePackage clones a base's ACTIVE items into a new package, re-namespacing object names (avoids the (type,name) collision) and rewriting intra-package references (lookup reference / view object / expressions) via a longest-first identifier-boundary replace. + POST /packages/:id/duplicate. +tests (re-namespacing + reference rewrite + boundary-safety). Completes D4 (delete + export already shipped). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8eb56b0 commit 61d441f

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(objectql): duplicate a writable base — ADR-0070 D4 ("duplicate base")
7+
8+
`protocol.duplicatePackage` clones every ACTIVE item a base owns into a NEW
9+
package, **re-namespacing** object names (the blueprint prefixes a base's object
10+
names with its namespace, e.g. `iojn_repair_ticket`, and `sys_metadata` keys on
11+
`(type,name,org)` so a same-name copy would collide with the source) and
12+
**rewriting every intra-package reference** (lookup `reference`, view `object`,
13+
expressions, …) to the new names via a longest-first, identifier-boundary
14+
replace. Exposed as `POST /packages/:id/duplicate` (body
15+
`{ targetPackageId, targetName?, targetNamespace? }`).
16+
17+
Completes ADR-0070 D4 (package = lifecycle unit): delete-cascade and export
18+
already shipped; this adds the duplicate gesture.

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,77 @@ describe('protocol.deletePackage', () => {
103103
expect(res).toMatchObject({ success: false, deletedCount: 0 });
104104
});
105105
});
106+
107+
describe('protocol.duplicatePackage (ADR-0070 D4)', () => {
108+
function makeProtocol() {
109+
const rows = [
110+
{
111+
type: 'object', name: 'iojn_repair_ticket', state: 'active',
112+
metadata: JSON.stringify({
113+
name: 'iojn_repair_ticket', label: 'Ticket',
114+
fields: { title: { type: 'text' }, customer: { type: 'lookup', reference: 'iojn_customer' } },
115+
}),
116+
},
117+
{
118+
type: 'object', name: 'iojn_customer', state: 'active',
119+
metadata: JSON.stringify({ name: 'iojn_customer', label: 'Customer', fields: { full_name: { type: 'text' } } }),
120+
},
121+
{
122+
type: 'view', name: 'iojn_repair_ticket.all', state: 'active',
123+
metadata: JSON.stringify({ name: 'iojn_repair_ticket.all', object: 'iojn_repair_ticket', viewKind: 'list' }),
124+
},
125+
];
126+
const installPackage = vi.fn();
127+
const engine = {
128+
find: vi.fn(async () => rows),
129+
registry: {
130+
getPackage: vi.fn(() => ({
131+
manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application', scope: 'environment' },
132+
})),
133+
installPackage,
134+
},
135+
};
136+
const protocol = new ObjectStackProtocolImplementation(engine as never);
137+
const saveMetaItem = vi.spyOn(protocol, 'saveMetaItem' as never);
138+
(saveMetaItem as any).mockResolvedValue({ success: true } as never);
139+
return { protocol, saveMetaItem, installPackage };
140+
}
141+
142+
it('clones items into a new package, re-namespacing names AND rewriting references', async () => {
143+
const { protocol, saveMetaItem, installPackage } = makeProtocol();
144+
const res = await protocol.duplicatePackage({
145+
sourcePackageId: 'app.iojn', targetPackageId: 'app.iojn2', targetNamespace: 'iojn2',
146+
});
147+
148+
// target package installed as a writable copy (new id + namespace, scope kept)
149+
expect(installPackage).toHaveBeenCalledWith(
150+
expect.objectContaining({ id: 'app.iojn2', namespace: 'iojn2', scope: 'environment' }),
151+
);
152+
153+
const calls = (saveMetaItem as any).mock.calls.map((c: any) => c[0]);
154+
const ticket = calls.find((c: any) => c.name === 'iojn2_repair_ticket');
155+
const customer = calls.find((c: any) => c.name === 'iojn2_customer');
156+
const view = calls.find((c: any) => c.name === 'iojn2_repair_ticket.all');
157+
158+
expect(ticket).toBeTruthy();
159+
expect(ticket.packageId).toBe('app.iojn2');
160+
expect(ticket.mode).toBe('publish');
161+
// the lookup reference was rewritten to the cloned object's new name
162+
expect(ticket.item.fields.customer.reference).toBe('iojn2_customer');
163+
expect(ticket.item.name).toBe('iojn2_repair_ticket');
164+
expect(customer).toBeTruthy();
165+
// the view's object binding + name were re-namespaced too
166+
expect(view.item.object).toBe('iojn2_repair_ticket');
167+
168+
expect(res).toMatchObject({ success: true, copiedCount: 3, failedCount: 0, targetPackageId: 'app.iojn2' });
169+
});
170+
171+
it('does NOT rewrite a same-prefix-but-distinct token incorrectly (boundary-safe)', async () => {
172+
const { protocol, saveMetaItem } = makeProtocol();
173+
await protocol.duplicatePackage({ sourcePackageId: 'app.iojn', targetPackageId: 'app.iojn2', targetNamespace: 'iojn2' });
174+
const calls = (saveMetaItem as any).mock.calls.map((c: any) => c[0]);
175+
// iojn_customer → iojn2_customer (exact), never iojn2_customer-with-leftover
176+
expect(calls.some((c: any) => c.name === 'iojn2_customer')).toBe(true);
177+
expect(calls.some((c: any) => /iojn_/.test(c.name))).toBe(false); // no un-renamed leftovers
178+
});
179+
});

packages/objectql/src/protocol.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4464,6 +4464,122 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
44644464
};
44654465
}
44664466

4467+
/**
4468+
* ADR-0070 D4 — duplicate a writable base into a NEW package (the Airtable
4469+
* "duplicate base" gesture). Clones every ACTIVE item the source owns into
4470+
* `targetPackageId`, RE-NAMESPACING object names — the blueprint prefixes a
4471+
* base's object names with its namespace (e.g. `iojn_repair_ticket`), and
4472+
* `sys_metadata` keys on (type,name,org), so a same-name copy would collide
4473+
* with the source — and rewriting every intra-package reference (lookup
4474+
* `reference`, view `object`, expressions, etc.) to the new names. Per-item
4475+
* best-effort; one failure never aborts the whole clone.
4476+
*/
4477+
async duplicatePackage(request: {
4478+
sourcePackageId: string;
4479+
targetPackageId: string;
4480+
targetName?: string;
4481+
targetNamespace?: string;
4482+
organizationId?: string;
4483+
actor?: string;
4484+
}): Promise<{
4485+
success: boolean;
4486+
copiedCount: number;
4487+
failedCount: number;
4488+
targetPackageId: string;
4489+
copied: Array<{ type: string; name: string }>;
4490+
failed: Array<{ type: string; name: string; error: string }>;
4491+
}> {
4492+
const registry: any = (this.engine as any).registry;
4493+
const srcPkg = registry?.getPackage?.(request.sourcePackageId);
4494+
const sourceNs: string =
4495+
(srcPkg?.manifest?.namespace as string) ?? (request.sourcePackageId.split('.').pop() ?? '');
4496+
const targetNs: string =
4497+
request.targetNamespace ?? (request.targetPackageId.split('.').pop() ?? request.targetPackageId);
4498+
4499+
const where: Record<string, unknown> = { package_id: request.sourcePackageId, state: 'active' };
4500+
if (request.organizationId) where.organization_id = request.organizationId;
4501+
const rows = (await this.engine.find('sys_metadata', { where })) as any[];
4502+
4503+
// Map only OBJECT names that carry the source namespace prefix; views/etc.
4504+
// are renamed by the same prefix swap and reference-rewritten via the map.
4505+
const renameName = (name: string): string =>
4506+
sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`)
4507+
? `${targetNs}_${name.slice(sourceNs.length + 1)}`
4508+
: name;
4509+
const renameMap = new Map<string, string>();
4510+
for (const row of rows) {
4511+
if (row?.type === 'object') {
4512+
const nn = renameName(row.name);
4513+
if (nn !== row.name) renameMap.set(row.name, nn);
4514+
}
4515+
}
4516+
// Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts
4517+
// `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
4518+
const olds = [...renameMap.keys()].sort((a, b) => b.length - a.length);
4519+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4520+
const re = olds.length ? new RegExp(`(${olds.map(esc).join('|')})(?![A-Za-z0-9_])`, 'g') : null;
4521+
const deepRewrite = (v: any): any => {
4522+
if (typeof v === 'string') return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
4523+
if (Array.isArray(v)) return v.map(deepRewrite);
4524+
if (v && typeof v === 'object') {
4525+
const o: any = {};
4526+
for (const [k, val] of Object.entries(v)) o[k] = deepRewrite(val);
4527+
return o;
4528+
}
4529+
return v;
4530+
};
4531+
4532+
if (srcPkg?.manifest && typeof registry?.installPackage === 'function') {
4533+
try {
4534+
registry.installPackage({
4535+
...srcPkg.manifest,
4536+
id: request.targetPackageId,
4537+
name: request.targetName ?? `${srcPkg.manifest.name ?? request.sourcePackageId} (copy)`,
4538+
namespace: targetNs,
4539+
});
4540+
} catch {
4541+
/* best-effort — the per-item package binding still works without a manifest row */
4542+
}
4543+
}
4544+
4545+
const copied: Array<{ type: string; name: string }> = [];
4546+
const failed: Array<{ type: string; name: string; error: string }> = [];
4547+
for (const row of rows) {
4548+
const newName = renameName(row.name);
4549+
let item: any;
4550+
try {
4551+
item = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {});
4552+
} catch {
4553+
failed.push({ type: row.type, name: row.name, error: 'unparseable metadata' });
4554+
continue;
4555+
}
4556+
const rewritten = deepRewrite(item);
4557+
if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) rewritten.name = newName;
4558+
try {
4559+
await this.saveMetaItem({
4560+
type: row.type,
4561+
name: newName,
4562+
item: rewritten,
4563+
mode: 'publish',
4564+
packageId: request.targetPackageId,
4565+
...(request.organizationId ? { organizationId: request.organizationId } : {}),
4566+
...(request.actor ? { actor: request.actor } : {}),
4567+
});
4568+
copied.push({ type: row.type, name: newName });
4569+
} catch (e: any) {
4570+
failed.push({ type: row.type, name: row.name, error: e?.message ?? 'copy failed' });
4571+
}
4572+
}
4573+
return {
4574+
success: failed.length === 0 && copied.length > 0,
4575+
copiedCount: copied.length,
4576+
failedCount: failed.length,
4577+
targetPackageId: request.targetPackageId,
4578+
copied,
4579+
failed,
4580+
};
4581+
}
4582+
44674583
// ─────────────────────────────────────────────────────────────────────
44684584
// ADR-0067 — package-scoped commit history & rollback
44694585
// ─────────────────────────────────────────────────────────────────────

packages/runtime/src/http-dispatcher.ts

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

1915+
// POST /packages/:id/duplicate → clone this base into a NEW writable
1916+
// package, re-namespacing objects + rewriting references (ADR-0070 D4
1917+
// "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }.
1918+
if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') {
1919+
const id = decodeURIComponent(parts[0]);
1920+
const protocol = await this.resolveService('protocol');
1921+
if (!protocol || typeof (protocol as any).duplicatePackage !== 'function') {
1922+
return { handled: true, response: this.error('Package duplication not supported', 501) };
1923+
}
1924+
const targetPackageId = typeof body?.targetPackageId === 'string' ? body.targetPackageId.trim() : '';
1925+
if (!targetPackageId) {
1926+
return { handled: true, response: this.error('Body { targetPackageId } is required', 400) };
1927+
}
1928+
try {
1929+
const organizationId = await this.resolveActiveOrganizationId(_context);
1930+
const result = await (protocol as any).duplicatePackage({
1931+
sourcePackageId: id,
1932+
targetPackageId,
1933+
...(typeof body?.targetName === 'string' ? { targetName: body.targetName } : {}),
1934+
...(typeof body?.targetNamespace === 'string' ? { targetNamespace: body.targetNamespace } : {}),
1935+
...(organizationId ? { organizationId } : {}),
1936+
...(body?.actor ? { actor: body.actor } : {}),
1937+
});
1938+
return { handled: true, response: this.success(result) };
1939+
} catch (e: any) {
1940+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
1941+
}
1942+
}
1943+
19151944
// GET /packages/:id → get package
19161945
if (parts.length === 1 && m === 'GET') {
19171946
const id = decodeURIComponent(parts[0]);

0 commit comments

Comments
 (0)