@byline/core 3.5.1 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/field-types.d.ts +22 -0
- package/dist/@types/site-config.d.ts +8 -1
- package/dist/services/document-lifecycle/context.d.ts +79 -0
- package/dist/services/document-lifecycle/context.js +8 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +62 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +157 -0
- package/dist/services/document-lifecycle/create.d.ts +45 -0
- package/dist/services/document-lifecycle/create.js +91 -0
- package/dist/services/document-lifecycle/delete-locale.d.ts +44 -0
- package/dist/services/document-lifecycle/delete-locale.js +117 -0
- package/dist/services/document-lifecycle/delete.d.ts +37 -0
- package/dist/services/document-lifecycle/delete.js +113 -0
- package/dist/services/document-lifecycle/duplicate.d.ts +60 -0
- package/dist/services/document-lifecycle/duplicate.js +219 -0
- package/dist/services/document-lifecycle/index.d.ts +43 -0
- package/dist/services/document-lifecycle/index.js +33 -0
- package/dist/services/document-lifecycle/internals.d.ts +113 -0
- package/dist/services/document-lifecycle/internals.js +218 -0
- package/dist/services/document-lifecycle/merge-locale-data.d.ts +48 -0
- package/dist/services/document-lifecycle/merge-locale-data.js +147 -0
- package/dist/services/document-lifecycle/restore.d.ts +57 -0
- package/dist/services/document-lifecycle/restore.js +162 -0
- package/dist/services/document-lifecycle/status.d.ts +41 -0
- package/dist/services/document-lifecycle/status.js +150 -0
- package/dist/services/document-lifecycle/system-fields.d.ts +62 -0
- package/dist/services/document-lifecycle/system-fields.js +100 -0
- package/dist/services/document-lifecycle/update.d.ts +84 -0
- package/dist/services/document-lifecycle/update.js +216 -0
- package/dist/services/document-lifecycle.test.node.js +1 -1
- package/dist/services/document-to-markdown.d.ts +79 -0
- package/dist/services/document-to-markdown.js +267 -0
- package/dist/services/document-to-markdown.test.node.d.ts +8 -0
- package/dist/services/document-to-markdown.test.node.js +161 -0
- package/dist/services/field-upload.js +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
9
|
+
export interface DeleteDocumentResult {
|
|
10
|
+
deletedVersionCount: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Soft-delete a document.
|
|
14
|
+
*
|
|
15
|
+
* Marks all versions of the document as deleted (`is_deleted = true`). The
|
|
16
|
+
* `current_documents` view automatically filters deleted rows, so the
|
|
17
|
+
* document disappears from all list / page queries without physically
|
|
18
|
+
* removing data.
|
|
19
|
+
*
|
|
20
|
+
* When the collection has any upload-capable image/file field and
|
|
21
|
+
* `ctx.storage` is provided, every original file and persisted variant
|
|
22
|
+
* across those fields is also removed from storage after the DB
|
|
23
|
+
* soft-delete succeeds. Variant paths are read from the field value's
|
|
24
|
+
* `variants` array (no re-derivation from `upload.sizes`), so cleanup
|
|
25
|
+
* stays correct even if the size set changed between upload and delete.
|
|
26
|
+
* File cleanup failures are logged but are non-fatal.
|
|
27
|
+
*
|
|
28
|
+
* Flow:
|
|
29
|
+
* 1. Fetch current document (reconstruct when upload-capable fields exist)
|
|
30
|
+
* 2. `hooks.beforeDelete({ documentId, collectionPath })`
|
|
31
|
+
* 3. `db.commands.documents.softDeleteDocument({ document_id })`
|
|
32
|
+
* 4. Storage file + variant cleanup (skipped when no upload fields, non-fatal)
|
|
33
|
+
* 5. `hooks.afterDelete({ documentId, collectionPath })`
|
|
34
|
+
*/
|
|
35
|
+
export declare function deleteDocument(ctx: DocumentLifecycleContext, params: {
|
|
36
|
+
documentId: string;
|
|
37
|
+
}): Promise<DeleteDocumentResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { resolveHooks } from '../../@types/index.js';
|
|
9
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
+
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { getUploadFields } from '../../utils/storage-utils.js';
|
|
13
|
+
import { invokeHook } from './internals.js';
|
|
14
|
+
/**
|
|
15
|
+
* Soft-delete a document.
|
|
16
|
+
*
|
|
17
|
+
* Marks all versions of the document as deleted (`is_deleted = true`). The
|
|
18
|
+
* `current_documents` view automatically filters deleted rows, so the
|
|
19
|
+
* document disappears from all list / page queries without physically
|
|
20
|
+
* removing data.
|
|
21
|
+
*
|
|
22
|
+
* When the collection has any upload-capable image/file field and
|
|
23
|
+
* `ctx.storage` is provided, every original file and persisted variant
|
|
24
|
+
* across those fields is also removed from storage after the DB
|
|
25
|
+
* soft-delete succeeds. Variant paths are read from the field value's
|
|
26
|
+
* `variants` array (no re-derivation from `upload.sizes`), so cleanup
|
|
27
|
+
* stays correct even if the size set changed between upload and delete.
|
|
28
|
+
* File cleanup failures are logged but are non-fatal.
|
|
29
|
+
*
|
|
30
|
+
* Flow:
|
|
31
|
+
* 1. Fetch current document (reconstruct when upload-capable fields exist)
|
|
32
|
+
* 2. `hooks.beforeDelete({ documentId, collectionPath })`
|
|
33
|
+
* 3. `db.commands.documents.softDeleteDocument({ document_id })`
|
|
34
|
+
* 4. Storage file + variant cleanup (skipped when no upload fields, non-fatal)
|
|
35
|
+
* 5. `hooks.afterDelete({ documentId, collectionPath })`
|
|
36
|
+
*/
|
|
37
|
+
export async function deleteDocument(ctx, params) {
|
|
38
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteDocument' }, async () => {
|
|
39
|
+
const { db, collectionPath, definition, logger } = ctx;
|
|
40
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'delete');
|
|
41
|
+
const hooks = await resolveHooks(definition);
|
|
42
|
+
// 1. Verify the document exists.
|
|
43
|
+
// For collections that have any upload-capable image/file field
|
|
44
|
+
// AND a storage provider, fetch with reconstruct: true so we
|
|
45
|
+
// can read the stored file paths (and persisted variant paths)
|
|
46
|
+
// from the field values before the DB rows are deleted.
|
|
47
|
+
const uploadFieldNames = getUploadFields(definition).map((f) => f.name);
|
|
48
|
+
const isUploadCollection = uploadFieldNames.length > 0 && ctx.storage != null;
|
|
49
|
+
const latest = await db.queries.documents.getDocumentById({
|
|
50
|
+
collection_id: ctx.collectionId,
|
|
51
|
+
document_id: params.documentId,
|
|
52
|
+
reconstruct: isUploadCollection,
|
|
53
|
+
});
|
|
54
|
+
if (latest == null) {
|
|
55
|
+
throw ERR_NOT_FOUND({
|
|
56
|
+
message: 'document not found',
|
|
57
|
+
details: { documentId: params.documentId },
|
|
58
|
+
}).log(ctx.logger);
|
|
59
|
+
}
|
|
60
|
+
// Collect storage paths for every upload-capable field on the doc:
|
|
61
|
+
// the original file plus every persisted variant. Reading the
|
|
62
|
+
// variants from the field value (rather than re-deriving from
|
|
63
|
+
// `upload.sizes`) keeps cleanup correct even when the size set
|
|
64
|
+
// changes between upload and delete.
|
|
65
|
+
const storagePathsToDelete = [];
|
|
66
|
+
if (isUploadCollection) {
|
|
67
|
+
for (const fieldName of uploadFieldNames) {
|
|
68
|
+
const fieldValue = latest?.fields?.[fieldName];
|
|
69
|
+
if (!fieldValue || typeof fieldValue !== 'object')
|
|
70
|
+
continue;
|
|
71
|
+
if (typeof fieldValue.storagePath === 'string') {
|
|
72
|
+
storagePathsToDelete.push(fieldValue.storagePath);
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(fieldValue.variants)) {
|
|
75
|
+
for (const variant of fieldValue.variants) {
|
|
76
|
+
if (variant && typeof variant.storagePath === 'string') {
|
|
77
|
+
storagePathsToDelete.push(variant.storagePath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const hookCtx = {
|
|
84
|
+
documentId: params.documentId,
|
|
85
|
+
collectionPath,
|
|
86
|
+
// The current document was fetched above (reconstructed only for
|
|
87
|
+
// upload collections, but the envelope carries the locale-resolved
|
|
88
|
+
// `path` projection either way). Surface it so delete hooks can purge
|
|
89
|
+
// the specific document/URL.
|
|
90
|
+
path: latest.path ?? '',
|
|
91
|
+
};
|
|
92
|
+
// 2. beforeDelete hook.
|
|
93
|
+
await invokeHook(hooks?.beforeDelete, hookCtx);
|
|
94
|
+
// 3. Soft-delete all versions.
|
|
95
|
+
const deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
96
|
+
document_id: params.documentId,
|
|
97
|
+
});
|
|
98
|
+
// 4. Clean up storage files. Non-fatal: logs errors but does not throw.
|
|
99
|
+
if (ctx.storage && storagePathsToDelete.length > 0) {
|
|
100
|
+
for (const storagePath of storagePathsToDelete) {
|
|
101
|
+
try {
|
|
102
|
+
await ctx.storage.delete(storagePath);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.error({ err, storagePath }, 'failed to delete storage file');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 5. afterDelete hook.
|
|
110
|
+
await invokeHook(hooks?.afterDelete, hookCtx);
|
|
111
|
+
return { deletedVersionCount };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
9
|
+
export interface DuplicateDocumentResult {
|
|
10
|
+
/** The newly-created document's id. */
|
|
11
|
+
documentId: string;
|
|
12
|
+
/** The newly-created version id (every duplicate starts at version 1). */
|
|
13
|
+
documentVersionId: string;
|
|
14
|
+
/** The id of the document this duplicate was cloned from. */
|
|
15
|
+
sourceDocumentId: string;
|
|
16
|
+
/**
|
|
17
|
+
* Final `path` written into `byline_document_paths` for the new document.
|
|
18
|
+
* Surfaced in the result so the UI can include it in success toasts /
|
|
19
|
+
* navigate to it directly.
|
|
20
|
+
*/
|
|
21
|
+
newPath: string;
|
|
22
|
+
/**
|
|
23
|
+
* `true` when the candidate path collided with an existing row and the
|
|
24
|
+
* lifecycle retried with a short-UUID suffix. UIs can surface a hint that
|
|
25
|
+
* the auto-generated path is uglier than usual so the editor knows to
|
|
26
|
+
* adjust it via the path widget.
|
|
27
|
+
*/
|
|
28
|
+
pathRetried: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Duplicate a document, cloning all of its locales into a brand-new
|
|
32
|
+
* document atomically.
|
|
33
|
+
*
|
|
34
|
+
* Flow:
|
|
35
|
+
* 1. `assertActorCanPerform('create')` — duplicating is a create at the
|
|
36
|
+
* ability level. The source must be readable (any RBAC scoping the
|
|
37
|
+
* caller has applies via the storage read).
|
|
38
|
+
* 2. Fetch the source with `locale: 'all'` so a single read carries the
|
|
39
|
+
* full multi-locale tree forward.
|
|
40
|
+
* 3. Deep-clone the source fields; strip block / array-item `_id` meta
|
|
41
|
+
* so the new doc gets fresh identities.
|
|
42
|
+
* 4. Append `" (copy)"` to the `useAsTitle` field's value(s).
|
|
43
|
+
* 5. Derive a candidate path from the default-locale suffixed title.
|
|
44
|
+
* 6. `hooks.beforeCreate({ data, collectionPath, duplicate })`.
|
|
45
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with `locale:
|
|
46
|
+
* 'all'`, `action: 'create'`, no `documentId` → fresh document_id.
|
|
47
|
+
* On `ERR_PATH_CONFLICT` retry once with the candidate path plus a
|
|
48
|
+
* 4-char UUID suffix; bounded to two attempts, no existence
|
|
49
|
+
* pre-check, no TOCTOU race.
|
|
50
|
+
* 8. `hooks.afterCreate({ data, collectionPath, documentId,
|
|
51
|
+
* documentVersionId, duplicate })`.
|
|
52
|
+
*
|
|
53
|
+
* The write is atomic at the storage layer — a partial duplicate is
|
|
54
|
+
* structurally impossible. Editors are expected to rename both the
|
|
55
|
+
* title and the system path after the operation; the UI surfaces a
|
|
56
|
+
* confirmation modal that calls this out.
|
|
57
|
+
*/
|
|
58
|
+
export declare function duplicateDocument(ctx: DocumentLifecycleContext, params: {
|
|
59
|
+
sourceDocumentId: string;
|
|
60
|
+
}): Promise<DuplicateDocumentResult>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { resolveHooks } from '../../@types/index.js';
|
|
9
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
+
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { slugify } from '../../utils/slugify.js';
|
|
13
|
+
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
14
|
+
import { assignCounterValues } from '../assign-counter-values.js';
|
|
15
|
+
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
16
|
+
/**
|
|
17
|
+
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
18
|
+
* duplicate's data tree. Handles both shapes:
|
|
19
|
+
*
|
|
20
|
+
* - Localized title — `fields[useAsTitle]` is `{ en: '...', fr: '...' }`,
|
|
21
|
+
* suffix is applied to every locale's value.
|
|
22
|
+
* - Non-localized title — `fields[useAsTitle]` is a plain string;
|
|
23
|
+
* suffix appended once.
|
|
24
|
+
*
|
|
25
|
+
* No-op when the collection has no `useAsTitle` or the title is null /
|
|
26
|
+
* undefined; the duplicate proceeds with the source's title verbatim and
|
|
27
|
+
* the editor can rename it. Mutates the tree in place.
|
|
28
|
+
*/
|
|
29
|
+
function applyDuplicateTitleSuffix(definition, fields, suffix) {
|
|
30
|
+
const titleField = definition.useAsTitle;
|
|
31
|
+
if (titleField == null)
|
|
32
|
+
return;
|
|
33
|
+
const value = fields[titleField];
|
|
34
|
+
if (value == null)
|
|
35
|
+
return;
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
fields[titleField] = value + suffix;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
41
|
+
const localized = value;
|
|
42
|
+
for (const loc of Object.keys(localized)) {
|
|
43
|
+
const v = localized[loc];
|
|
44
|
+
if (typeof v === 'string') {
|
|
45
|
+
localized[loc] = v + suffix;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute the candidate path for a duplicate.
|
|
52
|
+
*
|
|
53
|
+
* Reads the default-locale value of `definition.useAsPath` (peeling the
|
|
54
|
+
* localized-shape wrapper if present) and runs it through the existing
|
|
55
|
+
* `derivePath` helper. Falls back to `crypto.randomUUID()` when no source
|
|
56
|
+
* value is available — matches `createDocument`'s behaviour for paths
|
|
57
|
+
* that can't be slugged.
|
|
58
|
+
*/
|
|
59
|
+
function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifier) {
|
|
60
|
+
const useAsPath = definition.useAsPath;
|
|
61
|
+
if (useAsPath == null) {
|
|
62
|
+
return crypto.randomUUID();
|
|
63
|
+
}
|
|
64
|
+
const raw = fields[useAsPath];
|
|
65
|
+
// Peel the localized wrapper to find the default-locale value.
|
|
66
|
+
let sourceValue = raw;
|
|
67
|
+
if (raw != null && typeof raw === 'object' && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
68
|
+
sourceValue = raw[defaultLocale];
|
|
69
|
+
}
|
|
70
|
+
return derivePath(definition, { [useAsPath]: sourceValue }, defaultLocale, slugifier);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Duplicate a document, cloning all of its locales into a brand-new
|
|
74
|
+
* document atomically.
|
|
75
|
+
*
|
|
76
|
+
* Flow:
|
|
77
|
+
* 1. `assertActorCanPerform('create')` — duplicating is a create at the
|
|
78
|
+
* ability level. The source must be readable (any RBAC scoping the
|
|
79
|
+
* caller has applies via the storage read).
|
|
80
|
+
* 2. Fetch the source with `locale: 'all'` so a single read carries the
|
|
81
|
+
* full multi-locale tree forward.
|
|
82
|
+
* 3. Deep-clone the source fields; strip block / array-item `_id` meta
|
|
83
|
+
* so the new doc gets fresh identities.
|
|
84
|
+
* 4. Append `" (copy)"` to the `useAsTitle` field's value(s).
|
|
85
|
+
* 5. Derive a candidate path from the default-locale suffixed title.
|
|
86
|
+
* 6. `hooks.beforeCreate({ data, collectionPath, duplicate })`.
|
|
87
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with `locale:
|
|
88
|
+
* 'all'`, `action: 'create'`, no `documentId` → fresh document_id.
|
|
89
|
+
* On `ERR_PATH_CONFLICT` retry once with the candidate path plus a
|
|
90
|
+
* 4-char UUID suffix; bounded to two attempts, no existence
|
|
91
|
+
* pre-check, no TOCTOU race.
|
|
92
|
+
* 8. `hooks.afterCreate({ data, collectionPath, documentId,
|
|
93
|
+
* documentVersionId, duplicate })`.
|
|
94
|
+
*
|
|
95
|
+
* The write is atomic at the storage layer — a partial duplicate is
|
|
96
|
+
* structurally impossible. Editors are expected to rename both the
|
|
97
|
+
* title and the system path after the operation; the UI surfaces a
|
|
98
|
+
* confirmation modal that calls this out.
|
|
99
|
+
*/
|
|
100
|
+
export async function duplicateDocument(ctx, params) {
|
|
101
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
102
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
103
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
|
|
104
|
+
const slugifier = ctx.slugifier ?? slugify;
|
|
105
|
+
const hooks = await resolveHooks(definition);
|
|
106
|
+
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
107
|
+
const source = await db.queries.documents.getDocumentById({
|
|
108
|
+
collection_id: collectionId,
|
|
109
|
+
document_id: params.sourceDocumentId,
|
|
110
|
+
locale: 'all',
|
|
111
|
+
reconstruct: true,
|
|
112
|
+
lenient: true,
|
|
113
|
+
requestContext: ctx.requestContext,
|
|
114
|
+
});
|
|
115
|
+
if (source == null) {
|
|
116
|
+
throw ERR_NOT_FOUND({
|
|
117
|
+
message: 'source document not found',
|
|
118
|
+
details: { sourceDocumentId: params.sourceDocumentId, collectionPath },
|
|
119
|
+
}).log(ctx.logger);
|
|
120
|
+
}
|
|
121
|
+
const sourceRecord = source;
|
|
122
|
+
const sourceFields = sourceRecord.fields ?? {};
|
|
123
|
+
// 2. Deep clone — we'll mutate (suffix titles, strip meta ids).
|
|
124
|
+
const clonedFields = structuredClone(sourceFields);
|
|
125
|
+
// 3. Fresh block / array-item identities for the new doc.
|
|
126
|
+
stripMetaIdsInPlace(clonedFields);
|
|
127
|
+
// 4. Suffix titles per locale (or once if non-localized).
|
|
128
|
+
const titleSuffix = ' (copy)';
|
|
129
|
+
applyDuplicateTitleSuffix(definition, clonedFields, titleSuffix);
|
|
130
|
+
// 5. Derive candidate path from the (now suffixed) default-locale title.
|
|
131
|
+
const candidatePath = deriveDuplicateCandidatePath(definition, clonedFields, defaultLocale, slugifier);
|
|
132
|
+
// 6. beforeCreate hook with duplicate marker.
|
|
133
|
+
const duplicateMarker = { sourceDocumentId: params.sourceDocumentId };
|
|
134
|
+
await invokeHook(hooks?.beforeCreate, {
|
|
135
|
+
data: clonedFields,
|
|
136
|
+
collectionPath,
|
|
137
|
+
duplicate: duplicateMarker,
|
|
138
|
+
});
|
|
139
|
+
// 6b. Reset counter fields to freshly-allocated values. The clone
|
|
140
|
+
// currently carries the source document's counter values; without
|
|
141
|
+
// this pass, the duplicate would alias the source's facet IDs and
|
|
142
|
+
// break the "one ID per term" contract.
|
|
143
|
+
await assignCounterValues({
|
|
144
|
+
fields: definition.fields,
|
|
145
|
+
data: clonedFields,
|
|
146
|
+
counters: db.commands.counters,
|
|
147
|
+
});
|
|
148
|
+
// 7. Atomic write. Try the candidate path; on ERR_PATH_CONFLICT
|
|
149
|
+
// retry once with a 4-char UUID suffix.
|
|
150
|
+
const defaultStatus = getDefaultStatus(definition);
|
|
151
|
+
let finalPath = candidatePath;
|
|
152
|
+
let pathRetried = false;
|
|
153
|
+
let result;
|
|
154
|
+
// Append-at-end order_key for `orderable: true` collections. Computed
|
|
155
|
+
// before the insert; the source row's order is intentionally not
|
|
156
|
+
// copied — duplicates land at the end of the list.
|
|
157
|
+
const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
|
|
158
|
+
// Embed walker (no-op for multi-locale richtext leaves — see
|
|
159
|
+
// restoreDocumentVersion for the same caveat).
|
|
160
|
+
await applyRichTextEmbed(ctx, clonedFields);
|
|
161
|
+
try {
|
|
162
|
+
result = await db.commands.documents
|
|
163
|
+
.createDocumentVersion({
|
|
164
|
+
collectionId,
|
|
165
|
+
collectionVersion: ctx.collectionVersion,
|
|
166
|
+
collectionConfig: definition,
|
|
167
|
+
action: 'create',
|
|
168
|
+
documentData: clonedFields,
|
|
169
|
+
path: finalPath,
|
|
170
|
+
status: defaultStatus,
|
|
171
|
+
locale: 'all',
|
|
172
|
+
orderKey,
|
|
173
|
+
})
|
|
174
|
+
.catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (!isPathConflictError(err)) {
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
// Single retry with a short UUID suffix. crypto.randomUUID() is
|
|
181
|
+
// 36 chars; take the first 4 hex digits for a compact disambiguator.
|
|
182
|
+
const shortDisambiguator = crypto.randomUUID().slice(0, 4);
|
|
183
|
+
finalPath = `${candidatePath}-${shortDisambiguator}`;
|
|
184
|
+
pathRetried = true;
|
|
185
|
+
ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
|
|
186
|
+
result = await db.commands.documents
|
|
187
|
+
.createDocumentVersion({
|
|
188
|
+
collectionId,
|
|
189
|
+
collectionVersion: ctx.collectionVersion,
|
|
190
|
+
collectionConfig: definition,
|
|
191
|
+
action: 'create',
|
|
192
|
+
documentData: clonedFields,
|
|
193
|
+
path: finalPath,
|
|
194
|
+
status: defaultStatus,
|
|
195
|
+
locale: 'all',
|
|
196
|
+
orderKey,
|
|
197
|
+
})
|
|
198
|
+
.catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
|
|
199
|
+
}
|
|
200
|
+
const newDocumentId = extractDocumentId(result.document);
|
|
201
|
+
const newDocumentVersionId = extractVersionId(result.document);
|
|
202
|
+
// 8. afterCreate hook with duplicate marker.
|
|
203
|
+
await invokeHook(hooks?.afterCreate, {
|
|
204
|
+
data: clonedFields,
|
|
205
|
+
collectionPath,
|
|
206
|
+
documentId: newDocumentId,
|
|
207
|
+
documentVersionId: newDocumentVersionId,
|
|
208
|
+
path: finalPath,
|
|
209
|
+
duplicate: duplicateMarker,
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
documentId: newDocumentId,
|
|
213
|
+
documentVersionId: newDocumentVersionId,
|
|
214
|
+
sourceDocumentId: params.sourceDocumentId,
|
|
215
|
+
newPath: finalPath,
|
|
216
|
+
pathRetried,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Document lifecycle service.
|
|
10
|
+
*
|
|
11
|
+
* Orchestrates CRUD operations and workflow transitions, invoking collection
|
|
12
|
+
* hooks at the appropriate points. Sits between the API route layer and the
|
|
13
|
+
* storage adapter (`IDbAdapter`) so that every operation path — POST, PUT,
|
|
14
|
+
* PATCH, status change, unpublish — goes through a single set of hooks.
|
|
15
|
+
*
|
|
16
|
+
* Hook invocations run **outside** the storage transaction. They are suitable
|
|
17
|
+
* for logging, cache invalidation, webhooks, and similar side-effects.
|
|
18
|
+
*
|
|
19
|
+
* This module depends only on `@byline/core` types and utilities — it has no
|
|
20
|
+
* dependency on any specific database adapter.
|
|
21
|
+
*
|
|
22
|
+
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
|
+
* re-exported here — the public surface is exactly this barrel).
|
|
24
|
+
*/
|
|
25
|
+
export { copyToLocale } from './copy-to-locale.js';
|
|
26
|
+
export { createDocument } from './create.js';
|
|
27
|
+
export { deleteDocument } from './delete.js';
|
|
28
|
+
export { deleteLocale } from './delete-locale.js';
|
|
29
|
+
export { duplicateDocument } from './duplicate.js';
|
|
30
|
+
export { restoreDocumentVersion } from './restore.js';
|
|
31
|
+
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
|
+
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
|
+
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
34
|
+
export type { DocumentLifecycleContext } from './context.js';
|
|
35
|
+
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
36
|
+
export type { CreateDocumentResult } from './create.js';
|
|
37
|
+
export type { DeleteDocumentResult } from './delete.js';
|
|
38
|
+
export type { DeleteLocaleResult } from './delete-locale.js';
|
|
39
|
+
export type { DuplicateDocumentResult } from './duplicate.js';
|
|
40
|
+
export type { RestoreVersionResult } from './restore.js';
|
|
41
|
+
export type { ChangeStatusResult, UnpublishResult } from './status.js';
|
|
42
|
+
export type { UpdateDocumentSystemFieldsResult } from './system-fields.js';
|
|
43
|
+
export type { UpdateDocumentResult, UpdateDocumentWithPatchesResult } from './update.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Document lifecycle service.
|
|
10
|
+
*
|
|
11
|
+
* Orchestrates CRUD operations and workflow transitions, invoking collection
|
|
12
|
+
* hooks at the appropriate points. Sits between the API route layer and the
|
|
13
|
+
* storage adapter (`IDbAdapter`) so that every operation path — POST, PUT,
|
|
14
|
+
* PATCH, status change, unpublish — goes through a single set of hooks.
|
|
15
|
+
*
|
|
16
|
+
* Hook invocations run **outside** the storage transaction. They are suitable
|
|
17
|
+
* for logging, cache invalidation, webhooks, and similar side-effects.
|
|
18
|
+
*
|
|
19
|
+
* This module depends only on `@byline/core` types and utilities — it has no
|
|
20
|
+
* dependency on any specific database adapter.
|
|
21
|
+
*
|
|
22
|
+
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
|
+
* re-exported here — the public surface is exactly this barrel).
|
|
24
|
+
*/
|
|
25
|
+
export { copyToLocale } from './copy-to-locale.js';
|
|
26
|
+
export { createDocument } from './create.js';
|
|
27
|
+
export { deleteDocument } from './delete.js';
|
|
28
|
+
export { deleteLocale } from './delete-locale.js';
|
|
29
|
+
export { duplicateDocument } from './duplicate.js';
|
|
30
|
+
export { restoreDocumentVersion } from './restore.js';
|
|
31
|
+
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
|
+
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
|
+
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Helpers shared by the per-operation lifecycle modules. Internal to the
|
|
10
|
+
* `document-lifecycle/` directory — nothing here is re-exported through
|
|
11
|
+
* the barrel (`index.ts`), so the package's public surface is unchanged
|
|
12
|
+
* by the per-operation split.
|
|
13
|
+
*/
|
|
14
|
+
import { type CollectionDefinition, type CollectionHookSlot } from '../../@types/index.js';
|
|
15
|
+
import type { BylineLogger } from '../../lib/logger.js';
|
|
16
|
+
import type { SlugifierFn } from '../../utils/slugify.js';
|
|
17
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
18
|
+
/**
|
|
19
|
+
* Safely invoke an optional hook slot, awaiting the result if it returns a
|
|
20
|
+
* Promise. When the slot is an array of functions they are executed
|
|
21
|
+
* sequentially in order.
|
|
22
|
+
*/
|
|
23
|
+
export declare function invokeHook<Ctx>(hook: CollectionHookSlot<Ctx> | undefined, ctx: Ctx): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Run the registered richtext embed adapter across every rich-text leaf
|
|
26
|
+
* in the outgoing document data. Mirror of the read-side
|
|
27
|
+
* `populateRichTextFields` — fires once per write, mutates `data` in
|
|
28
|
+
* place. Per-leaf errors are logged and swallowed by `embedRichTextFields`
|
|
29
|
+
* itself (branch C); document-level errors propagate.
|
|
30
|
+
*
|
|
31
|
+
* No-op when no embed adapter is registered. The bootstrap validator
|
|
32
|
+
* (step 7 of the link-refactor strategy) will eventually fail-fast for
|
|
33
|
+
* collections that declare `embedRelationsOnSave: true` without a
|
|
34
|
+
* registered adapter; until then a missing adapter is silent and writes
|
|
35
|
+
* proceed unmodified.
|
|
36
|
+
*/
|
|
37
|
+
export declare function applyRichTextEmbed(ctx: DocumentLifecycleContext, data: Record<string, any>): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* For collections with `orderable: true` on their schema definition, compute
|
|
40
|
+
* an append-at-end fractional-index key for a newly-inserted document.
|
|
41
|
+
* Returns `undefined` when the collection hasn't opted in (or has no
|
|
42
|
+
* definition registered, e.g. in unit-test environments), so the storage row
|
|
43
|
+
* gets `order_key = NULL` and the existing "no ordering" behavior holds.
|
|
44
|
+
*/
|
|
45
|
+
export declare function maybeAppendOrderKey(ctx: DocumentLifecycleContext, collectionPath: string): Promise<string | undefined>;
|
|
46
|
+
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
47
|
+
export declare function extractVersionId(document: any): string;
|
|
48
|
+
/** Extract the logical document id from the document object returned by `createDocumentVersion`. */
|
|
49
|
+
export declare function extractDocumentId(document: any): string;
|
|
50
|
+
/**
|
|
51
|
+
* Detect a Postgres unique-constraint violation on
|
|
52
|
+
* `byline_document_paths(collection_id, locale, path)` and translate it
|
|
53
|
+
* to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
|
|
54
|
+
*
|
|
55
|
+
* The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
|
|
56
|
+
* the constraint name on the error object (`constraint`); matching by
|
|
57
|
+
* name keeps this targeted to the path constraint and avoids spuriously
|
|
58
|
+
* rebranding unrelated unique violations as path conflicts.
|
|
59
|
+
*
|
|
60
|
+
* Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
|
|
61
|
+
* original attached as `cause`, so we walk a short cause chain to find
|
|
62
|
+
* the carried `code` / `constraint`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function rethrowPathConflict(err: unknown, path: string, locale: string): never;
|
|
65
|
+
/**
|
|
66
|
+
* Detect whether an error is the `ERR_PATH_CONFLICT` raised by
|
|
67
|
+
* `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
|
|
68
|
+
* keep the conflict-handling path separate from genuine errors.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isPathConflictError(err: unknown): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the path argument the storage primitive should receive on an
|
|
73
|
+
* update operation. Phase 1 only writes path rows under the default
|
|
74
|
+
* content locale; on translation saves a supplied path is dropped with
|
|
75
|
+
* a `logger.warn`, leaving the existing default-locale row untouched.
|
|
76
|
+
*
|
|
77
|
+
* Returns `undefined` to signal the storage primitive should skip the
|
|
78
|
+
* path write entirely (no upsert).
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolvePathForUpdate(args: {
|
|
81
|
+
explicitPath: string | null;
|
|
82
|
+
currentPath: string | undefined;
|
|
83
|
+
requestLocale: string;
|
|
84
|
+
sourceLocale: string;
|
|
85
|
+
documentId: string;
|
|
86
|
+
logger?: BylineLogger;
|
|
87
|
+
}): string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Derive the `path` value written into `byline_document_paths` at
|
|
90
|
+
* create time.
|
|
91
|
+
*
|
|
92
|
+
* 1. `definition.useAsPath` set → slugify the named source field's value
|
|
93
|
+
* in the default content locale.
|
|
94
|
+
* 2. Source field absent / empty → fall back to `crypto.randomUUID()`.
|
|
95
|
+
*
|
|
96
|
+
* Caller passes explicit overrides separately; this helper only handles
|
|
97
|
+
* the auto-derivation cascade.
|
|
98
|
+
*/
|
|
99
|
+
export declare function derivePath(definition: CollectionDefinition, data: Record<string, any>, defaultLocale: string, slugifier: SlugifierFn): string;
|
|
100
|
+
/**
|
|
101
|
+
* Strip the synthetic `_id` / `_type` meta keys from every block and
|
|
102
|
+
* array-item node in a reconstructed document tree.
|
|
103
|
+
*
|
|
104
|
+
* Reconstructed `locale: 'all'` trees carry stable `_id` values for
|
|
105
|
+
* blocks and array items (see CLAUDE.md → "Block/array items carry a
|
|
106
|
+
* stable `_id`"). For a *duplicate*, the new document is conceptually a
|
|
107
|
+
* fresh entity — its blocks should get fresh meta ids rather than
|
|
108
|
+
* inheriting the source's. Mutates the tree in place.
|
|
109
|
+
*
|
|
110
|
+
* Distinct from `restoreDocumentVersion`, which deliberately preserves
|
|
111
|
+
* `_id`s so block identity is stable across history.
|
|
112
|
+
*/
|
|
113
|
+
export declare function stripMetaIdsInPlace(value: unknown): void;
|