@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.
Files changed (37) hide show
  1. package/dist/@types/field-types.d.ts +22 -0
  2. package/dist/@types/site-config.d.ts +8 -1
  3. package/dist/services/document-lifecycle/context.d.ts +79 -0
  4. package/dist/services/document-lifecycle/context.js +8 -0
  5. package/dist/services/document-lifecycle/copy-to-locale.d.ts +62 -0
  6. package/dist/services/document-lifecycle/copy-to-locale.js +157 -0
  7. package/dist/services/document-lifecycle/create.d.ts +45 -0
  8. package/dist/services/document-lifecycle/create.js +91 -0
  9. package/dist/services/document-lifecycle/delete-locale.d.ts +44 -0
  10. package/dist/services/document-lifecycle/delete-locale.js +117 -0
  11. package/dist/services/document-lifecycle/delete.d.ts +37 -0
  12. package/dist/services/document-lifecycle/delete.js +113 -0
  13. package/dist/services/document-lifecycle/duplicate.d.ts +60 -0
  14. package/dist/services/document-lifecycle/duplicate.js +219 -0
  15. package/dist/services/document-lifecycle/index.d.ts +43 -0
  16. package/dist/services/document-lifecycle/index.js +33 -0
  17. package/dist/services/document-lifecycle/internals.d.ts +113 -0
  18. package/dist/services/document-lifecycle/internals.js +218 -0
  19. package/dist/services/document-lifecycle/merge-locale-data.d.ts +48 -0
  20. package/dist/services/document-lifecycle/merge-locale-data.js +147 -0
  21. package/dist/services/document-lifecycle/restore.d.ts +57 -0
  22. package/dist/services/document-lifecycle/restore.js +162 -0
  23. package/dist/services/document-lifecycle/status.d.ts +41 -0
  24. package/dist/services/document-lifecycle/status.js +150 -0
  25. package/dist/services/document-lifecycle/system-fields.d.ts +62 -0
  26. package/dist/services/document-lifecycle/system-fields.js +100 -0
  27. package/dist/services/document-lifecycle/update.d.ts +84 -0
  28. package/dist/services/document-lifecycle/update.js +216 -0
  29. package/dist/services/document-lifecycle.test.node.js +1 -1
  30. package/dist/services/document-to-markdown.d.ts +79 -0
  31. package/dist/services/document-to-markdown.js +267 -0
  32. package/dist/services/document-to-markdown.test.node.d.ts +8 -0
  33. package/dist/services/document-to-markdown.test.node.js +161 -0
  34. package/dist/services/field-upload.js +1 -1
  35. package/dist/services/index.d.ts +2 -1
  36. package/dist/services/index.js +2 -1
  37. 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;