@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,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>;
|