diff --git a/.changeset/record-clone.md b/.changeset/record-clone.md new file mode 100644 index 0000000000..98474d4930 --- /dev/null +++ b/.changeset/record-clone.md @@ -0,0 +1,11 @@ +--- +"@objectstack/objectql": minor +"@objectstack/rest": minor +--- + +feat: record clone — wire the `object.enable.clone` capability to a real runtime (previously a parsed-but-dead flag). + +- **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`. +- **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. + +Reclassifies `object.enable.clone` `dead → live` in the spec liveness ledger. diff --git a/packages/objectql/src/protocol-clone-real-engine.test.ts b/packages/objectql/src/protocol-clone-real-engine.test.ts new file mode 100644 index 0000000000..ce68e3677b --- /dev/null +++ b/packages/objectql/src/protocol-clone-real-engine.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Integration test for `protocol.cloneData` driving a REAL {@link ObjectQL} + * engine + a minimal in-memory driver. The unit suite in + * `protocol-data.test.ts` stubs the engine; this exercises the production + * path — registry.getObject (enable.clone + field defs), engine.findOne for + * the source, and engine.insert for the copy — so the strongest real-engine + * signal (autonumber regeneration on the cloned row) is actually verified. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { ObjectQL } from './engine.js'; + +// A clonable object: business fields + an engine-generated autonumber that +// must be re-issued (not copied) on the clone. +const accountObject = { + name: 'clone_account', + label: 'Account', + // enable block omitted on purpose → clone is default-on. + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + amount: { name: 'amount', label: 'Amount', type: 'number' as const }, + ref: { name: 'ref', label: 'Ref', type: 'autonumber' as const, autonumberFormat: 'ACC-{0000}' }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const, system: true }, + }, +}; + +// A non-clonable object: enable.clone explicitly false. +const lockedObject = { + name: 'clone_locked', + label: 'Locked', + enable: { clone: false }, + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = row[k] === undefined ? null : row[k]; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, // no native autonumber → engine uses its counter + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + findStream() { throw new Error('not implemented'); }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, stores }; +} + +describe('cloneData — real ObjectQL engine', () => { + let engine: ObjectQL; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(async () => { + engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(accountObject as any); + engine.registry.registerObject(lockedObject as any); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('produces a new row with a regenerated autonumber and copied business fields', async () => { + const created = await protocol.createData({ + object: 'clone_account', + data: { name: 'Acme', amount: 100, organization_id: 'org_1' }, + }); + const sourceId = created.id; + const sourceRef = created.record.ref; + expect(sourceRef).toBe('ACC-0001'); + + const cloned = await protocol.cloneData({ object: 'clone_account', id: sourceId }); + + // Distinct identity. + expect(cloned.id).not.toBe(sourceId); + expect(cloned.sourceId).toBe(sourceId); + // Business fields carried over. + expect(cloned.record.name).toBe('Acme'); + expect(cloned.record.amount).toBe(100); + // Autonumber re-issued by the engine, not copied. + expect(cloned.record.ref).toBe('ACC-0002'); + + // Both rows persist independently. + const all = await engine.find('clone_account', {}); + expect(all.length).toBe(2); + }); + + it('applies caller overrides over the copied values', async () => { + const created = await protocol.createData({ + object: 'clone_account', + data: { name: 'Acme', amount: 100 }, + }); + const cloned = await protocol.cloneData({ + object: 'clone_account', + id: created.id, + overrides: { name: 'Acme (Copy)' }, + }); + expect(cloned.record.name).toBe('Acme (Copy)'); + expect(cloned.record.amount).toBe(100); + }); + + it('rejects with 403 CLONE_DISABLED when enable.clone === false', async () => { + const created = await protocol.createData({ + object: 'clone_locked', + data: { name: 'Immutable' }, + }); + await expect( + protocol.cloneData({ object: 'clone_locked', id: created.id }), + ).rejects.toMatchObject({ code: 'CLONE_DISABLED', status: 403 }); + // No second row written. + expect((await engine.find('clone_locked', {})).length).toBe(1); + }); + + it('rejects with 404 when the source record does not exist', async () => { + await expect( + protocol.cloneData({ object: 'clone_account', id: 'does-not-exist' }), + ).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + }); +}); diff --git a/packages/objectql/src/protocol-data.test.ts b/packages/objectql/src/protocol-data.test.ts index 464f006ebe..b2f85946d9 100644 --- a/packages/objectql/src/protocol-data.test.ts +++ b/packages/objectql/src/protocol-data.test.ts @@ -361,4 +361,131 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { expect(mockEngine.delete).toHaveBeenCalledOnce(); }); }); + + // ═══════════════════════════════════════════════════════════════ + // cloneData — duplicate a record, gated by enable.clone + // ═══════════════════════════════════════════════════════════════ + + describe('cloneData', () => { + // A richer engine mock: cloneData reads registry.getObject for the + // schema (enable.clone + field defs), findOne for the source row, and + // insert for the copy. + function makeProtocol(opts: { + schema?: any; + source?: any; + } = {}) { + const insert = vi.fn(async (_obj: string, data: any) => ({ id: 'new-id', ...data })); + const findOne = vi.fn().mockResolvedValue( + opts.source === undefined + ? { id: 'src-1', name: 'Acme', amount: 100 } + : opts.source, + ); + const engine: any = { + findOne, + insert, + registry: { + getObject: vi.fn().mockReturnValue( + opts.schema === undefined + ? { name: 'account', fields: { name: { type: 'text' }, amount: { type: 'number' } } } + : opts.schema, + ), + }, + }; + return { protocol: new ObjectStackProtocolImplementation(engine), engine, insert, findOne }; + } + + it('copies business fields and strips engine-owned audit/id columns', async () => { + const { protocol, insert } = makeProtocol({ + source: { + id: 'src-1', name: 'Acme', amount: 100, + created_at: 'x', created_by: 'u1', updated_at: 'y', updated_by: 'u1', + }, + }); + const result = await protocol.cloneData({ object: 'account', id: 'src-1' }); + + const [, inserted] = insert.mock.calls[0]; + expect(inserted).toEqual({ name: 'Acme', amount: 100 }); + expect(inserted).not.toHaveProperty('id'); + expect(inserted).not.toHaveProperty('created_at'); + expect(inserted).not.toHaveProperty('updated_by'); + expect(result).toMatchObject({ object: 'account', id: 'new-id', sourceId: 'src-1' }); + }); + + it('drops autonumber / formula / summary / system fields so they re-derive', async () => { + const { protocol, insert } = makeProtocol({ + schema: { + name: 'ticket', + fields: { + name: { type: 'text' }, + ref: { type: 'autonumber' }, + total: { type: 'formula' }, + rollup: { type: 'summary' }, + organization_id: { type: 'text', system: true }, + }, + }, + source: { + id: 'src-1', name: 'Bug', ref: 'TKT-0001', + total: 42, rollup: 7, organization_id: 'org-9', + }, + }); + await protocol.cloneData({ object: 'ticket', id: 'src-1' }); + + const [, inserted] = insert.mock.calls[0]; + expect(inserted).toEqual({ name: 'Bug' }); + }); + + it('applies caller overrides last (they win over copied values)', async () => { + const { protocol, insert } = makeProtocol({ + source: { id: 'src-1', name: 'Acme', amount: 100 }, + }); + await protocol.cloneData({ + object: 'account', + id: 'src-1', + overrides: { name: 'Acme (Copy)', amount: 0 }, + }); + const [, inserted] = insert.mock.calls[0]; + expect(inserted).toEqual({ name: 'Acme (Copy)', amount: 0 }); + }); + + it('forwards context to findOne and insert', async () => { + const { protocol, insert, findOne } = makeProtocol(); + const ctx = { userId: 'u1' }; + await protocol.cloneData({ object: 'account', id: 'src-1', context: ctx }); + expect(findOne).toHaveBeenCalledWith('account', expect.objectContaining({ context: ctx })); + expect(insert).toHaveBeenCalledWith('account', expect.anything(), { context: ctx }); + }); + + it('rejects with 403 CLONE_DISABLED when enable.clone === false', async () => { + const { protocol, insert } = makeProtocol({ + schema: { name: 'account', enable: { clone: false }, fields: {} }, + }); + await expect( + protocol.cloneData({ object: 'account', id: 'src-1' }), + ).rejects.toMatchObject({ code: 'CLONE_DISABLED', status: 403 }); + expect(insert).not.toHaveBeenCalled(); + }); + + it('allows clone when enable block is absent (default-on)', async () => { + const { protocol, insert } = makeProtocol({ + schema: { name: 'account', fields: { name: { type: 'text' } } }, + }); + await protocol.cloneData({ object: 'account', id: 'src-1' }); + expect(insert).toHaveBeenCalledOnce(); + }); + + it('rejects with 404 RECORD_NOT_FOUND when the source is missing', async () => { + const { protocol, insert } = makeProtocol({ source: null }); + await expect( + protocol.cloneData({ object: 'account', id: 'nope' }), + ).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect(insert).not.toHaveBeenCalled(); + }); + + it('rejects with 404 OBJECT_NOT_FOUND for an unknown object', async () => { + const { protocol } = makeProtocol({ schema: null }); + await expect( + protocol.cloneData({ object: 'ghost', id: 'src-1' }), + ).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); + }); + }); }); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 36a0589979..be8c5961ab 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -408,6 +408,13 @@ function normaliseVersionToken(v: unknown): string | null { return s; } +// Lifecycle columns the engine always owns; the clone path drops them by NAME +// so the insert re-stamps fresh values instead of copying the source's. Mirrors +// record-validator's SKIP_FIELDS (system-injected, never author-supplied). +const CLONE_STRIP_FIELDS: readonly string[] = [ + 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', +]; + /** * Service Configuration for Discovery * Maps service names to their routes and plugin providers @@ -2236,6 +2243,83 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } + /** + * Clone a record — read the source, drop engine-owned columns, and + * insert a fresh copy. Gated by the object's `enable.clone` capability + * (default `true`; only an explicit `enable.clone === false` disables it). + * + * Shallow by design: it duplicates the record's own scalar/business field + * values, not its related child records. The insert path re-stamps audit + * columns, regenerates `autonumber` fields, and recomputes derived + * (`formula`/`summary`) fields, so the copy is a valid new row rather than + * a byte-identical twin. Caller-supplied `overrides` are applied last and + * win over the copied values — the natural place to set a new `name`, + * clear a unique field, or reset status before insert. + */ + async cloneData(request: { object: string, id: string, overrides?: Record, context?: any }) { + const schema: any = this.engine.registry.getObject(request.object); + if (!schema) { + const err: any = new Error(`Object '${request.object}' not found`); + err.code = 'OBJECT_NOT_FOUND'; + err.status = 404; + err.object = request.object; + throw err; + } + // `enable.clone` defaults to true in the spec; treat an absent block / + // absent flag as enabled and only block on an explicit `false`. + if (schema.enable?.clone === false) { + const err: any = new Error(`Cloning is disabled for object '${request.object}'`); + err.code = 'CLONE_DISABLED'; + err.status = 403; + err.object = request.object; + throw err; + } + + const ctx = request.context; + const ctxOpt = ctx !== undefined ? { context: ctx } : undefined; + + const source = await this.engine.findOne( + request.object, + { where: { id: request.id }, ...(ctxOpt as any) } as any, + ); + if (!source) { + const err: any = new Error(`Record ${request.id} not found in ${request.object}`); + err.code = 'RECORD_NOT_FOUND'; + err.status = 404; + err.object = request.object; + throw err; + } + + // Copy the source, then strip the columns the engine owns so the insert + // path re-derives them rather than carrying the source's values over. + const data: Record = { ...source }; + for (const f of CLONE_STRIP_FIELDS) delete data[f]; + const fields: Record = schema.fields || {}; + for (const [name, def] of Object.entries(fields)) { + if (!def) continue; + // Engine-/automation-owned values: injected system/audit columns, + // engine-generated autonumbers, and computed formula/summary fields. + if ((def as any).system === true + || (def as any).type === 'autonumber' + || (def as any).type === 'formula' + || (def as any).type === 'summary') { + delete data[name]; + } + } + // Caller overrides win (new name, cleared unique field, reset status…). + if (request.overrides && typeof request.overrides === 'object') { + Object.assign(data, request.overrides); + } + + const result = await this.engine.insert(request.object, data, ctxOpt as any); + return { + object: request.object, + id: result.id, + sourceId: request.id, + record: result, + }; + } + async updateData(request: { object: string, id: string, data: any, expectedVersion?: string, context?: any }) { await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context); const opts: any = { where: { id: request.id } }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ebc72d2ee0..ace53a1fb5 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2978,6 +2978,59 @@ export class RestServer { tags: ['data', 'lead'], }, }); + + // POST /data/:object/:id/clone — duplicate a record (gated by the + // object's `enable.clone` capability, default on). Optional JSON body + // `{ overrides?: {...} }` (or a bare field map) is applied on top of + // the copied values, e.g. to set a new name or clear a unique field. + // Distinct path segment (`/clone`) keeps it clear of the greedy + // `/data/:object/:id` matchers. + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/:object/:id/clone`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; + const cloneData = (p as any).cloneData; + if (typeof cloneData !== 'function') { + res.status(501).json({ code: 'NOT_IMPLEMENTED', error: 'Clone not supported by this protocol' }); + return; + } + const body = req.body ?? {}; + // Accept both `{ overrides: {...} }` and a bare field map so + // callers can POST overrides directly without nesting. + const overrides = (body && typeof body === 'object' && 'overrides' in body) + ? body.overrides + : body; + const result = await cloneData.call(p, { + object: req.params.object, + id: req.params.id, + ...(overrides && typeof overrides === 'object' ? { overrides } : {}), + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + }); + res.status(201).json(result); + } catch (error: any) { + // Clone's domain errors (CLONE_DISABLED/RECORD_NOT_FOUND) + // carry an explicit `.status`; fall back to mapDataError for + // driver/validation faults. Only log genuine server faults. + const status = typeof error?.status === 'number' + ? error.status + : mapDataError(error, req.params?.object).status; + if (!isExpectedDataStatus(status) && error?.code !== 'VALIDATION_FAILED') logError('[REST] Unhandled error:', error); + sendError(res, error, req.params?.object); + } + }, + metadata: { + summary: 'Clone a record (gated by enable.clone)', + tags: ['data', 'clone'], + }, + }); + // POST /data/:object/import — bulk CSV/JSON ingestion (M10.9) // // Body shapes: diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index fb4dafcfb5..64ee22b61b 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -40,6 +40,7 @@ function createMockProtocol() { findData: vi.fn().mockResolvedValue([]), getData: vi.fn().mockResolvedValue({}), createData: vi.fn().mockResolvedValue({ id: '1' }), + cloneData: vi.fn().mockResolvedValue({ object: 'account', id: '2', sourceId: '1', record: { id: '2' } }), updateData: vi.fn().mockResolvedValue({}), deleteData: vi.fn().mockResolvedValue({ success: true }), batchData: undefined as any, @@ -499,6 +500,79 @@ describe('RestServer', () => { }); }); + describe('clone route — POST /data/:object/:id/clone', () => { + function cloneRoute(rest: any) { + return rest + .getRoutes() + .find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/:id/clone'); + } + const mockRes = () => ({ json: vi.fn(), status: vi.fn().mockReturnThis() }); + + it('registers the clone route alongside CRUD', () => { + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + expect(cloneRoute(rest)).toBeDefined(); + }); + + it('forwards object/id and nested overrides to protocol.cloneData and responds 201', async () => { + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const route = cloneRoute(rest); + + const req = { params: { object: 'account', id: 'a1' }, body: { overrides: { name: 'Copy' } } }; + const res = mockRes(); + await route!.handler(req, res); + + expect(protocol.cloneData).toHaveBeenCalledWith( + expect.objectContaining({ object: 'account', id: 'a1', overrides: { name: 'Copy' } }), + ); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('treats a bare body (no `overrides` key) as the override map', async () => { + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const route = cloneRoute(rest); + + const req = { params: { object: 'account', id: 'a1' }, body: { name: 'Bare' } }; + const res = mockRes(); + await route!.handler(req, res); + + expect(protocol.cloneData).toHaveBeenCalledWith( + expect.objectContaining({ object: 'account', id: 'a1', overrides: { name: 'Bare' } }), + ); + }); + + it('maps a CLONE_DISABLED protocol error to 403', async () => { + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const route = cloneRoute(rest); + + protocol.cloneData.mockRejectedValueOnce( + Object.assign(new Error('Cloning is disabled'), { code: 'CLONE_DISABLED', status: 403 }), + ); + const req = { params: { object: 'account', id: 'a1' }, body: {} }; + const res = mockRes(); + await route!.handler(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CLONE_DISABLED' })); + }); + + it('returns 501 when the protocol does not implement cloneData', async () => { + const noClone = { ...protocol, cloneData: undefined }; + const rest = new RestServer(server as any, noClone as any); + rest.registerRoutes(); + const route = cloneRoute(rest); + + const req = { params: { object: 'account', id: 'a1' }, body: {} }; + const res = mockRes(); + await route!.handler(req, res); + + expect(res.status).toHaveBeenCalledWith(501); + }); + }); + describe('meta routes preview=draft forwarding (ADR-0033/0037)', () => { function getMetaRoute(rest: any, method: string, path: string) { return rest diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index fef2c54965..b7a1afbc42 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -168,8 +168,9 @@ "evidence": "no behavior-changing reader" }, "clone": { - "status": "dead", - "evidence": "no behavior-changing reader" + "status": "live", + "evidence": "packages/objectql/src/protocol.ts:2259", + "note": "cloneData() gates on schema.enable.clone (explicit false ⇒ 403 CLONE_DISABLED); exposed at POST /data/:object/:id/clone (rest-server.ts registerDataActionEndpoints)." } } },