Skip to content

Commit 884bf2f

Browse files
os-zhuangclaude
andauthored
feat: record clone — wire object.enable.clone to a real runtime (#1961)
`object.enable.clone` was a parsed-but-dead capability flag. This wires it to an actual clone path and reclassifies it dead→live in the liveness ledger. objectql: protocol.cloneData({ object, id, overrides?, context? }) reads the source, drops engine-owned columns (id + created_at/created_by/updated_at/updated_by, plus system-flagged / autonumber / formula / summary fields) so the insert path re-derives them, applies caller overrides last, and inserts the copy. Shallow by design. Gated on schema.enable.clone — explicit false → 403 CLONE_DISABLED; absent/true → allowed. rest: POST /api/v1/data/:object/:id/clone (201 → { object, id, sourceId, record }). Optional { overrides } body (or a bare field map) wins over copied values. Honors the existing auth + enable.apiEnabled/apiMethods gates. Tests: 8 protocol unit tests (mock engine), 5 REST route tests (registration, overrides, 403 mapping, 501 fallback), and 4 real-engine integration tests proving autonumber regeneration (ACC-0001 → ACC-0002) and CLONE_DISABLED on a genuine ObjectQL engine. Gate green; objectql 633, rest 121. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3e0a4a0 commit 884bf2f

7 files changed

Lines changed: 533 additions & 2 deletions

File tree

.changeset/record-clone.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat: record clone — wire the `object.enable.clone` capability to a real runtime (previously a parsed-but-dead flag).
7+
8+
- **objectql**: new `protocol.cloneData({ object, id, overrides?, context? })` — reads the source record, drops engine-owned columns (`id` + audit `created_at`/`created_by`/`updated_at`/`updated_by`, plus `system`-flagged, `autonumber`, `formula` and `summary` fields) so the insert path re-derives them, applies caller `overrides` last, and inserts the copy. Shallow by design (duplicates the record's own fields, not its child records). Gated by `schema.enable.clone`: default-on, an explicit `enable.clone === false` throws `403 CLONE_DISABLED`.
9+
- **rest**: new `POST /api/v1/data/:object/:id/clone` (201 → `{ object, id, sourceId, record }`). Optional body `{ overrides }` (or a bare field map) overrides copied values, e.g. a new `name` or a cleared unique field. Honors the same auth + `enable.apiEnabled`/`apiMethods` gates as the rest of the data surface; `enable.clone === false` → 403.
10+
11+
Reclassifies `object.enable.clone` `dead → live` in the spec liveness ledger.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Integration test for `protocol.cloneData` driving a REAL {@link ObjectQL}
5+
* engine + a minimal in-memory driver. The unit suite in
6+
* `protocol-data.test.ts` stubs the engine; this exercises the production
7+
* path — registry.getObject (enable.clone + field defs), engine.findOne for
8+
* the source, and engine.insert for the copy — so the strongest real-engine
9+
* signal (autonumber regeneration on the cloned row) is actually verified.
10+
*/
11+
12+
import { describe, it, expect, beforeEach } from 'vitest';
13+
import { ObjectStackProtocolImplementation } from './protocol.js';
14+
import { ObjectQL } from './engine.js';
15+
16+
// A clonable object: business fields + an engine-generated autonumber that
17+
// must be re-issued (not copied) on the clone.
18+
const accountObject = {
19+
name: 'clone_account',
20+
label: 'Account',
21+
// enable block omitted on purpose → clone is default-on.
22+
fields: {
23+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
24+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
25+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
26+
ref: { name: 'ref', label: 'Ref', type: 'autonumber' as const, autonumberFormat: 'ACC-{0000}' },
27+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const, system: true },
28+
},
29+
};
30+
31+
// A non-clonable object: enable.clone explicitly false.
32+
const lockedObject = {
33+
name: 'clone_locked',
34+
label: 'Locked',
35+
enable: { clone: false },
36+
fields: {
37+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
38+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
39+
},
40+
};
41+
42+
function makeMemoryDriver() {
43+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
44+
const storeFor = (obj: string) => {
45+
let s = stores.get(obj);
46+
if (!s) { s = new Map(); stores.set(obj, s); }
47+
return s;
48+
};
49+
let nextId = 0;
50+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
51+
if (!where || typeof where !== 'object') return true;
52+
for (const [k, v] of Object.entries(where)) {
53+
if (k.startsWith('$')) continue;
54+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
55+
const a = row[k] === undefined ? null : row[k];
56+
const b = expected === undefined ? null : expected;
57+
if (a !== b) return false;
58+
}
59+
return true;
60+
};
61+
const driver: any = {
62+
name: 'memory',
63+
version: '0.0.0',
64+
supports: {} as any, // no native autonumber → engine uses its counter
65+
async connect() {},
66+
async disconnect() {},
67+
async checkHealth() { return true; },
68+
async execute() { return null; },
69+
async find(object: string, ast: any) {
70+
return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
71+
},
72+
findStream() { throw new Error('not implemented'); },
73+
async findOne(object: string, ast: any) {
74+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
75+
return null;
76+
},
77+
async create(object: string, data: Record<string, unknown>) {
78+
nextId += 1;
79+
const id = (data.id as string) ?? `r_${nextId}`;
80+
const row = { ...data, id };
81+
storeFor(object).set(id, row);
82+
return row;
83+
},
84+
async update(object: string, id: string, data: Record<string, unknown>) {
85+
const s = storeFor(object);
86+
const cur = s.get(id);
87+
if (!cur) throw new Error(`not found: ${object}/${id}`);
88+
const updated = { ...cur, ...data, id };
89+
s.set(id, updated);
90+
return updated;
91+
},
92+
async upsert(object: string, data: Record<string, unknown>) {
93+
const id = data.id as string | undefined;
94+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
95+
return this.create(object, data);
96+
},
97+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
98+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
99+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
100+
return Promise.all(rows.map((r) => this.create(object, r)));
101+
},
102+
async bulkUpdate() { return []; },
103+
async bulkDelete() {},
104+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
105+
async commit() {},
106+
async rollback() {},
107+
};
108+
return { driver, stores };
109+
}
110+
111+
describe('cloneData — real ObjectQL engine', () => {
112+
let engine: ObjectQL;
113+
let protocol: ObjectStackProtocolImplementation;
114+
115+
beforeEach(async () => {
116+
engine = new ObjectQL();
117+
const { driver } = makeMemoryDriver();
118+
engine.registerDriver(driver, true);
119+
await engine.init();
120+
engine.registry.registerObject(accountObject as any);
121+
engine.registry.registerObject(lockedObject as any);
122+
protocol = new ObjectStackProtocolImplementation(engine);
123+
});
124+
125+
it('produces a new row with a regenerated autonumber and copied business fields', async () => {
126+
const created = await protocol.createData({
127+
object: 'clone_account',
128+
data: { name: 'Acme', amount: 100, organization_id: 'org_1' },
129+
});
130+
const sourceId = created.id;
131+
const sourceRef = created.record.ref;
132+
expect(sourceRef).toBe('ACC-0001');
133+
134+
const cloned = await protocol.cloneData({ object: 'clone_account', id: sourceId });
135+
136+
// Distinct identity.
137+
expect(cloned.id).not.toBe(sourceId);
138+
expect(cloned.sourceId).toBe(sourceId);
139+
// Business fields carried over.
140+
expect(cloned.record.name).toBe('Acme');
141+
expect(cloned.record.amount).toBe(100);
142+
// Autonumber re-issued by the engine, not copied.
143+
expect(cloned.record.ref).toBe('ACC-0002');
144+
145+
// Both rows persist independently.
146+
const all = await engine.find('clone_account', {});
147+
expect(all.length).toBe(2);
148+
});
149+
150+
it('applies caller overrides over the copied values', async () => {
151+
const created = await protocol.createData({
152+
object: 'clone_account',
153+
data: { name: 'Acme', amount: 100 },
154+
});
155+
const cloned = await protocol.cloneData({
156+
object: 'clone_account',
157+
id: created.id,
158+
overrides: { name: 'Acme (Copy)' },
159+
});
160+
expect(cloned.record.name).toBe('Acme (Copy)');
161+
expect(cloned.record.amount).toBe(100);
162+
});
163+
164+
it('rejects with 403 CLONE_DISABLED when enable.clone === false', async () => {
165+
const created = await protocol.createData({
166+
object: 'clone_locked',
167+
data: { name: 'Immutable' },
168+
});
169+
await expect(
170+
protocol.cloneData({ object: 'clone_locked', id: created.id }),
171+
).rejects.toMatchObject({ code: 'CLONE_DISABLED', status: 403 });
172+
// No second row written.
173+
expect((await engine.find('clone_locked', {})).length).toBe(1);
174+
});
175+
176+
it('rejects with 404 when the source record does not exist', async () => {
177+
await expect(
178+
protocol.cloneData({ object: 'clone_account', id: 'does-not-exist' }),
179+
).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 });
180+
});
181+
});

packages/objectql/src/protocol-data.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,131 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
361361
expect(mockEngine.delete).toHaveBeenCalledOnce();
362362
});
363363
});
364+
365+
// ═══════════════════════════════════════════════════════════════
366+
// cloneData — duplicate a record, gated by enable.clone
367+
// ═══════════════════════════════════════════════════════════════
368+
369+
describe('cloneData', () => {
370+
// A richer engine mock: cloneData reads registry.getObject for the
371+
// schema (enable.clone + field defs), findOne for the source row, and
372+
// insert for the copy.
373+
function makeProtocol(opts: {
374+
schema?: any;
375+
source?: any;
376+
} = {}) {
377+
const insert = vi.fn(async (_obj: string, data: any) => ({ id: 'new-id', ...data }));
378+
const findOne = vi.fn().mockResolvedValue(
379+
opts.source === undefined
380+
? { id: 'src-1', name: 'Acme', amount: 100 }
381+
: opts.source,
382+
);
383+
const engine: any = {
384+
findOne,
385+
insert,
386+
registry: {
387+
getObject: vi.fn().mockReturnValue(
388+
opts.schema === undefined
389+
? { name: 'account', fields: { name: { type: 'text' }, amount: { type: 'number' } } }
390+
: opts.schema,
391+
),
392+
},
393+
};
394+
return { protocol: new ObjectStackProtocolImplementation(engine), engine, insert, findOne };
395+
}
396+
397+
it('copies business fields and strips engine-owned audit/id columns', async () => {
398+
const { protocol, insert } = makeProtocol({
399+
source: {
400+
id: 'src-1', name: 'Acme', amount: 100,
401+
created_at: 'x', created_by: 'u1', updated_at: 'y', updated_by: 'u1',
402+
},
403+
});
404+
const result = await protocol.cloneData({ object: 'account', id: 'src-1' });
405+
406+
const [, inserted] = insert.mock.calls[0];
407+
expect(inserted).toEqual({ name: 'Acme', amount: 100 });
408+
expect(inserted).not.toHaveProperty('id');
409+
expect(inserted).not.toHaveProperty('created_at');
410+
expect(inserted).not.toHaveProperty('updated_by');
411+
expect(result).toMatchObject({ object: 'account', id: 'new-id', sourceId: 'src-1' });
412+
});
413+
414+
it('drops autonumber / formula / summary / system fields so they re-derive', async () => {
415+
const { protocol, insert } = makeProtocol({
416+
schema: {
417+
name: 'ticket',
418+
fields: {
419+
name: { type: 'text' },
420+
ref: { type: 'autonumber' },
421+
total: { type: 'formula' },
422+
rollup: { type: 'summary' },
423+
organization_id: { type: 'text', system: true },
424+
},
425+
},
426+
source: {
427+
id: 'src-1', name: 'Bug', ref: 'TKT-0001',
428+
total: 42, rollup: 7, organization_id: 'org-9',
429+
},
430+
});
431+
await protocol.cloneData({ object: 'ticket', id: 'src-1' });
432+
433+
const [, inserted] = insert.mock.calls[0];
434+
expect(inserted).toEqual({ name: 'Bug' });
435+
});
436+
437+
it('applies caller overrides last (they win over copied values)', async () => {
438+
const { protocol, insert } = makeProtocol({
439+
source: { id: 'src-1', name: 'Acme', amount: 100 },
440+
});
441+
await protocol.cloneData({
442+
object: 'account',
443+
id: 'src-1',
444+
overrides: { name: 'Acme (Copy)', amount: 0 },
445+
});
446+
const [, inserted] = insert.mock.calls[0];
447+
expect(inserted).toEqual({ name: 'Acme (Copy)', amount: 0 });
448+
});
449+
450+
it('forwards context to findOne and insert', async () => {
451+
const { protocol, insert, findOne } = makeProtocol();
452+
const ctx = { userId: 'u1' };
453+
await protocol.cloneData({ object: 'account', id: 'src-1', context: ctx });
454+
expect(findOne).toHaveBeenCalledWith('account', expect.objectContaining({ context: ctx }));
455+
expect(insert).toHaveBeenCalledWith('account', expect.anything(), { context: ctx });
456+
});
457+
458+
it('rejects with 403 CLONE_DISABLED when enable.clone === false', async () => {
459+
const { protocol, insert } = makeProtocol({
460+
schema: { name: 'account', enable: { clone: false }, fields: {} },
461+
});
462+
await expect(
463+
protocol.cloneData({ object: 'account', id: 'src-1' }),
464+
).rejects.toMatchObject({ code: 'CLONE_DISABLED', status: 403 });
465+
expect(insert).not.toHaveBeenCalled();
466+
});
467+
468+
it('allows clone when enable block is absent (default-on)', async () => {
469+
const { protocol, insert } = makeProtocol({
470+
schema: { name: 'account', fields: { name: { type: 'text' } } },
471+
});
472+
await protocol.cloneData({ object: 'account', id: 'src-1' });
473+
expect(insert).toHaveBeenCalledOnce();
474+
});
475+
476+
it('rejects with 404 RECORD_NOT_FOUND when the source is missing', async () => {
477+
const { protocol, insert } = makeProtocol({ source: null });
478+
await expect(
479+
protocol.cloneData({ object: 'account', id: 'nope' }),
480+
).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 });
481+
expect(insert).not.toHaveBeenCalled();
482+
});
483+
484+
it('rejects with 404 OBJECT_NOT_FOUND for an unknown object', async () => {
485+
const { protocol } = makeProtocol({ schema: null });
486+
await expect(
487+
protocol.cloneData({ object: 'ghost', id: 'src-1' }),
488+
).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 });
489+
});
490+
});
364491
});

0 commit comments

Comments
 (0)