Skip to content

Commit e6cd442

Browse files
authored
feat!: add database copy method (#18453)
Adds a low-level database adapter method for copying one stored collection document. ## Summary `payload.db.copy` selects a source document with a `where` query, applies optional top-level data overrides, and creates one new stored record. It assigns new IDs to the document and copied array and block rows. It does not copy version history. Use `payload.duplicate` for normal application duplication. `payload.db.copy` is intended for internal storage operations that must skip the Local API create flow. ## How - The operation uses existing adapter `findOne` and `create` methods. - It leaves transaction ownership with the caller through the supplied request. - It throws `NotFound` when the source query does not match a document. - It does not run Local API access control, hooks, field validation, or upload handling. - Custom-ID collections require `data.id` with the configured ID type. The operation throws a clear error when it is missing or invalid. - `createDatabaseAdapter` supplies the default implementation. ## Before and after Before this change, callers had to read a stored document, remove its ID, regenerate nested row IDs, and create the copy themselves. After this change, callers can use: ```ts await payload.db.copy({ collection: "posts", data: { title: "Copied post" }, req, where: { id: { equals: sourcePostID } }, }) ``` For a custom-ID collection, the caller must supply a unique destination ID: ```ts await payload.db.copy({ collection: "posts-with-custom-ids", data: { id: "copied-post", title: "Copied post", }, req, where: { id: { equals: sourcePostID } }, }) ``` ## Breaking changes `BaseDatabaseAdapter` now requires `copy`. Adapters created with `createDatabaseAdapter` receive the default implementation automatically. Direct `BaseDatabaseAdapter` implementations must add this method. ## Validation Real integration tests cover custom IDs, data overrides, fresh nested row IDs, missing sources, and caller-owned rollback. The custom-ID behaviour passes on MongoDB, Postgres, and SQLite. The Payload package build also passes. Written with AI
1 parent 401d1d8 commit e6cd442

6 files changed

Lines changed: 283 additions & 0 deletions

File tree

‎docs/database/overview.mdx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,31 @@ The default implementation uses existing adapter methods. Custom adapters create
9898
It's important to note that nearly every Payload feature is available in all of our officially supported Database Adapters, including [Localization](../configuration/localization), [Arrays](../fields/array), [Blocks](../fields/blocks), etc. The only thing that is not supported in SQLite yet is the [Point Field](/docs/fields/point), but that should be added soon.
9999

100100
It's up to you to choose which database you would like to use based on the requirements of your project. Payload has no opinion on which database you should ultimately choose.
101+
102+
## Copying a stored document
103+
104+
Use `payload.db.copy` to copy one stored collection document. The method selects the source with a `where` query and creates a new database record. Use `data` to replace top-level source values. Payload also assigns new IDs to copied array and block rows.
105+
106+
```ts
107+
const copiedPost = await payload.db.copy({
108+
collection: 'posts',
109+
data: {
110+
title: 'Copied post',
111+
},
112+
req,
113+
where: {
114+
id: {
115+
equals: sourcePostID,
116+
},
117+
},
118+
})
119+
```
120+
121+
### Key differences
122+
123+
- `payload.db.copy` makes a one-to-one storage copy: one current source document becomes one new database record. It does not copy version history.
124+
- It copies stored values as-is, except document, array, and block row IDs. Unique fields and custom database indexes can reject the new record. Use `data` overrides or handle these conflicts yourself.
125+
- Custom-ID collections require `data.id` with the configured ID type. ID hooks do not run, so the caller must supply a unique destination ID.
126+
- It does not run access control, hooks, field validation, or upload handling. Use [`payload.duplicate`](/docs/local-api/overview#collection-create) for normal application duplication.
127+
128+
The default implementation uses existing adapter methods. Custom adapters created with `createDatabaseAdapter` receive this implementation automatically. Direct implementations of `BaseDatabaseAdapter` must provide `copy` after this change.

‎packages/payload/src/database/createDatabaseAdapter.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from './types.js'
99

1010
import { defaultBatchProcessing } from './defaultBatchProcessing.js'
11+
import { defaultCopy } from './defaultCopy.js'
1112
import { createMigration } from './migrations/createMigration.js'
1213
import { migrate } from './migrations/migrate.js'
1314
import { migrateDown } from './migrations/migrateDown.js'
@@ -27,6 +28,7 @@ export function createDatabaseAdapter<T extends BaseDatabaseAdapter>(
2728
| 'allowIDOnCreate'
2829
| 'batchProcessing'
2930
| 'bulkOperationsSingleTransaction'
31+
| 'copy'
3032
| 'createMigration'
3133
| 'migrate'
3234
| 'migrateDown'
@@ -44,6 +46,7 @@ export function createDatabaseAdapter<T extends BaseDatabaseAdapter>(
4446
beginTransaction,
4547
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
4648
commitTransaction,
49+
copy: defaultCopy,
4750
createMigration,
4851
migrate,
4952
migrateDown,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { v4 as uuid } from 'uuid'
2+
3+
import type { SanitizedConfig } from '../config/types.js'
4+
import type { Field } from '../fields/config/types.js'
5+
import type { Copy } from './types.js'
6+
7+
import { NotFound } from '../errors/NotFound.js'
8+
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
9+
import { traverseFields } from '../utilities/traverseFields.js'
10+
11+
export const defaultCopy: Copy = async function defaultCopy({ collection, data = {}, req, where }) {
12+
const collectionConfig = this.payload.collections[collection]!
13+
const { customIDType } = collectionConfig
14+
const hasValidCustomID =
15+
customIDType === 'number' ? typeof data.id === 'number' : typeof data.id === 'string'
16+
17+
if (customIDType && !hasValidCustomID) {
18+
throw new TypeError(
19+
`Database copy for collection "${collection}" requires data.id to match its custom ${customIDType} ID type`,
20+
)
21+
}
22+
23+
const sourceDocument = await this.findOne({
24+
collection,
25+
req,
26+
where,
27+
})
28+
29+
if (!sourceDocument) {
30+
throw new NotFound(req?.t)
31+
}
32+
33+
const { id: _sourceID, ...sourceData } = sourceDocument
34+
const copiedData = copyDataWithFreshRowIDs({
35+
config: this.payload.config,
36+
data: {
37+
...sourceData,
38+
...data,
39+
},
40+
fields: collectionConfig.config.fields,
41+
})
42+
43+
return this.create({
44+
collection,
45+
...(customIDType ? { customID: data.id as number | string } : {}),
46+
data: copiedData,
47+
req,
48+
})
49+
}
50+
51+
const copyDataWithFreshRowIDs = ({
52+
config,
53+
data,
54+
fields,
55+
}: {
56+
config: SanitizedConfig
57+
data: Record<string, unknown>
58+
fields: Field[]
59+
}): Record<string, unknown> => {
60+
const copiedData = deepCopyObjectSimple(data)
61+
62+
traverseFields({
63+
callback: ({ field, ref }) => {
64+
if (
65+
(field.type !== 'array' && field.type !== 'blocks') ||
66+
!field.name ||
67+
!ref ||
68+
typeof ref !== 'object'
69+
) {
70+
return
71+
}
72+
73+
const fieldValue = (ref as Record<string, unknown>)[field.name]
74+
const assignFreshRowIDs = (rows: unknown) => {
75+
if (!Array.isArray(rows)) {
76+
return
77+
}
78+
79+
for (const row of rows) {
80+
if (row && typeof row === 'object') {
81+
;(row as Record<string, unknown>).id = uuid()
82+
}
83+
}
84+
}
85+
86+
if (Array.isArray(fieldValue)) {
87+
assignFreshRowIDs(fieldValue)
88+
} else if (fieldValue && typeof fieldValue === 'object') {
89+
for (const localeValue of Object.values(fieldValue as Record<string, unknown>)) {
90+
assignFreshRowIDs(localeValue)
91+
}
92+
}
93+
},
94+
config,
95+
fields,
96+
fillEmpty: false,
97+
ref: copiedData,
98+
})
99+
100+
return copiedData
101+
}

‎packages/payload/src/database/types.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export interface BaseDatabaseAdapter {
4343
* Open the connection to the database
4444
*/
4545
connect?: Connect
46+
/**
47+
* Copy one stored collection document without running Local API operations.
48+
*/
49+
copy: Copy
4650
count: Count
4751
countGlobalVersions: CountGlobalVersions
4852
countVersions: CountVersions
@@ -235,6 +239,20 @@ export type BatchProcessing = (
235239
args: BatchProcessingArgs,
236240
) => Promise<BatchProcessingResult[]>
237241

242+
export type CopyArgs = {
243+
collection: CollectionSlug
244+
/**
245+
* Top-level field values that replace values from the source document.
246+
* Custom-ID collections require an `id` that matches the configured ID type.
247+
*/
248+
data?: Record<string, unknown>
249+
req?: Partial<PayloadRequest>
250+
/** Selects the stored source document. */
251+
where: Where
252+
}
253+
254+
export type Copy = (this: BaseDatabaseAdapter, args: CopyArgs) => Promise<Document>
255+
238256
export type CreateMigration = (args: {
239257
file?: string
240258
forceAcceptWarning?: boolean

‎packages/payload/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,8 @@ export type {
15941594
BeginTransaction,
15951595
CommitTransaction,
15961596
Connect,
1597+
Copy,
1598+
CopyArgs,
15971599
Count,
15981600
CountArgs,
15991601
CountGlobalVersionArgs,

‎test/database/copy.int.spec.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/* eslint vitest/no-standalone-expect: ["error", { "additionalTestBlockFunctions": ["test", "test.options"] }] -- Tests use the shared fixture wrapper. */
2+
3+
import type { PayloadRequest } from 'payload'
4+
5+
import { initTransaction, killTransaction } from 'payload'
6+
import { expect } from 'vitest'
7+
8+
import { test } from '../__helpers/int/vitest.js'
9+
import { customIDsSlug, postsSlug } from './shared.js'
10+
11+
type CopyTestPost = {
12+
arrayWithIDs?: { id?: string; text?: string }[]
13+
blocksWithIDs?: { blockType: string; id?: string; text?: string }[]
14+
id: number | string
15+
title: string
16+
}
17+
18+
test.suite('Database copy', { config: './config.ts' }, () => {
19+
test('should copy stored data with overrides and new row IDs', async ({ payload }) => {
20+
const source = (await payload.create({
21+
collection: postsSlug,
22+
data: {
23+
arrayWithIDs: [{ text: 'source array row' }],
24+
blocksWithIDs: [{ blockType: 'block-first', text: 'source block row' }],
25+
title: 'source title',
26+
},
27+
overrideAccess: true,
28+
})) as CopyTestPost
29+
30+
const copied = (await payload.db.copy({
31+
collection: postsSlug,
32+
data: { title: 'copied title' },
33+
req: { payload },
34+
where: { id: { equals: source.id } },
35+
})) as CopyTestPost
36+
37+
expect(copied.id).not.toBe(source.id)
38+
expect(copied.title).toBe('copied title')
39+
expect(copied.arrayWithIDs?.[0]?.text).toBe('source array row')
40+
expect(copied.arrayWithIDs?.[0]?.id).not.toBe(source.arrayWithIDs?.[0]?.id)
41+
expect(copied.blocksWithIDs?.[0]?.text).toBe('source block row')
42+
expect(copied.blocksWithIDs?.[0]?.id).not.toBe(source.blocksWithIDs?.[0]?.id)
43+
})
44+
45+
test('should reject a missing source document', async ({ payload }) => {
46+
await expect(
47+
payload.db.copy({
48+
collection: postsSlug,
49+
req: { payload },
50+
where: { id: { equals: 'missing-document' } },
51+
}),
52+
).rejects.toMatchObject({ name: 'NotFound' })
53+
})
54+
55+
test('should require a destination ID for a custom-ID collection', async ({ payload }) => {
56+
const source = await payload.create({
57+
collection: customIDsSlug,
58+
data: { title: 'custom ID source' },
59+
overrideAccess: true,
60+
})
61+
62+
await expect(
63+
payload.db.copy({
64+
collection: customIDsSlug,
65+
req: { payload },
66+
where: { id: { equals: source.id } },
67+
}),
68+
).rejects.toThrow('requires data.id')
69+
})
70+
71+
test('should copy a custom-ID document when given a destination ID', async ({ payload }) => {
72+
const source = await payload.create({
73+
collection: customIDsSlug,
74+
data: { title: 'custom ID source' },
75+
overrideAccess: true,
76+
})
77+
78+
const copied = await payload.db.copy({
79+
collection: customIDsSlug,
80+
data: {
81+
id: 'copied-custom-id',
82+
title: 'custom ID copy',
83+
},
84+
req: { payload },
85+
where: { id: { equals: source.id } },
86+
})
87+
88+
expect(copied.id).toBe('copied-custom-id')
89+
expect(copied.title).toBe('custom ID copy')
90+
})
91+
92+
test.options(
93+
'should leave rollback to the caller transaction',
94+
{ db: (adapter) => adapter === 'mongodb' || adapter === 'postgres' },
95+
async ({ payload }) => {
96+
const source = await payload.create({
97+
collection: postsSlug,
98+
data: { title: 'transaction source' },
99+
overrideAccess: true,
100+
})
101+
const req = { payload } as PayloadRequest
102+
const didStartTransaction = await initTransaction(req)
103+
const callerTransactionID = await req.transactionID
104+
let copiedID: number | string | undefined
105+
106+
expect(didStartTransaction).toBe(true)
107+
108+
try {
109+
const copied = await payload.db.copy({
110+
collection: postsSlug,
111+
req,
112+
where: { id: { equals: source.id } },
113+
})
114+
115+
copiedID = copied.id
116+
expect(req.transactionID).toBe(callerTransactionID)
117+
} finally {
118+
if (req.transactionID) {
119+
await killTransaction(req)
120+
}
121+
}
122+
123+
const copyAfterRollback = await payload.db.findOne({
124+
collection: postsSlug,
125+
where: { id: { equals: copiedID } },
126+
})
127+
128+
expect(copyAfterRollback).toBeNull()
129+
},
130+
)
131+
})

0 commit comments

Comments
 (0)