diff --git a/.changeset/23511-duplicate-package-unprefixed-object.md b/.changeset/23511-duplicate-package-unprefixed-object.md new file mode 100644 index 00000000000..857c453fdb4 --- /dev/null +++ b/.changeset/23511-duplicate-package-unprefixed-object.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): `duplicatePackage` copies an object whose name lacks the source namespace prefix into the target namespace, and a copied reference follows it + +Clause-②: no + +`duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace, because an object's name is its registry key and its table name, so a same-name copy is a second claim on the source's object. + +**FROM.** Only a name that starts with the source prefix was renamed (`crm_account` → `crm2_account`). An object whose name lacks it (`ticket`, stored by a direct publish-mode save, which no prefix gate reads, or bound to a package that had no namespace) kept its name. The copy then failed: it was listed in `failed[]` with the object-ownership refusal ("bind the customization to 'com.acme.crm', or have 'com.acme.crm2' extend the object instead"), which names no way out for a duplicate. Before that refusal existed, the same object was listed in `copied` while the registry refused the target row, and the copy sat over the source's table. Every copied item that referenced it (a lookup `reference`, a view's `object`, a flow node's `objectName`) still named the source's object. + +**TO.** Such an object is copied as `_` (`ticket` → `crm2_ticket`), the rename the namespace-prefix rule itself prescribes (ADR-0129 D2), with its own registry entry and its own table. Every reference to it is rewritten where a property names an object, and only when the value is exactly the object's name. A field that happens to be called `ticket`, and the word in a label or description, are left unchanged. When that target name is already taken (the source also holds `crm_ticket`, whose copy is `crm2_ticket`, or the source holds an object of that name), the object is listed in `failed[]` before anything is written for it. The entry names the object, the name it would take and who holds that name, and says to rename the object on the source. + +**Also.** A map keyed by object name (a permission set's `objects`, a translation's `objects`) now has its keys follow the copied objects, prefixed names included. So the duplicate's permission set grants on the duplicate's objects. Before, it kept the source's names and granted on the source. + +**Unchanged.** A prefixed object's copy name, a non-object item's name, and every other `failed[]` cause are unchanged. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2f9009318a6..ae6081fa287 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -26235,6 +26235,15 @@ export class ObjectStackProtocolImplementation implements * 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. + * + * Every copied object is named in the target namespace: `iojn_customer` + * becomes `iojn2_customer`, and an object whose name lacks the source + * prefix (`ticket`) becomes `iojn2_ticket` — the prefix rule's own rename + * (ADR-0129 D2). Such a name is rewritten where it is REFERENCED (an + * object-reference property, exact value), never inside free text; and + * when its target name is already claimed (the source also holds + * `iojn_ticket`) the object is refused in `failed[]`, before anything is + * written for it, with the source rename that clears it. */ async duplicatePackage(request: { sourcePackageId: string; @@ -26364,30 +26373,116 @@ export class ObjectStackProtocolImplementation implements // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) 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. + // Views/etc. are renamed by the source-prefix swap alone; a name without + // the prefix keeps it (the docblock re-namespaces OBJECT names). const renameName = (name: string): string => sourceNs && typeof name === 'string' && name.startsWith(`${sourceNs}_`) ? `${targetNs}_${name.slice(sourceNs.length + 1)}` : name; + + // ⭐ EVERY OBJECT GETS A NAME IN THE TARGET NAMESPACE (ADR-0070 D4, read + // with ADR-0129 D2). An object name IS its table name and its registry + // key, so a copy that keeps the source's name is a second claim on the + // source's object: before PR #23510 it stored a target row the registry + // refused, over the source's table, and after it the copy failed with + // the ADR-0029 D9.9 ownership prescription, which names no way out for + // a duplicate. ADR-0129 D2 says what the name is: every object a + // package defines is `${namespace}_${shortName}`, and the prefix rule's + // own prescription for a name that lacks the prefix + // (`validateObjectNamespacePrefix`) is "rename it to + // `${namespace}_${name}`". The copy is a NEW object, so that rename + // costs nothing here — while renaming the SOURCE object would move its + // table. A prefixed name swaps the source prefix for the target's; + // a name WITHOUT the source prefix (a direct-publish save, which no + // prefix gate reads, or a package that had no namespace) takes the + // target prefix in front of its whole name. + // + // ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by + // the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name + // `iojn2_ticket`) or by an object the source package holds — the object + // is reported in `failed[]` before anything is written for it, at its + // own name, with the rename that clears it. A second invented name + // (a numeric suffix, a third prefix) would be a name nobody wrote. + const objectNames = new Set( + rows.filter((row) => row?.type === 'object' && typeof row.name === 'string').map((row) => row.name as string), + ); 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); + const prefixed: string[] = []; + const unprefixed: string[] = []; + for (const name of objectNames) { + if (sourceNs && name.startsWith(`${sourceNs}_`)) prefixed.push(name); + else unprefixed.push(name); + } + for (const name of prefixed) { + const nn = renameName(name); + if (nn !== name) renameMap.set(name, nn); + } + // Every name a prefixed object's copy takes (itself, when the target + // namespace IS the source's), and every name the source holds. + const claimedBy = new Map(prefixed.map((name) => [renameName(name), name])); + const refusedObjects = new Map(); + for (const name of unprefixed) { + const nn = `${targetNs}_${name}`; + const holder = claimedBy.get(nn) ?? (objectNames.has(nn) ? nn : undefined); + if (holder !== undefined) { + const lacks = sourceNs + ? `lacks the '${sourceNs}_' prefix of package '${request.sourcePackageId}' (ADR-0129 D2: ` + + 'every object a package defines carries its namespace as a name prefix)' + : `carries no namespace prefix (package '${request.sourcePackageId}' declares no namespace)`; + const taken = holder === nn + ? `'${nn}' is an object package '${request.sourcePackageId}' already holds` + : `'${nn}' is already the copy name of object '${holder}'`; + refusedObjects.set( + name, + `Object '${name}' is not copied: its name ${lacks}, so its copy is named '${nn}' in ` + + `namespace '${targetNs}', and ${taken}. Rename '${name}' on the source to ` + + `${sourceNs ? `a '${sourceNs}_' name` : 'a name'} no other object in the package uses, ` + + 'then duplicate again.', + ); + continue; } + renameMap.set(name, nn); + claimedBy.set(nn, name); } + // 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); + // Prefixed names only: a prefixed name is distinctive enough to find in + // free text, a bare one is not (below). + const olds = prefixed.filter((name) => renameMap.has(name)).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); + // ⭐ AT AN OBJECT-REFERENCE PROPERTY, A NAME IS REWRITTEN WHOLE — the + // only place a BARE name is rewritten. Measured: putting `ticket` in the + // free-text rewrite above turns "A support ticket raised by a customer" + // into "A support iojn2_ticket raised …", and the same match renames the + // value of a lookup FIELD called `ticket` (`fields.ticket.name`). So a + // value is rewritten only where it sits under a property that names an + // object — the derived site index (`REFERENCE_SITES`, target `object`: + // `reference`, `object`, `objectName`, `objects`, …), read by property + // wherever it occurs in any item, because flow node configs (`config. + // objectName`) are declared by node descriptors the index does not + // walk — and only when it equals a renamed name exactly, in the four + // shapes a name arrives in there: a string, an array element, a record + // KEY (`permission.objects` is keyed by object name) or a `.name`. The + // record key holds for prefixed names too: a copied permission set + // otherwise kept `objects.`, a grant on the SOURCE. + const objectReferenceProperties = new Set( + (REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property), + ); + const deepRewrite = (v: any, atReference = false): any => { + if (typeof v === 'string') { + const whole = atReference ? renameMap.get(v) : undefined; + if (whole !== undefined) return whole; + return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v; + } + if (Array.isArray(v)) return v.map((element) => deepRewrite(element, atReference)); if (v && typeof v === 'object') { const o: any = {}; - for (const [k, val] of Object.entries(v)) o[k] = deepRewrite(val); + for (const [k, val] of Object.entries(v)) { + const key = atReference ? (renameMap.get(k) ?? k) : k; + o[key] = deepRewrite(val, objectReferenceProperties.has(k) || (atReference && k === 'name')); + } return o; } return v; @@ -26423,7 +26518,12 @@ export class ObjectStackProtocolImplementation implements const canonicalizeFlow = this.resolveFlowCanonicalizer(); for (const row of rows) { - const newName = renameName(row.name); + const refusal = row?.type === 'object' ? refusedObjects.get(row.name) : undefined; + if (refusal !== undefined) { + failed.push({ type: row.type, name: row.name, error: refusal }); + continue; + } + const newName = row?.type === 'object' ? (renameMap.get(row.name) ?? row.name) : renameName(row.name); const rawType = String(row.type); const singular = PLURAL_TO_SINGULAR[rawType] ?? rawType; let body: unknown; diff --git a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts new file mode 100644 index 00000000000..29c900f36c3 --- /dev/null +++ b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0070 D4 — `duplicatePackage` gives an object whose name lacks the source + * namespace prefix a name in the TARGET namespace, rewrites the references to + * it, and refuses it up front when that name is already claimed. + * + * ## The defect + * + * The method renamed an object only when its name started with the source + * prefix (`dupsrc_customer` → `dupdst_customer`). A name without it (`ticket`, + * stored by a direct-publish save, which no prefix gate reads) kept its name, + * and an object name IS its registry key and its table: the copy was a second + * claim on the source's object. It used to store a target row the registry + * refused, over the source's table; since the ADR-0029 D9.9 refusal landed it + * fails with an ownership prescription ("bind the customization to …, or have + * … extend the object instead") that names no way out for a duplicate. + * + * ## What the copy is now + * + * ADR-0129 D2: every object a package defines is `${namespace}_${shortName}`, + * and the prefix rule's own prescription for a missing prefix is + * `${namespace}_${name}`. So `ticket` copies as `dupdst_ticket`, and every + * reference to it follows — rewritten only where a property names an object, + * so a FIELD called `ticket` and the word "ticket" in prose are left alone. + * + * ## Why a real kernel + * + * The claim that matters is between the stores the kernel assembles: the + * `sys_metadata` rows the engine's driver holds, the `SchemaRegistry` that + * owns each object name, and the data plane that addresses a table by that + * name. `ObjectQLPlugin` binds the real protocol to the real engine and + * registry, so every pin reads rows out of the driver and owners out of the + * registry, never a payload. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** In-memory driver, copied from `protocol-runtime-owner-overlay-refusal.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + 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, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + 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) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + 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; +} + +/** Source package; `installPackage` derives its namespace `dupsrc`. */ +const P = 'com.qa.dupsrc'; +/** Target package; namespace `dupdst`, derived the same way. */ +const Q = 'com.qa.dupdst'; + +const PROSE = 'A support ticket raised by a customer'; + +const customerBody = { + name: 'dupsrc_customer', label: 'Customer', sharingModel: 'private', + fields: { full_name: { name: 'full_name', type: 'text', label: 'Full name' } }, +}; +/** The object whose name lacks the `dupsrc_` prefix. */ +const ticketBody = { + name: 'ticket', label: 'Ticket', description: PROSE, sharingModel: 'private', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + customer: { name: 'customer', type: 'lookup', reference: 'dupsrc_customer', label: 'Customer' }, + }, +}; +/** A prefixed object whose lookup FIELD is called `ticket` and points at `ticket`. */ +const noteBody = { + name: 'dupsrc_note', label: 'Note', sharingModel: 'private', + fields: { + body: { name: 'body', type: 'text', label: 'Body' }, + ticket: { name: 'ticket', type: 'lookup', reference: 'ticket', label: 'Ticket' }, + }, +}; +const ticketViewsBody = { + name: 'ticket_views', object: 'ticket', + list: { label: 'All tickets', type: 'grid', columns: [{ field: 'title' }] }, +}; +/** A flow binds its object on a node `config`, which the derived site index does not walk. */ +const onTicketBody = { + name: 'dupsrc_on_ticket', label: 'On ticket', type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'ticket', triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; +const opsBody = { + name: 'dupsrc_ops', label: 'Ops', + objects: { dupsrc_customer: { allowRead: true }, ticket: { allowRead: true } }, +}; + +describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the source namespace prefix', () => { + let kernel: ObjectKernel; + let stores: Stores; + let engine: ObjectQL; + let protocol: any; + + beforeEach(async () => { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stores = new Map(); + engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + protocol = kernel.getService('protocol'); + }); + afterEach(async () => { + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + /** The stored `sys_metadata` rows, as the driver holds them. */ + const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []); + const storedBody = (type: string, name: string, packageId: string): any => { + const row = storedRows().find((r) => r.type === type && r.name === name && r.package_id === packageId); + if (!row) return undefined; + return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + }; + const owner = (name: string) => engine.registry.getObjectOwner(name)?.packageId; + + async function save(type: string, item: { name: string }): Promise { + const res = await protocol.saveMetaItem({ type, name: item.name, packageId: P, item }); + expect(res.success).toBe(true); + } + + async function seedSource(extra: Array<[string, { name: string }]> = []): Promise { + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); + await save('object', customerBody); + await save('object', ticketBody); + await save('object', noteBody); + await save('view', ticketViewsBody); + await save('permission', opsBody); + await save('flow', onTicketBody); + for (const [type, item] of extra) await save(type, item); + // CONTROL: the bare name was stored by a direct save, no prefix gate read it. + expect(owner('ticket')).toBe(P); + } + + it('copies a prefixed and a non-prefixed object, each into the target namespace', async () => { + await seedSource(); + + const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + expect(res.failed).toEqual([]); + expect(res.success).toBe(true); + expect(res.copied).toEqual(expect.arrayContaining([ + { type: 'object', name: 'dupdst_customer' }, + { type: 'object', name: 'dupdst_ticket' }, + { type: 'object', name: 'dupdst_note' }, + ])); + // The copy is its own registry entry, owned by the target; the source + // object keeps its owner. + expect(owner('dupdst_ticket')).toBe(Q); + expect(owner('dupdst_customer')).toBe(Q); + expect(owner('ticket')).toBe(P); + expect(storedBody('object', 'dupdst_ticket', Q)).toMatchObject({ name: 'dupdst_ticket', label: 'Ticket' }); + // NEGATIVE CONTROL: no target row under the source's name. + expect(storedRows().filter((r) => r.type === 'object' && r.name === 'ticket').map((r) => r.package_id)) + .toEqual([P]); + }); + + it('references follow the new names; a field called `ticket` and the prose do not', async () => { + await seedSource(); + + await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + const ticketCopy = storedBody('object', 'dupdst_ticket', Q); + expect(ticketCopy.fields.customer.reference).toBe('dupdst_customer'); + // NEGATIVE CONTROL: the bare word in free text is not a reference. + expect(ticketCopy.description).toBe(PROSE); + + const noteCopy = storedBody('object', 'dupdst_note', Q); + expect(noteCopy.fields.ticket.reference).toBe('dupdst_ticket'); + // NEGATIVE CONTROL: the lookup FIELD keeps its name, key and value alike. + expect(Object.keys(noteCopy.fields)).toEqual(['body', 'ticket']); + expect(noteCopy.fields.ticket.name).toBe('ticket'); + + expect(storedBody('view', 'ticket_views', Q).object).toBe('dupdst_ticket'); + expect(storedBody('flow', 'dupdst_on_ticket', Q).nodes[0].config.objectName).toBe('dupdst_ticket'); + // A permission set is keyed by object name: both grants follow. + expect(Object.keys(storedBody('permission', 'dupdst_ops', Q).objects).sort()) + .toEqual(['dupdst_customer', 'dupdst_ticket']); + + // The source is untouched. + expect(storedBody('object', 'dupsrc_note', P).fields.ticket.reference).toBe('ticket'); + expect(Object.keys(storedBody('permission', 'dupsrc_ops', P).objects).sort()) + .toEqual(['dupsrc_customer', 'ticket']); + }); + + it('the copy has its own table: a record written through it never lands in the source\'s', async () => { + await seedSource(); + await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + await engine.insert('dupdst_ticket', { id: 't_copy', title: 'In the copy' }); + await engine.insert('ticket', { id: 't_source', title: 'In the source' }); + + expect([...(stores.get('dupdst_ticket')?.keys() ?? [])]).toEqual(['t_copy']); + // NEGATIVE CONTROL: the source table holds only its own record. + expect([...(stores.get('ticket')?.keys() ?? [])]).toEqual(['t_source']); + }); + + it('a name already claimed is refused up front, at the object, naming the rename', async () => { + // `dupsrc_ticket` copies as `dupdst_ticket` — the name `ticket` would take. + await seedSource([['object', { + name: 'dupsrc_ticket', label: 'Prefixed ticket', sharingModel: 'private', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + } as { name: string }]]); + const historyBefore = Array.from(stores.get('sys_metadata_history')?.values() ?? []) + .filter((r) => r.name === 'dupdst_ticket').length; + + const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + expect(res.success).toBe(false); + expect(res.failed).toHaveLength(1); + const [refusal] = res.failed; + expect(refusal).toMatchObject({ type: 'object', name: 'ticket' }); + // Located: the object, the name it would take, who holds that name, + // and the source rename that clears it. + expect(refusal.error).toContain("Object 'ticket'"); + expect(refusal.error).toContain("'dupdst_ticket'"); + expect(refusal.error).toContain("'dupsrc_ticket'"); + expect(refusal.error).toContain("Rename 'ticket' on the source"); + + // NEGATIVE CONTROL: the prefixed sibling took the name, with its own + // body, and nothing of `ticket` was written under it. + expect(owner('dupdst_ticket')).toBe(Q); + expect(storedRows().filter((r) => r.type === 'object' && r.name === 'dupdst_ticket')).toHaveLength(1); + expect(storedBody('object', 'dupdst_ticket', Q)).toMatchObject({ label: 'Prefixed ticket' }); + expect(Array.from(stores.get('sys_metadata_history')?.values() ?? []) + .filter((r) => r.name === 'dupdst_ticket').length).toBe(historyBefore + 1); + // Everything else still copies. + expect(res.copied).toEqual(expect.arrayContaining([ + { type: 'object', name: 'dupdst_customer' }, + { type: 'object', name: 'dupdst_note' }, + ])); + }); +});