@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,218 @@
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 { normalizeCollectionHook, } from '../../@types/index.js';
15
+ import { getCollectionDefinition, getServerConfig } from '../../config/config.js';
16
+ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
+ import { generateKeyBetween } from '../../lib/fractional-index.js';
18
+ import { createReadContext } from '../populate.js';
19
+ import { embedRichTextFields } from '../richtext-embed.js';
20
+ /**
21
+ * Safely invoke an optional hook slot, awaiting the result if it returns a
22
+ * Promise. When the slot is an array of functions they are executed
23
+ * sequentially in order.
24
+ */
25
+ export async function invokeHook(hook, ctx) {
26
+ const fns = normalizeCollectionHook(hook);
27
+ for (const fn of fns) {
28
+ await fn(ctx);
29
+ }
30
+ }
31
+ /**
32
+ * Run the registered richtext embed adapter across every rich-text leaf
33
+ * in the outgoing document data. Mirror of the read-side
34
+ * `populateRichTextFields` — fires once per write, mutates `data` in
35
+ * place. Per-leaf errors are logged and swallowed by `embedRichTextFields`
36
+ * itself (branch C); document-level errors propagate.
37
+ *
38
+ * No-op when no embed adapter is registered. The bootstrap validator
39
+ * (step 7 of the link-refactor strategy) will eventually fail-fast for
40
+ * collections that declare `embedRelationsOnSave: true` without a
41
+ * registered adapter; until then a missing adapter is silent and writes
42
+ * proceed unmodified.
43
+ */
44
+ export async function applyRichTextEmbed(ctx, data) {
45
+ // Tolerate environments that drive the lifecycle without
46
+ // `initBylineCore()` (unit tests, isolated tooling) — they have no
47
+ // adapter to invoke, so this is a soft no-op.
48
+ let embed;
49
+ try {
50
+ embed = getServerConfig().fields?.richText?.embed;
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ if (embed == null)
56
+ return;
57
+ await embedRichTextFields({
58
+ fields: ctx.definition.fields,
59
+ collectionPath: ctx.collectionPath,
60
+ data,
61
+ embed,
62
+ readContext: createReadContext(),
63
+ logger: ctx.logger,
64
+ });
65
+ }
66
+ /**
67
+ * For collections with `orderable: true` on their schema definition, compute
68
+ * an append-at-end fractional-index key for a newly-inserted document.
69
+ * Returns `undefined` when the collection hasn't opted in (or has no
70
+ * definition registered, e.g. in unit-test environments), so the storage row
71
+ * gets `order_key = NULL` and the existing "no ordering" behavior holds.
72
+ */
73
+ export async function maybeAppendOrderKey(ctx, collectionPath) {
74
+ const definition = getCollectionDefinition(collectionPath);
75
+ if (definition?.orderable !== true)
76
+ return undefined;
77
+ const last = await ctx.db.queries.documents.getLastOrderKey({
78
+ collection_id: ctx.collectionId,
79
+ });
80
+ return generateKeyBetween(last, null);
81
+ }
82
+ /** Extract `id` from the document object returned by `createDocumentVersion`. */
83
+ export function extractVersionId(document) {
84
+ return document?.id ?? document?.document_version_id ?? '';
85
+ }
86
+ /** Extract the logical document id from the document object returned by `createDocumentVersion`. */
87
+ export function extractDocumentId(document) {
88
+ return document?.document_id ?? '';
89
+ }
90
+ /**
91
+ * Detect a Postgres unique-constraint violation on
92
+ * `byline_document_paths(collection_id, locale, path)` and translate it
93
+ * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
94
+ *
95
+ * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
96
+ * the constraint name on the error object (`constraint`); matching by
97
+ * name keeps this targeted to the path constraint and avoids spuriously
98
+ * rebranding unrelated unique violations as path conflicts.
99
+ *
100
+ * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
101
+ * original attached as `cause`, so we walk a short cause chain to find
102
+ * the carried `code` / `constraint`.
103
+ */
104
+ export function rethrowPathConflict(err, path, locale) {
105
+ let e = err;
106
+ // Walk at most a few `cause` hops — DrizzleQueryError → underlying pg error.
107
+ for (let i = 0; i < 3 && e; i++) {
108
+ if (e.code === '23505' &&
109
+ typeof e.constraint === 'string' &&
110
+ e.constraint.includes('document_paths_collection_locale_path')) {
111
+ throw ERR_PATH_CONFLICT({
112
+ message: `path "${path}" is already in use in this collection (locale: ${locale})`,
113
+ details: { path, locale, constraint: e.constraint },
114
+ });
115
+ }
116
+ e = e.cause;
117
+ }
118
+ throw err;
119
+ }
120
+ /**
121
+ * Detect whether an error is the `ERR_PATH_CONFLICT` raised by
122
+ * `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
123
+ * keep the conflict-handling path separate from genuine errors.
124
+ */
125
+ export function isPathConflictError(err) {
126
+ return (err != null &&
127
+ typeof err === 'object' &&
128
+ err.code === ErrorCodes.PATH_CONFLICT);
129
+ }
130
+ /**
131
+ * Resolve the path argument the storage primitive should receive on an
132
+ * update operation. Phase 1 only writes path rows under the default
133
+ * content locale; on translation saves a supplied path is dropped with
134
+ * a `logger.warn`, leaving the existing default-locale row untouched.
135
+ *
136
+ * Returns `undefined` to signal the storage primitive should skip the
137
+ * path write entirely (no upsert).
138
+ */
139
+ export function resolvePathForUpdate(args) {
140
+ const { explicitPath, currentPath, requestLocale, sourceLocale, documentId, logger } = args;
141
+ if (requestLocale === sourceLocale) {
142
+ // Source-locale write: pass path through when supplied; otherwise
143
+ // skip the write (existing path row stays as-is — sticky). The path row
144
+ // lives under the document's source_locale (its anchor), not the mutable
145
+ // global default — so this stays correct after the global default is
146
+ // switched. See docs/I18N.md.
147
+ return explicitPath ?? undefined;
148
+ }
149
+ // Non-source-locale (translation) write: reject any path change with a warn
150
+ // so the operation succeeds but the editor / API caller is informed.
151
+ if (explicitPath !== null && explicitPath !== currentPath) {
152
+ logger?.warn({
153
+ documentId,
154
+ requestedLocale: requestLocale,
155
+ sourceLocale,
156
+ suppliedPath: explicitPath,
157
+ currentPath,
158
+ }, 'path changes apply only on source-locale writes; ignored on translation save');
159
+ }
160
+ return undefined;
161
+ }
162
+ /**
163
+ * Derive the `path` value written into `byline_document_paths` at
164
+ * create time.
165
+ *
166
+ * 1. `definition.useAsPath` set → slugify the named source field's value
167
+ * in the default content locale.
168
+ * 2. Source field absent / empty → fall back to `crypto.randomUUID()`.
169
+ *
170
+ * Caller passes explicit overrides separately; this helper only handles
171
+ * the auto-derivation cascade.
172
+ */
173
+ export function derivePath(definition, data, defaultLocale, slugifier) {
174
+ if (definition.useAsPath != null) {
175
+ const sourceValue = data[definition.useAsPath];
176
+ if (sourceValue != null) {
177
+ const asString = sourceValue instanceof Date ? sourceValue.toISOString() : String(sourceValue);
178
+ if (asString.length > 0) {
179
+ const slug = slugifier(asString, {
180
+ locale: defaultLocale,
181
+ collectionPath: definition.path,
182
+ });
183
+ if (slug.length > 0)
184
+ return slug;
185
+ }
186
+ }
187
+ }
188
+ return crypto.randomUUID();
189
+ }
190
+ /**
191
+ * Strip the synthetic `_id` / `_type` meta keys from every block and
192
+ * array-item node in a reconstructed document tree.
193
+ *
194
+ * Reconstructed `locale: 'all'` trees carry stable `_id` values for
195
+ * blocks and array items (see CLAUDE.md → "Block/array items carry a
196
+ * stable `_id`"). For a *duplicate*, the new document is conceptually a
197
+ * fresh entity — its blocks should get fresh meta ids rather than
198
+ * inheriting the source's. Mutates the tree in place.
199
+ *
200
+ * Distinct from `restoreDocumentVersion`, which deliberately preserves
201
+ * `_id`s so block identity is stable across history.
202
+ */
203
+ export function stripMetaIdsInPlace(value) {
204
+ if (Array.isArray(value)) {
205
+ for (const item of value) {
206
+ stripMetaIdsInPlace(item);
207
+ }
208
+ return;
209
+ }
210
+ if (value != null && typeof value === 'object' && !(value instanceof Date)) {
211
+ const obj = value;
212
+ delete obj._id;
213
+ delete obj._type;
214
+ for (const key of Object.keys(obj)) {
215
+ stripMetaIdsInPlace(obj[key]);
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,48 @@
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
+ * Copy-to-Locale merge walker.
10
+ *
11
+ * Schema-aware, pure tree merge used by `copyToLocale` to decide, leaf by
12
+ * leaf, whether to take the source locale's value or keep the target's.
13
+ */
14
+ import { type FieldSet } from '../../@types/index.js';
15
+ /**
16
+ * Result of merging source-locale and target-locale data trees for
17
+ * `copyToLocale`. `data` is the payload to hand to
18
+ * `createDocumentVersion`; `fieldsUpdated` counts every localized leaf
19
+ * the merge rule chose to overwrite (used for UI toasts).
20
+ */
21
+ export interface CopyToLocaleMergeResult {
22
+ data: Record<string, any>;
23
+ fieldsUpdated: number;
24
+ }
25
+ /**
26
+ * Build the payload `copyToLocale` will write into the target locale.
27
+ *
28
+ * Walks `definition.fields` and the two reconstructed data trees in
29
+ * lockstep, applying the merge rule at every leaf:
30
+ *
31
+ * - **Localized leaf, `overwrite: true`** — take source's value (even
32
+ * when source is empty; overwriting means overwriting).
33
+ * - **Localized leaf, `overwrite: false`** — take source's value only
34
+ * when target is empty AND source is non-empty. Otherwise keep
35
+ * target's value. Empties under this rule are treated by
36
+ * `isEmptyLeafValue` — `null` / `undefined` / `''`.
37
+ * - **Non-localized leaf** — always keep target's value. Non-localized
38
+ * fields live on `locale: 'all'` rows in storage and would be wiped
39
+ * by the upcoming write if we did not pass them through verbatim.
40
+ *
41
+ * Structure (number of array items, blocks, etc.) follows the *target*
42
+ * tree — copy-to-locale never restructures the document; it only fills
43
+ * in localized leaves at positions the target already has.
44
+ *
45
+ * Pure: mutates nothing. The returned `data` is a fresh tree suitable
46
+ * to pass to `createDocumentVersion`.
47
+ */
48
+ export declare function mergeLocaleData(fields: FieldSet, sourceData: Record<string, any> | null | undefined, targetData: Record<string, any> | null | undefined, overwrite: boolean): CopyToLocaleMergeResult;
@@ -0,0 +1,147 @@
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
+ * Copy-to-Locale merge walker.
10
+ *
11
+ * Schema-aware, pure tree merge used by `copyToLocale` to decide, leaf by
12
+ * leaf, whether to take the source locale's value or keep the target's.
13
+ */
14
+ import { isArrayField, isBlocksField, isGroupField, } from '../../@types/index.js';
15
+ /**
16
+ * Treat null, undefined, and empty string as "no value" for the purpose
17
+ * of the `overwrite: false` merge rule. We intentionally do NOT treat
18
+ * `0`, `false`, or `[]` / `{}` as empty — they are meaningful values an
19
+ * editor may have set deliberately.
20
+ */
21
+ function isEmptyLeafValue(value) {
22
+ return value == null || value === '';
23
+ }
24
+ /**
25
+ * Build the payload `copyToLocale` will write into the target locale.
26
+ *
27
+ * Walks `definition.fields` and the two reconstructed data trees in
28
+ * lockstep, applying the merge rule at every leaf:
29
+ *
30
+ * - **Localized leaf, `overwrite: true`** — take source's value (even
31
+ * when source is empty; overwriting means overwriting).
32
+ * - **Localized leaf, `overwrite: false`** — take source's value only
33
+ * when target is empty AND source is non-empty. Otherwise keep
34
+ * target's value. Empties under this rule are treated by
35
+ * `isEmptyLeafValue` — `null` / `undefined` / `''`.
36
+ * - **Non-localized leaf** — always keep target's value. Non-localized
37
+ * fields live on `locale: 'all'` rows in storage and would be wiped
38
+ * by the upcoming write if we did not pass them through verbatim.
39
+ *
40
+ * Structure (number of array items, blocks, etc.) follows the *target*
41
+ * tree — copy-to-locale never restructures the document; it only fills
42
+ * in localized leaves at positions the target already has.
43
+ *
44
+ * Pure: mutates nothing. The returned `data` is a fresh tree suitable
45
+ * to pass to `createDocumentVersion`.
46
+ */
47
+ export function mergeLocaleData(fields, sourceData, targetData, overwrite) {
48
+ const source = (sourceData ?? {});
49
+ const target = (targetData ?? {});
50
+ const out = {};
51
+ let fieldsUpdated = 0;
52
+ for (const field of fields) {
53
+ const updated = mergeFieldValue(field, source[field.name], target[field.name], overwrite);
54
+ out[field.name] = updated.value;
55
+ fieldsUpdated += updated.fieldsUpdated;
56
+ }
57
+ return { data: out, fieldsUpdated };
58
+ }
59
+ function mergeFieldValue(field, sourceValue, targetValue, overwrite) {
60
+ if (isGroupField(field)) {
61
+ const childSource = isPlainObject(sourceValue) ? sourceValue : {};
62
+ const childTarget = isPlainObject(targetValue) ? targetValue : {};
63
+ const merged = mergeLocaleData(field.fields, childSource, childTarget, overwrite);
64
+ return { value: merged.data, fieldsUpdated: merged.fieldsUpdated };
65
+ }
66
+ if (isArrayField(field)) {
67
+ if (!Array.isArray(targetValue)) {
68
+ // Target has no array here — keep that. Source is not authoritative
69
+ // for structure under copy-to-locale.
70
+ return { value: targetValue, fieldsUpdated: 0 };
71
+ }
72
+ const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
73
+ const mergedItems = [];
74
+ let count = 0;
75
+ for (let i = 0; i < targetValue.length; i++) {
76
+ const tItem = targetValue[i];
77
+ const sItem = sourceItems[i];
78
+ if (!isPlainObject(tItem)) {
79
+ mergedItems.push(tItem);
80
+ continue;
81
+ }
82
+ const itemMerge = mergeLocaleData(field.fields, isPlainObject(sItem) ? sItem : {}, tItem, overwrite);
83
+ // Preserve `_id` / `_type` meta on the target item — same identity
84
+ // is carried forward across this update.
85
+ const merged = { ...itemMerge.data };
86
+ if (tItem._id !== undefined)
87
+ merged._id = tItem._id;
88
+ if (tItem._type !== undefined)
89
+ merged._type = tItem._type;
90
+ mergedItems.push(merged);
91
+ count += itemMerge.fieldsUpdated;
92
+ }
93
+ return { value: mergedItems, fieldsUpdated: count };
94
+ }
95
+ if (isBlocksField(field)) {
96
+ if (!Array.isArray(targetValue)) {
97
+ return { value: targetValue, fieldsUpdated: 0 };
98
+ }
99
+ const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
100
+ const mergedItems = [];
101
+ let count = 0;
102
+ for (let i = 0; i < targetValue.length; i++) {
103
+ const tItem = targetValue[i];
104
+ if (!isPlainObject(tItem)) {
105
+ mergedItems.push(tItem);
106
+ continue;
107
+ }
108
+ const blockType = tItem._type;
109
+ const block = field.blocks.find((b) => b.blockType === blockType);
110
+ if (block == null) {
111
+ // Unknown block — pass through unchanged.
112
+ mergedItems.push(tItem);
113
+ continue;
114
+ }
115
+ const sItem = sourceItems[i];
116
+ const itemMerge = mergeLocaleData(block.fields, isPlainObject(sItem) && sItem._type === blockType ? sItem : {}, tItem, overwrite);
117
+ const merged = { ...itemMerge.data };
118
+ if (tItem._id !== undefined)
119
+ merged._id = tItem._id;
120
+ merged._type = blockType;
121
+ mergedItems.push(merged);
122
+ count += itemMerge.fieldsUpdated;
123
+ }
124
+ return { value: mergedItems, fieldsUpdated: count };
125
+ }
126
+ // Leaf field.
127
+ const localized = field.localized === true;
128
+ if (!localized) {
129
+ // Non-localized leaves live on locale: 'all' rows. Pass the target's
130
+ // value through verbatim so the write does not wipe them.
131
+ return { value: targetValue, fieldsUpdated: 0 };
132
+ }
133
+ if (overwrite) {
134
+ return {
135
+ value: sourceValue,
136
+ fieldsUpdated: sourceValue === targetValue ? 0 : 1,
137
+ };
138
+ }
139
+ // overwrite: false — fill only when target is empty AND source has content.
140
+ if (isEmptyLeafValue(targetValue) && !isEmptyLeafValue(sourceValue)) {
141
+ return { value: sourceValue, fieldsUpdated: 1 };
142
+ }
143
+ return { value: targetValue, fieldsUpdated: 0 };
144
+ }
145
+ function isPlainObject(value) {
146
+ return value != null && typeof value === 'object' && !Array.isArray(value);
147
+ }
@@ -0,0 +1,57 @@
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 RestoreVersionResult {
10
+ documentId: string;
11
+ documentVersionId: string;
12
+ sourceVersionId: string;
13
+ }
14
+ /**
15
+ * Restore a historical document version as the new current version.
16
+ *
17
+ * Reads the source version with `locale: 'all'` so the entire multi-locale
18
+ * field tree (with `_id` / `_type` meta inlined onto blocks and array items
19
+ * by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
20
+ * through `createDocumentVersion` with `locale: 'all'`, which produces a
21
+ * fresh version row with a new UUIDv7 id and the latest `created_at`. The
22
+ * `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
23
+ * ORDER BY created_at DESC`) automatically promotes the new row to current.
24
+ *
25
+ * Status is hard-defaulted to the workflow's first status — restoring an
26
+ * old `published` version must never silently re-publish content. The user
27
+ * runs the restored draft through the normal workflow.
28
+ *
29
+ * `path` is sticky from the previous current version (not from the source),
30
+ * matching the semantics of `updateDocument`. A path change made between
31
+ * the source and now should not be undone by the restore.
32
+ *
33
+ * Auth reuses the `update` ability — restore is conceptually an edit
34
+ * against an existing document.
35
+ *
36
+ * Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
37
+ * field on the context. Userland hooks that need to react differently
38
+ * (e.g. tag the audit entry, skip search re-index) can branch on its
39
+ * presence.
40
+ *
41
+ * Flow:
42
+ * 1. Auth: `update` ability.
43
+ * 2. Read source version with `locale: 'all'`.
44
+ * 3. Validate source belongs to `documentId` (defence against forged
45
+ * cross-document version ids).
46
+ * 4. Read current version metadata; reject if the source IS already the
47
+ * current version (nothing to restore).
48
+ * 5. Read current document with reconstruction for hook `originalData`.
49
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
50
+ * 7. `db.commands.documents.createDocumentVersion(...)` with
51
+ * `action: 'restore'`, `locale: 'all'`, sticky path, default status.
52
+ * 8. `hooks.afterUpdate({ ..., restore })`
53
+ */
54
+ export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, params: {
55
+ documentId: string;
56
+ sourceVersionId: string;
57
+ }): Promise<RestoreVersionResult>;
@@ -0,0 +1,162 @@
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_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { getDefaultStatus } from '../../workflow/workflow.js';
13
+ import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
14
+ /**
15
+ * Restore a historical document version as the new current version.
16
+ *
17
+ * Reads the source version with `locale: 'all'` so the entire multi-locale
18
+ * field tree (with `_id` / `_type` meta inlined onto blocks and array items
19
+ * by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
20
+ * through `createDocumentVersion` with `locale: 'all'`, which produces a
21
+ * fresh version row with a new UUIDv7 id and the latest `created_at`. The
22
+ * `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
23
+ * ORDER BY created_at DESC`) automatically promotes the new row to current.
24
+ *
25
+ * Status is hard-defaulted to the workflow's first status — restoring an
26
+ * old `published` version must never silently re-publish content. The user
27
+ * runs the restored draft through the normal workflow.
28
+ *
29
+ * `path` is sticky from the previous current version (not from the source),
30
+ * matching the semantics of `updateDocument`. A path change made between
31
+ * the source and now should not be undone by the restore.
32
+ *
33
+ * Auth reuses the `update` ability — restore is conceptually an edit
34
+ * against an existing document.
35
+ *
36
+ * Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
37
+ * field on the context. Userland hooks that need to react differently
38
+ * (e.g. tag the audit entry, skip search re-index) can branch on its
39
+ * presence.
40
+ *
41
+ * Flow:
42
+ * 1. Auth: `update` ability.
43
+ * 2. Read source version with `locale: 'all'`.
44
+ * 3. Validate source belongs to `documentId` (defence against forged
45
+ * cross-document version ids).
46
+ * 4. Read current version metadata; reject if the source IS already the
47
+ * current version (nothing to restore).
48
+ * 5. Read current document with reconstruction for hook `originalData`.
49
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
50
+ * 7. `db.commands.documents.createDocumentVersion(...)` with
51
+ * `action: 'restore'`, `locale: 'all'`, sticky path, default status.
52
+ * 8. `hooks.afterUpdate({ ..., restore })`
53
+ */
54
+ export async function restoreDocumentVersion(ctx, params) {
55
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
56
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
57
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
58
+ const hooks = await resolveHooks(definition);
59
+ // 1. Read source version (full multi-locale tree).
60
+ const source = await db.queries.documents.getDocumentByVersion({
61
+ document_version_id: params.sourceVersionId,
62
+ locale: 'all',
63
+ });
64
+ if (source == null) {
65
+ throw ERR_NOT_FOUND({
66
+ message: 'source version not found',
67
+ details: { sourceVersionId: params.sourceVersionId },
68
+ }).log(ctx.logger);
69
+ }
70
+ // 2. Cross-document forgery check.
71
+ if (source.document_id !== params.documentId) {
72
+ throw ERR_VALIDATION({
73
+ message: 'source version does not belong to the target document',
74
+ details: {
75
+ documentId: params.documentId,
76
+ sourceVersionId: params.sourceVersionId,
77
+ sourceDocumentId: source.document_id,
78
+ },
79
+ }).log(ctx.logger);
80
+ }
81
+ // 3. Current version metadata — used both for the already-current
82
+ // guard and for the sticky path resolution.
83
+ const currentMeta = await db.queries.documents.getCurrentVersionMetadata({
84
+ collection_id: collectionId,
85
+ document_id: params.documentId,
86
+ });
87
+ if (currentMeta == null) {
88
+ throw ERR_NOT_FOUND({
89
+ message: 'document not found',
90
+ details: { documentId: params.documentId },
91
+ }).log(ctx.logger);
92
+ }
93
+ if (currentMeta.document_version_id === params.sourceVersionId) {
94
+ throw ERR_INVALID_TRANSITION({
95
+ message: 'source version is already the current version of this document',
96
+ details: {
97
+ documentId: params.documentId,
98
+ sourceVersionId: params.sourceVersionId,
99
+ },
100
+ }).log(ctx.logger);
101
+ }
102
+ // 4. originalData for hooks: full reconstruction of the current
103
+ // version (locale-scoped, matching updateDocument's semantics).
104
+ const latest = await db.queries.documents.getDocumentById({
105
+ collection_id: collectionId,
106
+ document_id: params.documentId,
107
+ locale: defaultLocale,
108
+ reconstruct: true,
109
+ });
110
+ const originalData = latest ?? {};
111
+ const sourceFields = source.fields ?? {};
112
+ const restoreContext = { sourceVersionId: params.sourceVersionId };
113
+ // 5. beforeUpdate.
114
+ await invokeHook(hooks?.beforeUpdate, {
115
+ data: sourceFields,
116
+ originalData,
117
+ collectionPath,
118
+ restore: restoreContext,
119
+ });
120
+ // Embed walker is a no-op here for localized richtext leaves
121
+ // (multi-locale `{ locale: lexJson }` shape — see
122
+ // richtext-embed.ts header). Non-localized richtext leaves still
123
+ // get refreshed, so leave the call in for that branch.
124
+ await applyRichTextEmbed(ctx, sourceFields);
125
+ // 6. Persist new version. locale: 'all' carries every locale row in
126
+ // the source tree forward in a single flatten pass — the
127
+ // cross-locale carry-forward branch in createDocumentVersion does
128
+ // not fire when locale === 'all'.
129
+ //
130
+ // No `path` is passed: restore does not change the document's path
131
+ // (the existing byline_document_paths row stays as-is — sticky).
132
+ const result = await db.commands.documents.createDocumentVersion({
133
+ documentId: params.documentId,
134
+ collectionId,
135
+ collectionVersion: ctx.collectionVersion,
136
+ collectionConfig: definition,
137
+ action: 'restore',
138
+ documentData: sourceFields,
139
+ status: getDefaultStatus(definition),
140
+ locale: 'all',
141
+ previousVersionId: currentMeta.document_version_id,
142
+ });
143
+ const documentId = extractDocumentId(result.document) || params.documentId;
144
+ const documentVersionId = extractVersionId(result.document);
145
+ // 7. afterUpdate. Restore is path-sticky: the canonical path comes
146
+ // from the current version's envelope (originalData), not the source.
147
+ await invokeHook(hooks?.afterUpdate, {
148
+ data: sourceFields,
149
+ originalData,
150
+ collectionPath,
151
+ documentId,
152
+ documentVersionId,
153
+ path: originalData.path ?? '',
154
+ restore: restoreContext,
155
+ });
156
+ return {
157
+ documentId,
158
+ documentVersionId,
159
+ sourceVersionId: params.sourceVersionId,
160
+ };
161
+ });
162
+ }
@@ -0,0 +1,41 @@
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 ChangeStatusResult {
10
+ previousStatus: string;
11
+ newStatus: string;
12
+ }
13
+ export interface UnpublishResult {
14
+ archivedCount: number;
15
+ }
16
+ /**
17
+ * Change a document's workflow status.
18
+ *
19
+ * Flow:
20
+ * 1. Fetch current document metadata
21
+ * 2. Validate transition via `validateStatusTransition()`
22
+ * 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
23
+ * 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
24
+ * 5. Auto-archive: if transitioning to `'published'`, archive other published versions
25
+ * 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
26
+ */
27
+ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, params: {
28
+ documentId: string;
29
+ nextStatus: string;
30
+ }): Promise<ChangeStatusResult>;
31
+ /**
32
+ * Unpublish a document by archiving its published version(s).
33
+ *
34
+ * Flow:
35
+ * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
36
+ * 2. `db.commands.documents.archivePublishedVersions(...)`
37
+ * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
38
+ */
39
+ export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
40
+ documentId: string;
41
+ }): Promise<UnpublishResult>;