diff --git a/.changeset/adr-0070-d4-duplicate-package.md b/.changeset/adr-0070-d4-duplicate-package.md new file mode 100644 index 0000000000..fedba8b173 --- /dev/null +++ b/.changeset/adr-0070-d4-duplicate-package.md @@ -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. diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts index 2504885347..11cd5baca8 100644 --- a/packages/objectql/src/protocol-package-lifecycle.test.ts +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -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 + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 5a0ea0f810..9b7dd3171e 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -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 = { 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(); + 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 // ───────────────────────────────────────────────────────────────────── diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index d36de8c072..dae9ac59c5 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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]);