@byline/core 3.11.2 → 3.12.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.
|
@@ -1,1569 +0,0 @@
|
|
|
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 { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, resolveHooks, } from '../@types/index.js';
|
|
9
|
-
import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
|
|
10
|
-
import { getCollectionDefinition, getServerConfig } from '../config/config.js';
|
|
11
|
-
import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, ErrorCodes, } from '../lib/errors.js';
|
|
12
|
-
import { generateKeyBetween } from '../lib/fractional-index.js';
|
|
13
|
-
import { withLogContext } from '../lib/logger.js';
|
|
14
|
-
import { applyPatches } from '../patches/index.js';
|
|
15
|
-
import { normaliseDateFields } from '../utils/normalise-dates.js';
|
|
16
|
-
import { slugify } from '../utils/slugify.js';
|
|
17
|
-
import { getUploadFields } from '../utils/storage-utils.js';
|
|
18
|
-
import { getDefaultStatus, getWorkflow, validateStatusTransition } from '../workflow/workflow.js';
|
|
19
|
-
import { assignCounterValues } from './assign-counter-values.js';
|
|
20
|
-
import { createReadContext } from './populate.js';
|
|
21
|
-
import { embedRichTextFields } from './richtext-embed.js';
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Internal helpers
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
/**
|
|
26
|
-
* Safely invoke an optional hook slot, awaiting the result if it returns a
|
|
27
|
-
* Promise. When the slot is an array of functions they are executed
|
|
28
|
-
* sequentially in order.
|
|
29
|
-
*/
|
|
30
|
-
async function invokeHook(hook, ctx) {
|
|
31
|
-
const fns = normalizeCollectionHook(hook);
|
|
32
|
-
for (const fn of fns) {
|
|
33
|
-
await fn(ctx);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Run the registered richtext embed adapter across every rich-text leaf
|
|
38
|
-
* in the outgoing document data. Mirror of the read-side
|
|
39
|
-
* `populateRichTextFields` — fires once per write, mutates `data` in
|
|
40
|
-
* place. Per-leaf errors are logged and swallowed by `embedRichTextFields`
|
|
41
|
-
* itself (branch C); document-level errors propagate.
|
|
42
|
-
*
|
|
43
|
-
* No-op when no embed adapter is registered. The bootstrap validator
|
|
44
|
-
* (step 7 of the link-refactor strategy) will eventually fail-fast for
|
|
45
|
-
* collections that declare `embedRelationsOnSave: true` without a
|
|
46
|
-
* registered adapter; until then a missing adapter is silent and writes
|
|
47
|
-
* proceed unmodified.
|
|
48
|
-
*/
|
|
49
|
-
async function applyRichTextEmbed(ctx, data) {
|
|
50
|
-
// Tolerate environments that drive the lifecycle without
|
|
51
|
-
// `initBylineCore()` (unit tests, isolated tooling) — they have no
|
|
52
|
-
// adapter to invoke, so this is a soft no-op.
|
|
53
|
-
let embed;
|
|
54
|
-
try {
|
|
55
|
-
embed = getServerConfig().fields?.richText?.embed;
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (embed == null)
|
|
61
|
-
return;
|
|
62
|
-
await embedRichTextFields({
|
|
63
|
-
fields: ctx.definition.fields,
|
|
64
|
-
collectionPath: ctx.collectionPath,
|
|
65
|
-
data,
|
|
66
|
-
embed,
|
|
67
|
-
readContext: createReadContext(),
|
|
68
|
-
logger: ctx.logger,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* For collections with `orderable: true` on their schema definition, compute
|
|
73
|
-
* an append-at-end fractional-index key for a newly-inserted document.
|
|
74
|
-
* Returns `undefined` when the collection hasn't opted in (or has no
|
|
75
|
-
* definition registered, e.g. in unit-test environments), so the storage row
|
|
76
|
-
* gets `order_key = NULL` and the existing "no ordering" behavior holds.
|
|
77
|
-
*/
|
|
78
|
-
async function maybeAppendOrderKey(ctx, collectionPath) {
|
|
79
|
-
const definition = getCollectionDefinition(collectionPath);
|
|
80
|
-
if (definition?.orderable !== true)
|
|
81
|
-
return undefined;
|
|
82
|
-
const last = await ctx.db.queries.documents.getLastOrderKey({
|
|
83
|
-
collection_id: ctx.collectionId,
|
|
84
|
-
});
|
|
85
|
-
return generateKeyBetween(last, null);
|
|
86
|
-
}
|
|
87
|
-
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
88
|
-
function extractVersionId(document) {
|
|
89
|
-
return document?.id ?? document?.document_version_id ?? '';
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Detect a Postgres unique-constraint violation on
|
|
93
|
-
* `byline_document_paths(collection_id, locale, path)` and translate it
|
|
94
|
-
* to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
|
|
95
|
-
*
|
|
96
|
-
* The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
|
|
97
|
-
* the constraint name on the error object (`constraint`); matching by
|
|
98
|
-
* name keeps this targeted to the path constraint and avoids spuriously
|
|
99
|
-
* rebranding unrelated unique violations as path conflicts.
|
|
100
|
-
*
|
|
101
|
-
* Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
|
|
102
|
-
* original attached as `cause`, so we walk a short cause chain to find
|
|
103
|
-
* the carried `code` / `constraint`.
|
|
104
|
-
*/
|
|
105
|
-
function rethrowPathConflict(err, path, locale) {
|
|
106
|
-
let e = err;
|
|
107
|
-
// Walk at most a few `cause` hops — DrizzleQueryError → underlying pg error.
|
|
108
|
-
for (let i = 0; i < 3 && e; i++) {
|
|
109
|
-
if (e.code === '23505' &&
|
|
110
|
-
typeof e.constraint === 'string' &&
|
|
111
|
-
e.constraint.includes('document_paths_collection_locale_path')) {
|
|
112
|
-
throw ERR_PATH_CONFLICT({
|
|
113
|
-
message: `path "${path}" is already in use in this collection (locale: ${locale})`,
|
|
114
|
-
details: { path, locale, constraint: e.constraint },
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
e = e.cause;
|
|
118
|
-
}
|
|
119
|
-
throw err;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Resolve the path argument the storage primitive should receive on an
|
|
123
|
-
* update operation. Phase 1 only writes path rows under the default
|
|
124
|
-
* content locale; on translation saves a supplied path is dropped with
|
|
125
|
-
* a `logger.warn`, leaving the existing default-locale row untouched.
|
|
126
|
-
*
|
|
127
|
-
* Returns `undefined` to signal the storage primitive should skip the
|
|
128
|
-
* path write entirely (no upsert).
|
|
129
|
-
*/
|
|
130
|
-
function resolvePathForUpdate(args) {
|
|
131
|
-
const { explicitPath, currentPath, requestLocale, sourceLocale, documentId, logger } = args;
|
|
132
|
-
if (requestLocale === sourceLocale) {
|
|
133
|
-
// Source-locale write: pass path through when supplied; otherwise
|
|
134
|
-
// skip the write (existing path row stays as-is — sticky). The path row
|
|
135
|
-
// lives under the document's source_locale (its anchor), not the mutable
|
|
136
|
-
// global default — so this stays correct after the global default is
|
|
137
|
-
// switched. See docs/I18N.md.
|
|
138
|
-
return explicitPath ?? undefined;
|
|
139
|
-
}
|
|
140
|
-
// Non-source-locale (translation) write: reject any path change with a warn
|
|
141
|
-
// so the operation succeeds but the editor / API caller is informed.
|
|
142
|
-
if (explicitPath !== null && explicitPath !== currentPath) {
|
|
143
|
-
logger?.warn({
|
|
144
|
-
documentId,
|
|
145
|
-
requestedLocale: requestLocale,
|
|
146
|
-
sourceLocale,
|
|
147
|
-
suppliedPath: explicitPath,
|
|
148
|
-
currentPath,
|
|
149
|
-
}, 'path changes apply only on source-locale writes; ignored on translation save');
|
|
150
|
-
}
|
|
151
|
-
return undefined;
|
|
152
|
-
}
|
|
153
|
-
/** Extract the logical document id from the document object returned by `createDocumentVersion`. */
|
|
154
|
-
function extractDocumentId(document) {
|
|
155
|
-
return document?.document_id ?? '';
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Derive the `path` value written into `byline_document_paths` at
|
|
159
|
-
* create time.
|
|
160
|
-
*
|
|
161
|
-
* 1. `definition.useAsPath` set → slugify the named source field's value
|
|
162
|
-
* in the default content locale.
|
|
163
|
-
* 2. Source field absent / empty → fall back to `crypto.randomUUID()`.
|
|
164
|
-
*
|
|
165
|
-
* Caller passes explicit overrides separately; this helper only handles
|
|
166
|
-
* the auto-derivation cascade.
|
|
167
|
-
*/
|
|
168
|
-
function derivePath(definition, data, defaultLocale, slugifier) {
|
|
169
|
-
if (definition.useAsPath != null) {
|
|
170
|
-
const sourceValue = data[definition.useAsPath];
|
|
171
|
-
if (sourceValue != null) {
|
|
172
|
-
const asString = sourceValue instanceof Date ? sourceValue.toISOString() : String(sourceValue);
|
|
173
|
-
if (asString.length > 0) {
|
|
174
|
-
const slug = slugifier(asString, {
|
|
175
|
-
locale: defaultLocale,
|
|
176
|
-
collectionPath: definition.path,
|
|
177
|
-
});
|
|
178
|
-
if (slug.length > 0)
|
|
179
|
-
return slug;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return crypto.randomUUID();
|
|
184
|
-
}
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
// Lifecycle functions
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
/**
|
|
189
|
-
* Create a new document.
|
|
190
|
-
*
|
|
191
|
-
* Flow:
|
|
192
|
-
* 1. Default-locale enforcement: reject if `params.locale` is anything
|
|
193
|
-
* other than the configured default content locale (a brand-new
|
|
194
|
-
* document's canonical `path` lives in the default locale).
|
|
195
|
-
* 2. `normaliseDateFields(data)`
|
|
196
|
-
* 3. `hooks.beforeCreate({ data, collectionPath })`
|
|
197
|
-
* 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
|
|
198
|
-
* → UUID fallback.
|
|
199
|
-
* 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
|
|
200
|
-
* 6. `hooks.afterCreate({ data, collectionPath, documentId, documentVersionId })`
|
|
201
|
-
*/
|
|
202
|
-
export async function createDocument(ctx, params) {
|
|
203
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
|
|
204
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
205
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
|
|
206
|
-
const slugifier = ctx.slugifier ?? slugify;
|
|
207
|
-
const hooks = await resolveHooks(definition);
|
|
208
|
-
const data = params.data;
|
|
209
|
-
if (params.locale != null && params.locale !== defaultLocale) {
|
|
210
|
-
throw ERR_VALIDATION({
|
|
211
|
-
message: `documents must be created in the default content locale ('${defaultLocale}'); received '${params.locale}'. Create the default-locale version first, then add localised versions via update.`,
|
|
212
|
-
details: { defaultLocale, providedLocale: params.locale, collectionPath },
|
|
213
|
-
}).log(ctx.logger);
|
|
214
|
-
}
|
|
215
|
-
normaliseDateFields(data);
|
|
216
|
-
await invokeHook(hooks?.beforeCreate, { data, collectionPath });
|
|
217
|
-
// Allocate counter-field values after beforeCreate so user-land hooks
|
|
218
|
-
// can run their own logic on the raw payload, but before the flatten/
|
|
219
|
-
// insert pass so the assigned values are persisted on the same write.
|
|
220
|
-
// Caller-supplied counter values are overwritten — counters are
|
|
221
|
-
// allocator-assigned, never user-set.
|
|
222
|
-
await assignCounterValues({
|
|
223
|
-
fields: definition.fields,
|
|
224
|
-
data,
|
|
225
|
-
counters: db.commands.counters,
|
|
226
|
-
});
|
|
227
|
-
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
228
|
-
const resolvedPath = explicitPath ?? derivePath(definition, data, defaultLocale, slugifier);
|
|
229
|
-
// Append-at-end order_key for `orderable: true` collections.
|
|
230
|
-
// Computed before the insert so the single createDocumentVersion call
|
|
231
|
-
// carries the key into the byline_documents row. No effect when the
|
|
232
|
-
// admin config opts out or isn't registered.
|
|
233
|
-
const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
|
|
234
|
-
// Refresh embedded relation envelopes inside rich-text fields
|
|
235
|
-
// (internal-link / inline-image nodes) before flatten-and-persist.
|
|
236
|
-
await applyRichTextEmbed(ctx, data);
|
|
237
|
-
const result = await db.commands.documents
|
|
238
|
-
.createDocumentVersion({
|
|
239
|
-
collectionId,
|
|
240
|
-
collectionVersion: ctx.collectionVersion,
|
|
241
|
-
collectionConfig: definition,
|
|
242
|
-
action: 'create',
|
|
243
|
-
documentData: data,
|
|
244
|
-
path: resolvedPath,
|
|
245
|
-
availableLocales: params.availableLocales,
|
|
246
|
-
status: params.status ?? data.status ?? getDefaultStatus(definition),
|
|
247
|
-
locale: params.locale ?? defaultLocale,
|
|
248
|
-
orderKey,
|
|
249
|
-
})
|
|
250
|
-
.catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
|
|
251
|
-
const documentId = extractDocumentId(result.document);
|
|
252
|
-
const documentVersionId = extractVersionId(result.document);
|
|
253
|
-
await invokeHook(hooks?.afterCreate, {
|
|
254
|
-
data,
|
|
255
|
-
collectionPath,
|
|
256
|
-
documentId,
|
|
257
|
-
documentVersionId,
|
|
258
|
-
path: resolvedPath,
|
|
259
|
-
});
|
|
260
|
-
return { documentId, documentVersionId };
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Update a document via full replacement (PUT semantics).
|
|
265
|
-
*
|
|
266
|
-
* Unlike the previous implementation, this now fetches the current version
|
|
267
|
-
* from storage to provide a real `originalData` to hooks.
|
|
268
|
-
*
|
|
269
|
-
* Flow:
|
|
270
|
-
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
271
|
-
* 2. `normaliseDateFields(data)`
|
|
272
|
-
* 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
|
|
273
|
-
* 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
274
|
-
* 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
|
|
275
|
-
*/
|
|
276
|
-
export async function updateDocument(ctx, params) {
|
|
277
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
|
|
278
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
279
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
280
|
-
const hooks = await resolveHooks(definition);
|
|
281
|
-
const data = params.data;
|
|
282
|
-
// Fetch the real original so hooks get accurate originalData (fixes the
|
|
283
|
-
// PUT handler bug where originalData === data).
|
|
284
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
285
|
-
collection_id: collectionId,
|
|
286
|
-
document_id: params.documentId,
|
|
287
|
-
locale: params.locale ?? defaultLocale,
|
|
288
|
-
reconstruct: true,
|
|
289
|
-
});
|
|
290
|
-
const originalData = latest ?? {};
|
|
291
|
-
normaliseDateFields(data);
|
|
292
|
-
await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
|
|
293
|
-
// Counter fields are immutable: carry their values forward from the
|
|
294
|
-
// previous version rather than trusting whatever (or nothing) the
|
|
295
|
-
// caller sent. Lazy-allocates when a counter was added to the
|
|
296
|
-
// collection after this document was first created.
|
|
297
|
-
// originalData is the document envelope (with `.fields`, `.path`,
|
|
298
|
-
// `.document_version_id`); assignCounterValues expects field-shape.
|
|
299
|
-
await assignCounterValues({
|
|
300
|
-
fields: definition.fields,
|
|
301
|
-
data,
|
|
302
|
-
previousData: originalData.fields ?? originalData,
|
|
303
|
-
counters: db.commands.counters,
|
|
304
|
-
});
|
|
305
|
-
const defaultStatus = getDefaultStatus(definition);
|
|
306
|
-
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
307
|
-
const requestLocale = params.locale ?? defaultLocale;
|
|
308
|
-
// The document's own content-locale anchor governs which save writes the
|
|
309
|
-
// path row — not the mutable global default. Falls back to the global
|
|
310
|
-
// default for rows predating source_locale (not yet backfilled).
|
|
311
|
-
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
312
|
-
const pathForCommand = resolvePathForUpdate({
|
|
313
|
-
explicitPath,
|
|
314
|
-
currentPath: originalData.path,
|
|
315
|
-
requestLocale,
|
|
316
|
-
sourceLocale,
|
|
317
|
-
documentId: params.documentId,
|
|
318
|
-
logger: ctx.logger,
|
|
319
|
-
});
|
|
320
|
-
await applyRichTextEmbed(ctx, data);
|
|
321
|
-
const result = await db.commands.documents
|
|
322
|
-
.createDocumentVersion({
|
|
323
|
-
documentId: params.documentId,
|
|
324
|
-
collectionId,
|
|
325
|
-
collectionVersion: ctx.collectionVersion,
|
|
326
|
-
collectionConfig: definition,
|
|
327
|
-
action: 'update',
|
|
328
|
-
documentData: data,
|
|
329
|
-
path: pathForCommand,
|
|
330
|
-
availableLocales: params.availableLocales,
|
|
331
|
-
status: defaultStatus,
|
|
332
|
-
locale: requestLocale,
|
|
333
|
-
previousVersionId: originalData.document_version_id,
|
|
334
|
-
})
|
|
335
|
-
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
336
|
-
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
337
|
-
const documentVersionId = extractVersionId(result.document);
|
|
338
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
339
|
-
data,
|
|
340
|
-
originalData,
|
|
341
|
-
collectionPath,
|
|
342
|
-
documentId,
|
|
343
|
-
documentVersionId,
|
|
344
|
-
path: pathForCommand ?? originalData.path,
|
|
345
|
-
});
|
|
346
|
-
return { documentId, documentVersionId };
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Update a document via patch application.
|
|
351
|
-
*
|
|
352
|
-
* Flow:
|
|
353
|
-
* 1. Fetch current document via `getDocumentById({ reconstruct: true })`
|
|
354
|
-
* 2. Optimistic concurrency check on `documentVersionId`
|
|
355
|
-
* 3. `applyPatches(definition, originalData, patches)` → `nextData`
|
|
356
|
-
* 4. `normaliseDateFields(nextData)`
|
|
357
|
-
* 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
|
|
358
|
-
* 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
|
|
359
|
-
* 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
|
|
360
|
-
*
|
|
361
|
-
* @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
|
|
362
|
-
* @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
|
|
363
|
-
*/
|
|
364
|
-
export async function updateDocumentWithPatches(ctx, params) {
|
|
365
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
|
|
366
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
367
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
368
|
-
const hooks = await resolveHooks(definition);
|
|
369
|
-
// 1. Fetch current document.
|
|
370
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
371
|
-
collection_id: collectionId,
|
|
372
|
-
document_id: params.documentId,
|
|
373
|
-
locale: params.locale ?? defaultLocale,
|
|
374
|
-
reconstruct: true,
|
|
375
|
-
});
|
|
376
|
-
if (latest == null) {
|
|
377
|
-
throw ERR_NOT_FOUND({
|
|
378
|
-
message: 'document not found',
|
|
379
|
-
details: { documentId: params.documentId },
|
|
380
|
-
}).log(ctx.logger);
|
|
381
|
-
}
|
|
382
|
-
const originalData = latest;
|
|
383
|
-
// 2. Optimistic concurrency check.
|
|
384
|
-
if (params.documentVersionId &&
|
|
385
|
-
params.documentVersionId !== originalData.document_version_id) {
|
|
386
|
-
throw ERR_CONFLICT({
|
|
387
|
-
message: 'document has been modified since you loaded it',
|
|
388
|
-
details: {
|
|
389
|
-
currentVersionId: originalData.document_version_id,
|
|
390
|
-
yourVersionId: params.documentVersionId,
|
|
391
|
-
},
|
|
392
|
-
}).log(ctx.logger);
|
|
393
|
-
}
|
|
394
|
-
// 3. Apply patches (patches operate on flat field data, not the full envelope).
|
|
395
|
-
const { doc: patchedDocument, errors } = applyPatches(definition, originalData.fields ?? {}, params.patches);
|
|
396
|
-
if (errors.length > 0) {
|
|
397
|
-
throw ERR_PATCH_FAILED({
|
|
398
|
-
message: `failed to apply patches: ${errors.map((e) => e.message).join('; ')}`,
|
|
399
|
-
details: { errors },
|
|
400
|
-
}).log(ctx.logger);
|
|
401
|
-
}
|
|
402
|
-
const nextData = patchedDocument;
|
|
403
|
-
// 4. Normalise dates.
|
|
404
|
-
normaliseDateFields(nextData);
|
|
405
|
-
// 5. beforeUpdate hook.
|
|
406
|
-
await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
|
|
407
|
-
// 5b. Carry counter values forward from the previous version (or
|
|
408
|
-
// lazy-allocate if the previous version is missing a value). See
|
|
409
|
-
// updateDocument for the rationale — patch-based updates are
|
|
410
|
-
// subject to the same immutability contract.
|
|
411
|
-
await assignCounterValues({
|
|
412
|
-
fields: definition.fields,
|
|
413
|
-
data: nextData,
|
|
414
|
-
previousData: originalData.fields ?? {},
|
|
415
|
-
counters: db.commands.counters,
|
|
416
|
-
});
|
|
417
|
-
// 6. Persist.
|
|
418
|
-
const defaultStatus = getDefaultStatus(definition);
|
|
419
|
-
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
420
|
-
const requestLocale = params.locale ?? defaultLocale;
|
|
421
|
-
// The document's own content-locale anchor governs which save writes the
|
|
422
|
-
// path row — not the mutable global default. Falls back to the global
|
|
423
|
-
// default for rows predating source_locale (not yet backfilled).
|
|
424
|
-
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
425
|
-
const pathForCommand = resolvePathForUpdate({
|
|
426
|
-
explicitPath,
|
|
427
|
-
currentPath: originalData.path,
|
|
428
|
-
requestLocale,
|
|
429
|
-
sourceLocale,
|
|
430
|
-
documentId: params.documentId,
|
|
431
|
-
logger: ctx.logger,
|
|
432
|
-
});
|
|
433
|
-
await applyRichTextEmbed(ctx, nextData);
|
|
434
|
-
const result = await db.commands.documents
|
|
435
|
-
.createDocumentVersion({
|
|
436
|
-
documentId: params.documentId,
|
|
437
|
-
collectionId,
|
|
438
|
-
collectionVersion: ctx.collectionVersion,
|
|
439
|
-
collectionConfig: definition,
|
|
440
|
-
action: 'update',
|
|
441
|
-
documentData: nextData,
|
|
442
|
-
path: pathForCommand,
|
|
443
|
-
availableLocales: params.availableLocales,
|
|
444
|
-
status: defaultStatus,
|
|
445
|
-
locale: requestLocale,
|
|
446
|
-
previousVersionId: originalData.document_version_id,
|
|
447
|
-
})
|
|
448
|
-
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
449
|
-
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
450
|
-
const documentVersionId = extractVersionId(result.document);
|
|
451
|
-
// 7. afterUpdate hook.
|
|
452
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
453
|
-
data: nextData,
|
|
454
|
-
originalData,
|
|
455
|
-
collectionPath,
|
|
456
|
-
documentId,
|
|
457
|
-
documentVersionId,
|
|
458
|
-
path: pathForCommand ?? originalData.path,
|
|
459
|
-
});
|
|
460
|
-
return { documentId, documentVersionId };
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Write a document's system-managed, document-grain fields — `path` and the
|
|
465
|
-
* editorial `availableLocales` set — **without** minting a new version or
|
|
466
|
-
* touching workflow status.
|
|
467
|
-
*
|
|
468
|
-
* These fields are document-grain (they live in `byline_document_paths` and
|
|
469
|
-
* `byline_document_available_locales`, keyed by logical document, sticky across
|
|
470
|
-
* versions), so a workflow status change would falsely imply the edit is gated
|
|
471
|
-
* behind publish. It is not: the write is immediate and applies across every
|
|
472
|
-
* version. This service backs the admin path / available-locales widgets'
|
|
473
|
-
* direct-write Save (the `direct-write` and `both` dirty-reason cases). The
|
|
474
|
-
* public *advertised* set remains the intersection of `availableLocales` with
|
|
475
|
-
* the resolved version's completeness ledger. See docs/I18N.md.
|
|
476
|
-
*
|
|
477
|
-
* Flow:
|
|
478
|
-
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
479
|
-
* 2. Fetch the document to resolve its `source_locale` anchor + current path.
|
|
480
|
-
* 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
|
|
481
|
-
* rule (translation-locale path edits are dropped with a warn); a real
|
|
482
|
-
* change is written via `updateDocumentPath`, mapping the unique-constraint
|
|
483
|
-
* violation to `ERR_PATH_CONFLICT`.
|
|
484
|
-
* 4. `availableLocales` (when supplied): rewritten wholesale via
|
|
485
|
-
* `setDocumentAvailableLocales`.
|
|
486
|
-
*
|
|
487
|
-
* No content hooks fire — these are not content writes. Accountability for
|
|
488
|
-
* these mutations is the job of the (planned) document-grain audit log.
|
|
489
|
-
*
|
|
490
|
-
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
491
|
-
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
492
|
-
*/
|
|
493
|
-
export async function updateDocumentSystemFields(ctx, params) {
|
|
494
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
|
|
495
|
-
const { db, collectionId, collectionPath, defaultLocale } = ctx;
|
|
496
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
497
|
-
const requestLocale = params.locale ?? defaultLocale;
|
|
498
|
-
// Resolve the document's source-locale anchor + current path. Both feed
|
|
499
|
-
// the path source-locale guard below; the fetch also asserts existence.
|
|
500
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
501
|
-
collection_id: collectionId,
|
|
502
|
-
document_id: params.documentId,
|
|
503
|
-
locale: requestLocale,
|
|
504
|
-
reconstruct: true,
|
|
505
|
-
});
|
|
506
|
-
if (latest == null) {
|
|
507
|
-
throw ERR_NOT_FOUND({
|
|
508
|
-
message: 'document not found',
|
|
509
|
-
details: { documentId: params.documentId },
|
|
510
|
-
}).log(ctx.logger);
|
|
511
|
-
}
|
|
512
|
-
const originalData = latest;
|
|
513
|
-
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
514
|
-
// Path: honour the same source-locale-only rule the versioned write
|
|
515
|
-
// uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
|
|
516
|
-
// write" (null/empty override, or a translation-locale save).
|
|
517
|
-
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
518
|
-
const pathForCommand = resolvePathForUpdate({
|
|
519
|
-
explicitPath,
|
|
520
|
-
currentPath: originalData.path,
|
|
521
|
-
requestLocale,
|
|
522
|
-
sourceLocale,
|
|
523
|
-
documentId: params.documentId,
|
|
524
|
-
logger: ctx.logger,
|
|
525
|
-
});
|
|
526
|
-
if (pathForCommand !== undefined) {
|
|
527
|
-
await db.commands.documents
|
|
528
|
-
.updateDocumentPath({
|
|
529
|
-
documentId: params.documentId,
|
|
530
|
-
collectionId,
|
|
531
|
-
locale: sourceLocale,
|
|
532
|
-
path: pathForCommand,
|
|
533
|
-
})
|
|
534
|
-
.catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
|
|
535
|
-
}
|
|
536
|
-
// Advertised locales: rewrite the document-grain set wholesale.
|
|
537
|
-
const availableLocalesWritten = params.availableLocales !== undefined;
|
|
538
|
-
if (params.availableLocales !== undefined) {
|
|
539
|
-
await db.commands.documents.setDocumentAvailableLocales({
|
|
540
|
-
documentId: params.documentId,
|
|
541
|
-
collectionId,
|
|
542
|
-
availableLocales: params.availableLocales,
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
return {
|
|
546
|
-
documentId: params.documentId,
|
|
547
|
-
path: pathForCommand,
|
|
548
|
-
availableLocalesWritten,
|
|
549
|
-
};
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
/**
|
|
553
|
-
* Change a document's workflow status.
|
|
554
|
-
*
|
|
555
|
-
* Flow:
|
|
556
|
-
* 1. Fetch current document metadata
|
|
557
|
-
* 2. Validate transition via `validateStatusTransition()`
|
|
558
|
-
* 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
|
|
559
|
-
* 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
|
|
560
|
-
* 5. Auto-archive: if transitioning to `'published'`, archive other published versions
|
|
561
|
-
* 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
|
|
562
|
-
*/
|
|
563
|
-
export async function changeDocumentStatus(ctx, params) {
|
|
564
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'changeDocumentStatus' }, async () => {
|
|
565
|
-
const { db, definition, collectionId, collectionPath } = ctx;
|
|
566
|
-
// Every transition requires the general changeStatus ability.
|
|
567
|
-
// Transitions that target the `published` status additionally
|
|
568
|
-
// require the narrower `publish` ability — so installations can
|
|
569
|
-
// grant "move things through the workflow" without also granting
|
|
570
|
-
// "flip the final publish switch".
|
|
571
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
|
|
572
|
-
if (params.nextStatus === 'published') {
|
|
573
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'publish');
|
|
574
|
-
}
|
|
575
|
-
// Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups)
|
|
576
|
-
// have no transitions to perform. Reject early with a clear message
|
|
577
|
-
// rather than relying on the generic ±1-step validator.
|
|
578
|
-
const workflow = getWorkflow(definition);
|
|
579
|
-
if (workflow.statuses.length <= 1) {
|
|
580
|
-
throw ERR_INVALID_TRANSITION({
|
|
581
|
-
message: `collection '${collectionPath}' has a single-status workflow; status transitions are not supported`,
|
|
582
|
-
details: { collectionPath, nextStatus: params.nextStatus },
|
|
583
|
-
}).log(ctx.logger);
|
|
584
|
-
}
|
|
585
|
-
const hooks = await resolveHooks(definition);
|
|
586
|
-
// 1. Fetch current version metadata. No field reconstruction needed —
|
|
587
|
-
// status transitions only touch the document_versions.status column.
|
|
588
|
-
const latest = await db.queries.documents.getCurrentVersionMetadata({
|
|
589
|
-
collection_id: collectionId,
|
|
590
|
-
document_id: params.documentId,
|
|
591
|
-
});
|
|
592
|
-
if (latest == null) {
|
|
593
|
-
throw ERR_NOT_FOUND({
|
|
594
|
-
message: 'document not found',
|
|
595
|
-
details: { documentId: params.documentId },
|
|
596
|
-
}).log(ctx.logger);
|
|
597
|
-
}
|
|
598
|
-
const currentStatus = latest.status ?? 'draft';
|
|
599
|
-
const documentVersionId = latest.document_version_id;
|
|
600
|
-
// 2. Validate transition.
|
|
601
|
-
const result = validateStatusTransition(workflow, currentStatus, params.nextStatus);
|
|
602
|
-
if (!result.valid) {
|
|
603
|
-
throw ERR_INVALID_TRANSITION({
|
|
604
|
-
message: result.reason ??
|
|
605
|
-
`invalid status transition from '${currentStatus}' to '${params.nextStatus}'`,
|
|
606
|
-
details: { currentStatus, nextStatus: params.nextStatus },
|
|
607
|
-
}).log(ctx.logger);
|
|
608
|
-
}
|
|
609
|
-
// Resolve the document's canonical path so the hooks can act on the
|
|
610
|
-
// specific document/URL (CDN purge, cache-key drop). Narrow lookup —
|
|
611
|
-
// getCurrentVersionMetadata deliberately omits the path subquery.
|
|
612
|
-
const path = (await ctx.db.queries.documents.getCurrentPath({
|
|
613
|
-
collection_id: collectionId,
|
|
614
|
-
document_id: params.documentId,
|
|
615
|
-
})) ?? '';
|
|
616
|
-
const hookCtx = {
|
|
617
|
-
documentId: params.documentId,
|
|
618
|
-
documentVersionId,
|
|
619
|
-
collectionPath,
|
|
620
|
-
path,
|
|
621
|
-
previousStatus: currentStatus,
|
|
622
|
-
nextStatus: params.nextStatus,
|
|
623
|
-
};
|
|
624
|
-
// 3. beforeStatusChange hook.
|
|
625
|
-
await invokeHook(hooks?.beforeStatusChange, hookCtx);
|
|
626
|
-
// 4. Mutate status in-place.
|
|
627
|
-
await db.commands.documents.setDocumentStatus({
|
|
628
|
-
document_version_id: documentVersionId,
|
|
629
|
-
status: params.nextStatus,
|
|
630
|
-
});
|
|
631
|
-
// 5. Auto-archive previous published versions.
|
|
632
|
-
if (params.nextStatus === 'published') {
|
|
633
|
-
await db.commands.documents.archivePublishedVersions({
|
|
634
|
-
document_id: params.documentId,
|
|
635
|
-
excludeVersionId: documentVersionId,
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
// 6. afterStatusChange hook.
|
|
639
|
-
await invokeHook(hooks?.afterStatusChange, hookCtx);
|
|
640
|
-
return { previousStatus: currentStatus, newStatus: params.nextStatus };
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Unpublish a document by archiving its published version(s).
|
|
645
|
-
*
|
|
646
|
-
* Flow:
|
|
647
|
-
* 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
|
|
648
|
-
* 2. `db.commands.documents.archivePublishedVersions(...)`
|
|
649
|
-
* 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
|
|
650
|
-
*/
|
|
651
|
-
export async function unpublishDocument(ctx, params) {
|
|
652
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'unpublishDocument' }, async () => {
|
|
653
|
-
const { db, collectionId, collectionPath, definition } = ctx;
|
|
654
|
-
// Unpublish is a workflow transition out of `published` — reuse the
|
|
655
|
-
// changeStatus gate rather than a separate ability.
|
|
656
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
|
|
657
|
-
// Single-status workflows have nothing to unpublish to.
|
|
658
|
-
const workflow = getWorkflow(definition);
|
|
659
|
-
if (workflow.statuses.length <= 1) {
|
|
660
|
-
throw ERR_INVALID_TRANSITION({
|
|
661
|
-
message: `collection '${collectionPath}' has a single-status workflow; unpublish is not supported`,
|
|
662
|
-
details: { collectionPath },
|
|
663
|
-
}).log(ctx.logger);
|
|
664
|
-
}
|
|
665
|
-
const hooks = await resolveHooks(definition);
|
|
666
|
-
// Resolve the document's canonical path so the hooks can target the
|
|
667
|
-
// specific document/URL (CDN purge, cache-key drop).
|
|
668
|
-
const path = (await db.queries.documents.getCurrentPath({
|
|
669
|
-
collection_id: collectionId,
|
|
670
|
-
document_id: params.documentId,
|
|
671
|
-
})) ?? '';
|
|
672
|
-
await invokeHook(hooks?.beforeUnpublish, {
|
|
673
|
-
documentId: params.documentId,
|
|
674
|
-
collectionPath,
|
|
675
|
-
path,
|
|
676
|
-
});
|
|
677
|
-
const archivedCount = await db.commands.documents.archivePublishedVersions({
|
|
678
|
-
document_id: params.documentId,
|
|
679
|
-
});
|
|
680
|
-
await invokeHook(hooks?.afterUnpublish, {
|
|
681
|
-
documentId: params.documentId,
|
|
682
|
-
collectionPath,
|
|
683
|
-
path,
|
|
684
|
-
archivedCount,
|
|
685
|
-
});
|
|
686
|
-
return { archivedCount };
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
/**
|
|
690
|
-
* Restore a historical document version as the new current version.
|
|
691
|
-
*
|
|
692
|
-
* Reads the source version with `locale: 'all'` so the entire multi-locale
|
|
693
|
-
* field tree (with `_id` / `_type` meta inlined onto blocks and array items
|
|
694
|
-
* by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
|
|
695
|
-
* through `createDocumentVersion` with `locale: 'all'`, which produces a
|
|
696
|
-
* fresh version row with a new UUIDv7 id and the latest `created_at`. The
|
|
697
|
-
* `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
|
|
698
|
-
* ORDER BY created_at DESC`) automatically promotes the new row to current.
|
|
699
|
-
*
|
|
700
|
-
* Status is hard-defaulted to the workflow's first status — restoring an
|
|
701
|
-
* old `published` version must never silently re-publish content. The user
|
|
702
|
-
* runs the restored draft through the normal workflow.
|
|
703
|
-
*
|
|
704
|
-
* `path` is sticky from the previous current version (not from the source),
|
|
705
|
-
* matching the semantics of `updateDocument`. A path change made between
|
|
706
|
-
* the source and now should not be undone by the restore.
|
|
707
|
-
*
|
|
708
|
-
* Auth reuses the `update` ability — restore is conceptually an edit
|
|
709
|
-
* against an existing document.
|
|
710
|
-
*
|
|
711
|
-
* Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
|
|
712
|
-
* field on the context. Userland hooks that need to react differently
|
|
713
|
-
* (e.g. tag the audit entry, skip search re-index) can branch on its
|
|
714
|
-
* presence.
|
|
715
|
-
*
|
|
716
|
-
* Flow:
|
|
717
|
-
* 1. Auth: `update` ability.
|
|
718
|
-
* 2. Read source version with `locale: 'all'`.
|
|
719
|
-
* 3. Validate source belongs to `documentId` (defence against forged
|
|
720
|
-
* cross-document version ids).
|
|
721
|
-
* 4. Read current version metadata; reject if the source IS already the
|
|
722
|
-
* current version (nothing to restore).
|
|
723
|
-
* 5. Read current document with reconstruction for hook `originalData`.
|
|
724
|
-
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
|
|
725
|
-
* 7. `db.commands.documents.createDocumentVersion(...)` with
|
|
726
|
-
* `action: 'restore'`, `locale: 'all'`, sticky path, default status.
|
|
727
|
-
* 8. `hooks.afterUpdate({ ..., restore })`
|
|
728
|
-
*/
|
|
729
|
-
export async function restoreDocumentVersion(ctx, params) {
|
|
730
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
|
|
731
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
732
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
733
|
-
const hooks = await resolveHooks(definition);
|
|
734
|
-
// 1. Read source version (full multi-locale tree).
|
|
735
|
-
const source = await db.queries.documents.getDocumentByVersion({
|
|
736
|
-
document_version_id: params.sourceVersionId,
|
|
737
|
-
locale: 'all',
|
|
738
|
-
});
|
|
739
|
-
if (source == null) {
|
|
740
|
-
throw ERR_NOT_FOUND({
|
|
741
|
-
message: 'source version not found',
|
|
742
|
-
details: { sourceVersionId: params.sourceVersionId },
|
|
743
|
-
}).log(ctx.logger);
|
|
744
|
-
}
|
|
745
|
-
// 2. Cross-document forgery check.
|
|
746
|
-
if (source.document_id !== params.documentId) {
|
|
747
|
-
throw ERR_VALIDATION({
|
|
748
|
-
message: 'source version does not belong to the target document',
|
|
749
|
-
details: {
|
|
750
|
-
documentId: params.documentId,
|
|
751
|
-
sourceVersionId: params.sourceVersionId,
|
|
752
|
-
sourceDocumentId: source.document_id,
|
|
753
|
-
},
|
|
754
|
-
}).log(ctx.logger);
|
|
755
|
-
}
|
|
756
|
-
// 3. Current version metadata — used both for the already-current
|
|
757
|
-
// guard and for the sticky path resolution.
|
|
758
|
-
const currentMeta = await db.queries.documents.getCurrentVersionMetadata({
|
|
759
|
-
collection_id: collectionId,
|
|
760
|
-
document_id: params.documentId,
|
|
761
|
-
});
|
|
762
|
-
if (currentMeta == null) {
|
|
763
|
-
throw ERR_NOT_FOUND({
|
|
764
|
-
message: 'document not found',
|
|
765
|
-
details: { documentId: params.documentId },
|
|
766
|
-
}).log(ctx.logger);
|
|
767
|
-
}
|
|
768
|
-
if (currentMeta.document_version_id === params.sourceVersionId) {
|
|
769
|
-
throw ERR_INVALID_TRANSITION({
|
|
770
|
-
message: 'source version is already the current version of this document',
|
|
771
|
-
details: {
|
|
772
|
-
documentId: params.documentId,
|
|
773
|
-
sourceVersionId: params.sourceVersionId,
|
|
774
|
-
},
|
|
775
|
-
}).log(ctx.logger);
|
|
776
|
-
}
|
|
777
|
-
// 4. originalData for hooks: full reconstruction of the current
|
|
778
|
-
// version (locale-scoped, matching updateDocument's semantics).
|
|
779
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
780
|
-
collection_id: collectionId,
|
|
781
|
-
document_id: params.documentId,
|
|
782
|
-
locale: defaultLocale,
|
|
783
|
-
reconstruct: true,
|
|
784
|
-
});
|
|
785
|
-
const originalData = latest ?? {};
|
|
786
|
-
const sourceFields = source.fields ?? {};
|
|
787
|
-
const restoreContext = { sourceVersionId: params.sourceVersionId };
|
|
788
|
-
// 5. beforeUpdate.
|
|
789
|
-
await invokeHook(hooks?.beforeUpdate, {
|
|
790
|
-
data: sourceFields,
|
|
791
|
-
originalData,
|
|
792
|
-
collectionPath,
|
|
793
|
-
restore: restoreContext,
|
|
794
|
-
});
|
|
795
|
-
// Embed walker is a no-op here for localized richtext leaves
|
|
796
|
-
// (multi-locale `{ locale: lexJson }` shape — see
|
|
797
|
-
// richtext-embed.ts header). Non-localized richtext leaves still
|
|
798
|
-
// get refreshed, so leave the call in for that branch.
|
|
799
|
-
await applyRichTextEmbed(ctx, sourceFields);
|
|
800
|
-
// 6. Persist new version. locale: 'all' carries every locale row in
|
|
801
|
-
// the source tree forward in a single flatten pass — the
|
|
802
|
-
// cross-locale carry-forward branch in createDocumentVersion does
|
|
803
|
-
// not fire when locale === 'all'.
|
|
804
|
-
//
|
|
805
|
-
// No `path` is passed: restore does not change the document's path
|
|
806
|
-
// (the existing byline_document_paths row stays as-is — sticky).
|
|
807
|
-
const result = await db.commands.documents.createDocumentVersion({
|
|
808
|
-
documentId: params.documentId,
|
|
809
|
-
collectionId,
|
|
810
|
-
collectionVersion: ctx.collectionVersion,
|
|
811
|
-
collectionConfig: definition,
|
|
812
|
-
action: 'restore',
|
|
813
|
-
documentData: sourceFields,
|
|
814
|
-
status: getDefaultStatus(definition),
|
|
815
|
-
locale: 'all',
|
|
816
|
-
previousVersionId: currentMeta.document_version_id,
|
|
817
|
-
});
|
|
818
|
-
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
819
|
-
const documentVersionId = extractVersionId(result.document);
|
|
820
|
-
// 7. afterUpdate. Restore is path-sticky: the canonical path comes
|
|
821
|
-
// from the current version's envelope (originalData), not the source.
|
|
822
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
823
|
-
data: sourceFields,
|
|
824
|
-
originalData,
|
|
825
|
-
collectionPath,
|
|
826
|
-
documentId,
|
|
827
|
-
documentVersionId,
|
|
828
|
-
path: originalData.path ?? '',
|
|
829
|
-
restore: restoreContext,
|
|
830
|
-
});
|
|
831
|
-
return {
|
|
832
|
-
documentId,
|
|
833
|
-
documentVersionId,
|
|
834
|
-
sourceVersionId: params.sourceVersionId,
|
|
835
|
-
};
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* Soft-delete a document.
|
|
840
|
-
*
|
|
841
|
-
* Marks all versions of the document as deleted (`is_deleted = true`). The
|
|
842
|
-
* `current_documents` view automatically filters deleted rows, so the
|
|
843
|
-
* document disappears from all list / page queries without physically
|
|
844
|
-
* removing data.
|
|
845
|
-
*
|
|
846
|
-
* When the collection has any upload-capable image/file field and
|
|
847
|
-
* `ctx.storage` is provided, every original file and persisted variant
|
|
848
|
-
* across those fields is also removed from storage after the DB
|
|
849
|
-
* soft-delete succeeds. Variant paths are read from the field value's
|
|
850
|
-
* `variants` array (no re-derivation from `upload.sizes`), so cleanup
|
|
851
|
-
* stays correct even if the size set changed between upload and delete.
|
|
852
|
-
* File cleanup failures are logged but are non-fatal.
|
|
853
|
-
*
|
|
854
|
-
* Flow:
|
|
855
|
-
* 1. Fetch current document (reconstruct when upload-capable fields exist)
|
|
856
|
-
* 2. `hooks.beforeDelete({ documentId, collectionPath })`
|
|
857
|
-
* 3. `db.commands.documents.softDeleteDocument({ document_id })`
|
|
858
|
-
* 4. Storage file + variant cleanup (skipped when no upload fields, non-fatal)
|
|
859
|
-
* 5. `hooks.afterDelete({ documentId, collectionPath })`
|
|
860
|
-
*/
|
|
861
|
-
export async function deleteDocument(ctx, params) {
|
|
862
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteDocument' }, async () => {
|
|
863
|
-
const { db, collectionPath, definition, logger } = ctx;
|
|
864
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'delete');
|
|
865
|
-
const hooks = await resolveHooks(definition);
|
|
866
|
-
// 1. Verify the document exists.
|
|
867
|
-
// For collections that have any upload-capable image/file field
|
|
868
|
-
// AND a storage provider, fetch with reconstruct: true so we
|
|
869
|
-
// can read the stored file paths (and persisted variant paths)
|
|
870
|
-
// from the field values before the DB rows are deleted.
|
|
871
|
-
const uploadFieldNames = getUploadFields(definition).map((f) => f.name);
|
|
872
|
-
const isUploadCollection = uploadFieldNames.length > 0 && ctx.storage != null;
|
|
873
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
874
|
-
collection_id: ctx.collectionId,
|
|
875
|
-
document_id: params.documentId,
|
|
876
|
-
reconstruct: isUploadCollection,
|
|
877
|
-
});
|
|
878
|
-
if (latest == null) {
|
|
879
|
-
throw ERR_NOT_FOUND({
|
|
880
|
-
message: 'document not found',
|
|
881
|
-
details: { documentId: params.documentId },
|
|
882
|
-
}).log(ctx.logger);
|
|
883
|
-
}
|
|
884
|
-
// Collect storage paths for every upload-capable field on the doc:
|
|
885
|
-
// the original file plus every persisted variant. Reading the
|
|
886
|
-
// variants from the field value (rather than re-deriving from
|
|
887
|
-
// `upload.sizes`) keeps cleanup correct even when the size set
|
|
888
|
-
// changes between upload and delete.
|
|
889
|
-
const storagePathsToDelete = [];
|
|
890
|
-
if (isUploadCollection) {
|
|
891
|
-
for (const fieldName of uploadFieldNames) {
|
|
892
|
-
const fieldValue = latest?.fields?.[fieldName];
|
|
893
|
-
if (!fieldValue || typeof fieldValue !== 'object')
|
|
894
|
-
continue;
|
|
895
|
-
if (typeof fieldValue.storagePath === 'string') {
|
|
896
|
-
storagePathsToDelete.push(fieldValue.storagePath);
|
|
897
|
-
}
|
|
898
|
-
if (Array.isArray(fieldValue.variants)) {
|
|
899
|
-
for (const variant of fieldValue.variants) {
|
|
900
|
-
if (variant && typeof variant.storagePath === 'string') {
|
|
901
|
-
storagePathsToDelete.push(variant.storagePath);
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
const hookCtx = {
|
|
908
|
-
documentId: params.documentId,
|
|
909
|
-
collectionPath,
|
|
910
|
-
// The current document was fetched above (reconstructed only for
|
|
911
|
-
// upload collections, but the envelope carries the locale-resolved
|
|
912
|
-
// `path` projection either way). Surface it so delete hooks can purge
|
|
913
|
-
// the specific document/URL.
|
|
914
|
-
path: latest.path ?? '',
|
|
915
|
-
};
|
|
916
|
-
// 2. beforeDelete hook.
|
|
917
|
-
await invokeHook(hooks?.beforeDelete, hookCtx);
|
|
918
|
-
// 3. Soft-delete all versions.
|
|
919
|
-
const deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
920
|
-
document_id: params.documentId,
|
|
921
|
-
});
|
|
922
|
-
// 4. Clean up storage files. Non-fatal: logs errors but does not throw.
|
|
923
|
-
if (ctx.storage && storagePathsToDelete.length > 0) {
|
|
924
|
-
for (const storagePath of storagePathsToDelete) {
|
|
925
|
-
try {
|
|
926
|
-
await ctx.storage.delete(storagePath);
|
|
927
|
-
}
|
|
928
|
-
catch (err) {
|
|
929
|
-
logger.error({ err, storagePath }, 'failed to delete storage file');
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
// 5. afterDelete hook.
|
|
934
|
-
await invokeHook(hooks?.afterDelete, hookCtx);
|
|
935
|
-
return { deletedVersionCount };
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* Strip the synthetic `_id` / `_type` meta keys from every block and
|
|
940
|
-
* array-item node in a reconstructed document tree.
|
|
941
|
-
*
|
|
942
|
-
* Reconstructed `locale: 'all'` trees carry stable `_id` values for
|
|
943
|
-
* blocks and array items (see CLAUDE.md → "Block/array items carry a
|
|
944
|
-
* stable `_id`"). For a *duplicate*, the new document is conceptually a
|
|
945
|
-
* fresh entity — its blocks should get fresh meta ids rather than
|
|
946
|
-
* inheriting the source's. Mutates the tree in place.
|
|
947
|
-
*
|
|
948
|
-
* Distinct from `restoreDocumentVersion`, which deliberately preserves
|
|
949
|
-
* `_id`s so block identity is stable across history.
|
|
950
|
-
*/
|
|
951
|
-
function stripMetaIdsInPlace(value) {
|
|
952
|
-
if (Array.isArray(value)) {
|
|
953
|
-
for (const item of value) {
|
|
954
|
-
stripMetaIdsInPlace(item);
|
|
955
|
-
}
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
if (value != null && typeof value === 'object' && !(value instanceof Date)) {
|
|
959
|
-
const obj = value;
|
|
960
|
-
delete obj._id;
|
|
961
|
-
delete obj._type;
|
|
962
|
-
for (const key of Object.keys(obj)) {
|
|
963
|
-
stripMetaIdsInPlace(obj[key]);
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
969
|
-
* duplicate's data tree. Handles both shapes:
|
|
970
|
-
*
|
|
971
|
-
* - Localized title — `fields[useAsTitle]` is `{ en: '...', fr: '...' }`,
|
|
972
|
-
* suffix is applied to every locale's value.
|
|
973
|
-
* - Non-localized title — `fields[useAsTitle]` is a plain string;
|
|
974
|
-
* suffix appended once.
|
|
975
|
-
*
|
|
976
|
-
* No-op when the collection has no `useAsTitle` or the title is null /
|
|
977
|
-
* undefined; the duplicate proceeds with the source's title verbatim and
|
|
978
|
-
* the editor can rename it. Mutates the tree in place.
|
|
979
|
-
*/
|
|
980
|
-
function applyDuplicateTitleSuffix(definition, fields, suffix) {
|
|
981
|
-
const titleField = definition.useAsTitle;
|
|
982
|
-
if (titleField == null)
|
|
983
|
-
return;
|
|
984
|
-
const value = fields[titleField];
|
|
985
|
-
if (value == null)
|
|
986
|
-
return;
|
|
987
|
-
if (typeof value === 'string') {
|
|
988
|
-
fields[titleField] = value + suffix;
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
992
|
-
const localized = value;
|
|
993
|
-
for (const loc of Object.keys(localized)) {
|
|
994
|
-
const v = localized[loc];
|
|
995
|
-
if (typeof v === 'string') {
|
|
996
|
-
localized[loc] = v + suffix;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* Compute the candidate path for a duplicate.
|
|
1003
|
-
*
|
|
1004
|
-
* Reads the default-locale value of `definition.useAsPath` (peeling the
|
|
1005
|
-
* localized-shape wrapper if present) and runs it through the existing
|
|
1006
|
-
* `derivePath` helper. Falls back to `crypto.randomUUID()` when no source
|
|
1007
|
-
* value is available — matches `createDocument`'s behaviour for paths
|
|
1008
|
-
* that can't be slugged.
|
|
1009
|
-
*/
|
|
1010
|
-
function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifier) {
|
|
1011
|
-
const useAsPath = definition.useAsPath;
|
|
1012
|
-
if (useAsPath == null) {
|
|
1013
|
-
return crypto.randomUUID();
|
|
1014
|
-
}
|
|
1015
|
-
const raw = fields[useAsPath];
|
|
1016
|
-
// Peel the localized wrapper to find the default-locale value.
|
|
1017
|
-
let sourceValue = raw;
|
|
1018
|
-
if (raw != null && typeof raw === 'object' && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
1019
|
-
sourceValue = raw[defaultLocale];
|
|
1020
|
-
}
|
|
1021
|
-
return derivePath(definition, { [useAsPath]: sourceValue }, defaultLocale, slugifier);
|
|
1022
|
-
}
|
|
1023
|
-
/**
|
|
1024
|
-
* Duplicate a document, cloning all of its locales into a brand-new
|
|
1025
|
-
* document atomically.
|
|
1026
|
-
*
|
|
1027
|
-
* Flow:
|
|
1028
|
-
* 1. `assertActorCanPerform('create')` — duplicating is a create at the
|
|
1029
|
-
* ability level. The source must be readable (any RBAC scoping the
|
|
1030
|
-
* caller has applies via the storage read).
|
|
1031
|
-
* 2. Fetch the source with `locale: 'all'` so a single read carries the
|
|
1032
|
-
* full multi-locale tree forward.
|
|
1033
|
-
* 3. Deep-clone the source fields; strip block / array-item `_id` meta
|
|
1034
|
-
* so the new doc gets fresh identities.
|
|
1035
|
-
* 4. Append `" (copy)"` to the `useAsTitle` field's value(s).
|
|
1036
|
-
* 5. Derive a candidate path from the default-locale suffixed title.
|
|
1037
|
-
* 6. `hooks.beforeCreate({ data, collectionPath, duplicate })`.
|
|
1038
|
-
* 7. `db.commands.documents.createDocumentVersion(...)` with `locale:
|
|
1039
|
-
* 'all'`, `action: 'create'`, no `documentId` → fresh document_id.
|
|
1040
|
-
* On `ERR_PATH_CONFLICT` retry once with the candidate path plus a
|
|
1041
|
-
* 4-char UUID suffix; bounded to two attempts, no existence
|
|
1042
|
-
* pre-check, no TOCTOU race.
|
|
1043
|
-
* 8. `hooks.afterCreate({ data, collectionPath, documentId,
|
|
1044
|
-
* documentVersionId, duplicate })`.
|
|
1045
|
-
*
|
|
1046
|
-
* The write is atomic at the storage layer — a partial duplicate is
|
|
1047
|
-
* structurally impossible. Editors are expected to rename both the
|
|
1048
|
-
* title and the system path after the operation; the UI surfaces a
|
|
1049
|
-
* confirmation modal that calls this out.
|
|
1050
|
-
*/
|
|
1051
|
-
export async function duplicateDocument(ctx, params) {
|
|
1052
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
1053
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
1054
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
|
|
1055
|
-
const slugifier = ctx.slugifier ?? slugify;
|
|
1056
|
-
const hooks = await resolveHooks(definition);
|
|
1057
|
-
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
1058
|
-
const source = await db.queries.documents.getDocumentById({
|
|
1059
|
-
collection_id: collectionId,
|
|
1060
|
-
document_id: params.sourceDocumentId,
|
|
1061
|
-
locale: 'all',
|
|
1062
|
-
reconstruct: true,
|
|
1063
|
-
lenient: true,
|
|
1064
|
-
requestContext: ctx.requestContext,
|
|
1065
|
-
});
|
|
1066
|
-
if (source == null) {
|
|
1067
|
-
throw ERR_NOT_FOUND({
|
|
1068
|
-
message: 'source document not found',
|
|
1069
|
-
details: { sourceDocumentId: params.sourceDocumentId, collectionPath },
|
|
1070
|
-
}).log(ctx.logger);
|
|
1071
|
-
}
|
|
1072
|
-
const sourceRecord = source;
|
|
1073
|
-
const sourceFields = sourceRecord.fields ?? {};
|
|
1074
|
-
// 2. Deep clone — we'll mutate (suffix titles, strip meta ids).
|
|
1075
|
-
const clonedFields = structuredClone(sourceFields);
|
|
1076
|
-
// 3. Fresh block / array-item identities for the new doc.
|
|
1077
|
-
stripMetaIdsInPlace(clonedFields);
|
|
1078
|
-
// 4. Suffix titles per locale (or once if non-localized).
|
|
1079
|
-
const titleSuffix = ' (copy)';
|
|
1080
|
-
applyDuplicateTitleSuffix(definition, clonedFields, titleSuffix);
|
|
1081
|
-
// 5. Derive candidate path from the (now suffixed) default-locale title.
|
|
1082
|
-
const candidatePath = deriveDuplicateCandidatePath(definition, clonedFields, defaultLocale, slugifier);
|
|
1083
|
-
// 6. beforeCreate hook with duplicate marker.
|
|
1084
|
-
const duplicateMarker = { sourceDocumentId: params.sourceDocumentId };
|
|
1085
|
-
await invokeHook(hooks?.beforeCreate, {
|
|
1086
|
-
data: clonedFields,
|
|
1087
|
-
collectionPath,
|
|
1088
|
-
duplicate: duplicateMarker,
|
|
1089
|
-
});
|
|
1090
|
-
// 6b. Reset counter fields to freshly-allocated values. The clone
|
|
1091
|
-
// currently carries the source document's counter values; without
|
|
1092
|
-
// this pass, the duplicate would alias the source's facet IDs and
|
|
1093
|
-
// break the "one ID per term" contract.
|
|
1094
|
-
await assignCounterValues({
|
|
1095
|
-
fields: definition.fields,
|
|
1096
|
-
data: clonedFields,
|
|
1097
|
-
counters: db.commands.counters,
|
|
1098
|
-
});
|
|
1099
|
-
// 7. Atomic write. Try the candidate path; on ERR_PATH_CONFLICT
|
|
1100
|
-
// retry once with a 4-char UUID suffix.
|
|
1101
|
-
const defaultStatus = getDefaultStatus(definition);
|
|
1102
|
-
let finalPath = candidatePath;
|
|
1103
|
-
let pathRetried = false;
|
|
1104
|
-
let result;
|
|
1105
|
-
// Append-at-end order_key for `orderable: true` collections. Computed
|
|
1106
|
-
// before the insert; the source row's order is intentionally not
|
|
1107
|
-
// copied — duplicates land at the end of the list.
|
|
1108
|
-
const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
|
|
1109
|
-
// Embed walker (no-op for multi-locale richtext leaves — see
|
|
1110
|
-
// restoreDocumentVersion for the same caveat).
|
|
1111
|
-
await applyRichTextEmbed(ctx, clonedFields);
|
|
1112
|
-
try {
|
|
1113
|
-
result = await db.commands.documents
|
|
1114
|
-
.createDocumentVersion({
|
|
1115
|
-
collectionId,
|
|
1116
|
-
collectionVersion: ctx.collectionVersion,
|
|
1117
|
-
collectionConfig: definition,
|
|
1118
|
-
action: 'create',
|
|
1119
|
-
documentData: clonedFields,
|
|
1120
|
-
path: finalPath,
|
|
1121
|
-
status: defaultStatus,
|
|
1122
|
-
locale: 'all',
|
|
1123
|
-
orderKey,
|
|
1124
|
-
})
|
|
1125
|
-
.catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
|
|
1126
|
-
}
|
|
1127
|
-
catch (err) {
|
|
1128
|
-
if (!isPathConflictError(err)) {
|
|
1129
|
-
throw err;
|
|
1130
|
-
}
|
|
1131
|
-
// Single retry with a short UUID suffix. crypto.randomUUID() is
|
|
1132
|
-
// 36 chars; take the first 4 hex digits for a compact disambiguator.
|
|
1133
|
-
const shortDisambiguator = crypto.randomUUID().slice(0, 4);
|
|
1134
|
-
finalPath = `${candidatePath}-${shortDisambiguator}`;
|
|
1135
|
-
pathRetried = true;
|
|
1136
|
-
ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
|
|
1137
|
-
result = await db.commands.documents
|
|
1138
|
-
.createDocumentVersion({
|
|
1139
|
-
collectionId,
|
|
1140
|
-
collectionVersion: ctx.collectionVersion,
|
|
1141
|
-
collectionConfig: definition,
|
|
1142
|
-
action: 'create',
|
|
1143
|
-
documentData: clonedFields,
|
|
1144
|
-
path: finalPath,
|
|
1145
|
-
status: defaultStatus,
|
|
1146
|
-
locale: 'all',
|
|
1147
|
-
orderKey,
|
|
1148
|
-
})
|
|
1149
|
-
.catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
|
|
1150
|
-
}
|
|
1151
|
-
const newDocumentId = extractDocumentId(result.document);
|
|
1152
|
-
const newDocumentVersionId = extractVersionId(result.document);
|
|
1153
|
-
// 8. afterCreate hook with duplicate marker.
|
|
1154
|
-
await invokeHook(hooks?.afterCreate, {
|
|
1155
|
-
data: clonedFields,
|
|
1156
|
-
collectionPath,
|
|
1157
|
-
documentId: newDocumentId,
|
|
1158
|
-
documentVersionId: newDocumentVersionId,
|
|
1159
|
-
path: finalPath,
|
|
1160
|
-
duplicate: duplicateMarker,
|
|
1161
|
-
});
|
|
1162
|
-
return {
|
|
1163
|
-
documentId: newDocumentId,
|
|
1164
|
-
documentVersionId: newDocumentVersionId,
|
|
1165
|
-
sourceDocumentId: params.sourceDocumentId,
|
|
1166
|
-
newPath: finalPath,
|
|
1167
|
-
pathRetried,
|
|
1168
|
-
};
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
/**
|
|
1172
|
-
* Detect whether an error is the `ERR_PATH_CONFLICT` raised by
|
|
1173
|
-
* `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
|
|
1174
|
-
* keep the conflict-handling path separate from genuine errors.
|
|
1175
|
-
*/
|
|
1176
|
-
function isPathConflictError(err) {
|
|
1177
|
-
return (err != null &&
|
|
1178
|
-
typeof err === 'object' &&
|
|
1179
|
-
err.code === ErrorCodes.PATH_CONFLICT);
|
|
1180
|
-
}
|
|
1181
|
-
// ---------------------------------------------------------------------------
|
|
1182
|
-
// Copy-to-Locale merge walker
|
|
1183
|
-
// ---------------------------------------------------------------------------
|
|
1184
|
-
/**
|
|
1185
|
-
* Treat null, undefined, and empty string as "no value" for the purpose
|
|
1186
|
-
* of the `overwrite: false` merge rule. We intentionally do NOT treat
|
|
1187
|
-
* `0`, `false`, or `[]` / `{}` as empty — they are meaningful values an
|
|
1188
|
-
* editor may have set deliberately.
|
|
1189
|
-
*/
|
|
1190
|
-
function isEmptyLeafValue(value) {
|
|
1191
|
-
return value == null || value === '';
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* Build the payload `copyToLocale` will write into the target locale.
|
|
1195
|
-
*
|
|
1196
|
-
* Walks `definition.fields` and the two reconstructed data trees in
|
|
1197
|
-
* lockstep, applying the merge rule at every leaf:
|
|
1198
|
-
*
|
|
1199
|
-
* - **Localized leaf, `overwrite: true`** — take source's value (even
|
|
1200
|
-
* when source is empty; overwriting means overwriting).
|
|
1201
|
-
* - **Localized leaf, `overwrite: false`** — take source's value only
|
|
1202
|
-
* when target is empty AND source is non-empty. Otherwise keep
|
|
1203
|
-
* target's value. Empties under this rule are treated by
|
|
1204
|
-
* `isEmptyLeafValue` — `null` / `undefined` / `''`.
|
|
1205
|
-
* - **Non-localized leaf** — always keep target's value. Non-localized
|
|
1206
|
-
* fields live on `locale: 'all'` rows in storage and would be wiped
|
|
1207
|
-
* by the upcoming write if we did not pass them through verbatim.
|
|
1208
|
-
*
|
|
1209
|
-
* Structure (number of array items, blocks, etc.) follows the *target*
|
|
1210
|
-
* tree — copy-to-locale never restructures the document; it only fills
|
|
1211
|
-
* in localized leaves at positions the target already has.
|
|
1212
|
-
*
|
|
1213
|
-
* Pure: mutates nothing. The returned `data` is a fresh tree suitable
|
|
1214
|
-
* to pass to `createDocumentVersion`.
|
|
1215
|
-
*/
|
|
1216
|
-
function mergeLocaleData(fields, sourceData, targetData, overwrite) {
|
|
1217
|
-
const source = (sourceData ?? {});
|
|
1218
|
-
const target = (targetData ?? {});
|
|
1219
|
-
const out = {};
|
|
1220
|
-
let fieldsUpdated = 0;
|
|
1221
|
-
for (const field of fields) {
|
|
1222
|
-
const updated = mergeFieldValue(field, source[field.name], target[field.name], overwrite);
|
|
1223
|
-
out[field.name] = updated.value;
|
|
1224
|
-
fieldsUpdated += updated.fieldsUpdated;
|
|
1225
|
-
}
|
|
1226
|
-
return { data: out, fieldsUpdated };
|
|
1227
|
-
}
|
|
1228
|
-
function mergeFieldValue(field, sourceValue, targetValue, overwrite) {
|
|
1229
|
-
if (isGroupField(field)) {
|
|
1230
|
-
const childSource = isPlainObject(sourceValue) ? sourceValue : {};
|
|
1231
|
-
const childTarget = isPlainObject(targetValue) ? targetValue : {};
|
|
1232
|
-
const merged = mergeLocaleData(field.fields, childSource, childTarget, overwrite);
|
|
1233
|
-
return { value: merged.data, fieldsUpdated: merged.fieldsUpdated };
|
|
1234
|
-
}
|
|
1235
|
-
if (isArrayField(field)) {
|
|
1236
|
-
if (!Array.isArray(targetValue)) {
|
|
1237
|
-
// Target has no array here — keep that. Source is not authoritative
|
|
1238
|
-
// for structure under copy-to-locale.
|
|
1239
|
-
return { value: targetValue, fieldsUpdated: 0 };
|
|
1240
|
-
}
|
|
1241
|
-
const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
|
|
1242
|
-
const mergedItems = [];
|
|
1243
|
-
let count = 0;
|
|
1244
|
-
for (let i = 0; i < targetValue.length; i++) {
|
|
1245
|
-
const tItem = targetValue[i];
|
|
1246
|
-
const sItem = sourceItems[i];
|
|
1247
|
-
if (!isPlainObject(tItem)) {
|
|
1248
|
-
mergedItems.push(tItem);
|
|
1249
|
-
continue;
|
|
1250
|
-
}
|
|
1251
|
-
const itemMerge = mergeLocaleData(field.fields, isPlainObject(sItem) ? sItem : {}, tItem, overwrite);
|
|
1252
|
-
// Preserve `_id` / `_type` meta on the target item — same identity
|
|
1253
|
-
// is carried forward across this update.
|
|
1254
|
-
const merged = { ...itemMerge.data };
|
|
1255
|
-
if (tItem._id !== undefined)
|
|
1256
|
-
merged._id = tItem._id;
|
|
1257
|
-
if (tItem._type !== undefined)
|
|
1258
|
-
merged._type = tItem._type;
|
|
1259
|
-
mergedItems.push(merged);
|
|
1260
|
-
count += itemMerge.fieldsUpdated;
|
|
1261
|
-
}
|
|
1262
|
-
return { value: mergedItems, fieldsUpdated: count };
|
|
1263
|
-
}
|
|
1264
|
-
if (isBlocksField(field)) {
|
|
1265
|
-
if (!Array.isArray(targetValue)) {
|
|
1266
|
-
return { value: targetValue, fieldsUpdated: 0 };
|
|
1267
|
-
}
|
|
1268
|
-
const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
|
|
1269
|
-
const mergedItems = [];
|
|
1270
|
-
let count = 0;
|
|
1271
|
-
for (let i = 0; i < targetValue.length; i++) {
|
|
1272
|
-
const tItem = targetValue[i];
|
|
1273
|
-
if (!isPlainObject(tItem)) {
|
|
1274
|
-
mergedItems.push(tItem);
|
|
1275
|
-
continue;
|
|
1276
|
-
}
|
|
1277
|
-
const blockType = tItem._type;
|
|
1278
|
-
const block = field.blocks.find((b) => b.blockType === blockType);
|
|
1279
|
-
if (block == null) {
|
|
1280
|
-
// Unknown block — pass through unchanged.
|
|
1281
|
-
mergedItems.push(tItem);
|
|
1282
|
-
continue;
|
|
1283
|
-
}
|
|
1284
|
-
const sItem = sourceItems[i];
|
|
1285
|
-
const itemMerge = mergeLocaleData(block.fields, isPlainObject(sItem) && sItem._type === blockType ? sItem : {}, tItem, overwrite);
|
|
1286
|
-
const merged = { ...itemMerge.data };
|
|
1287
|
-
if (tItem._id !== undefined)
|
|
1288
|
-
merged._id = tItem._id;
|
|
1289
|
-
merged._type = blockType;
|
|
1290
|
-
mergedItems.push(merged);
|
|
1291
|
-
count += itemMerge.fieldsUpdated;
|
|
1292
|
-
}
|
|
1293
|
-
return { value: mergedItems, fieldsUpdated: count };
|
|
1294
|
-
}
|
|
1295
|
-
// Leaf field.
|
|
1296
|
-
const localized = field.localized === true;
|
|
1297
|
-
if (!localized) {
|
|
1298
|
-
// Non-localized leaves live on locale: 'all' rows. Pass the target's
|
|
1299
|
-
// value through verbatim so the write does not wipe them.
|
|
1300
|
-
return { value: targetValue, fieldsUpdated: 0 };
|
|
1301
|
-
}
|
|
1302
|
-
if (overwrite) {
|
|
1303
|
-
return {
|
|
1304
|
-
value: sourceValue,
|
|
1305
|
-
fieldsUpdated: sourceValue === targetValue ? 0 : 1,
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
// overwrite: false — fill only when target is empty AND source has content.
|
|
1309
|
-
if (isEmptyLeafValue(targetValue) && !isEmptyLeafValue(sourceValue)) {
|
|
1310
|
-
return { value: sourceValue, fieldsUpdated: 1 };
|
|
1311
|
-
}
|
|
1312
|
-
return { value: targetValue, fieldsUpdated: 0 };
|
|
1313
|
-
}
|
|
1314
|
-
function isPlainObject(value) {
|
|
1315
|
-
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
1316
|
-
}
|
|
1317
|
-
// ---------------------------------------------------------------------------
|
|
1318
|
-
// copyToLocale
|
|
1319
|
-
// ---------------------------------------------------------------------------
|
|
1320
|
-
/**
|
|
1321
|
-
* Copy a document's content from one locale into another, in place on
|
|
1322
|
-
* the same document.
|
|
1323
|
-
*
|
|
1324
|
-
* Reads the source and target locales separately (the storage layer
|
|
1325
|
-
* resolves localized fields to flat single-locale shapes when given a
|
|
1326
|
-
* specific `resolveLocale`). A schema-aware merge walker decides, leaf
|
|
1327
|
-
* by leaf, whether to take the source's value or keep the target's,
|
|
1328
|
-
* driven by the `overwrite` flag. The merged tree is written via
|
|
1329
|
-
* `createDocumentVersion({ action: 'copy_to_locale', locale: target })`
|
|
1330
|
-
* — the existing cross-locale carry-forward in the storage primitive
|
|
1331
|
-
* preserves every *other* locale's rows untouched.
|
|
1332
|
-
*
|
|
1333
|
-
* Non-localized fields are never altered by this operation: they live
|
|
1334
|
-
* on `locale: 'all'` rows and the merge walker passes the target's
|
|
1335
|
-
* value through so the write does not blank them.
|
|
1336
|
-
*
|
|
1337
|
-
* Path is sticky and lives on default-locale only; this operation never
|
|
1338
|
-
* touches `byline_document_paths`. Status resets to the workflow
|
|
1339
|
-
* default — translations land as drafts.
|
|
1340
|
-
*
|
|
1341
|
-
* Flow:
|
|
1342
|
-
* 1. `assertActorCanPerform('update')` — same gate as a translation save.
|
|
1343
|
-
* 2. Reject if `sourceLocale === targetLocale`.
|
|
1344
|
-
* 3. Fetch source via `getDocumentById({ locale: sourceLocale })`.
|
|
1345
|
-
* 4. Fetch target via `getDocumentById({ locale: targetLocale })`.
|
|
1346
|
-
* 5. `mergeLocaleData(definition.fields, source.fields, target.fields, overwrite)`.
|
|
1347
|
-
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, copyToLocale })`.
|
|
1348
|
-
* 7. `createDocumentVersion({ documentId, action: 'copy_to_locale',
|
|
1349
|
-
* locale: targetLocale, documentData, previousVersionId, status })`.
|
|
1350
|
-
* 8. `hooks.afterUpdate({ ..., copyToLocale })`.
|
|
1351
|
-
*/
|
|
1352
|
-
export async function copyToLocale(ctx, params) {
|
|
1353
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
|
|
1354
|
-
const { db, definition, collectionId, collectionPath } = ctx;
|
|
1355
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
1356
|
-
if (params.sourceLocale === params.targetLocale) {
|
|
1357
|
-
throw ERR_VALIDATION({
|
|
1358
|
-
message: 'sourceLocale and targetLocale must differ',
|
|
1359
|
-
details: {
|
|
1360
|
-
documentId: params.documentId,
|
|
1361
|
-
sourceLocale: params.sourceLocale,
|
|
1362
|
-
targetLocale: params.targetLocale,
|
|
1363
|
-
},
|
|
1364
|
-
}).log(ctx.logger);
|
|
1365
|
-
}
|
|
1366
|
-
// 1. Source read.
|
|
1367
|
-
const source = await db.queries.documents.getDocumentById({
|
|
1368
|
-
collection_id: collectionId,
|
|
1369
|
-
document_id: params.documentId,
|
|
1370
|
-
locale: params.sourceLocale,
|
|
1371
|
-
reconstruct: true,
|
|
1372
|
-
lenient: true,
|
|
1373
|
-
requestContext: ctx.requestContext,
|
|
1374
|
-
});
|
|
1375
|
-
if (source == null) {
|
|
1376
|
-
throw ERR_NOT_FOUND({
|
|
1377
|
-
message: 'document not found in source locale',
|
|
1378
|
-
details: {
|
|
1379
|
-
documentId: params.documentId,
|
|
1380
|
-
sourceLocale: params.sourceLocale,
|
|
1381
|
-
collectionPath,
|
|
1382
|
-
},
|
|
1383
|
-
}).log(ctx.logger);
|
|
1384
|
-
}
|
|
1385
|
-
// 2. Target read — needed for both originalData (hooks) and to
|
|
1386
|
-
// preserve non-localized values + structural shape.
|
|
1387
|
-
const target = await db.queries.documents.getDocumentById({
|
|
1388
|
-
collection_id: collectionId,
|
|
1389
|
-
document_id: params.documentId,
|
|
1390
|
-
locale: params.targetLocale,
|
|
1391
|
-
reconstruct: true,
|
|
1392
|
-
lenient: true,
|
|
1393
|
-
requestContext: ctx.requestContext,
|
|
1394
|
-
});
|
|
1395
|
-
if (target == null) {
|
|
1396
|
-
throw ERR_NOT_FOUND({
|
|
1397
|
-
message: 'document not found in target locale',
|
|
1398
|
-
details: {
|
|
1399
|
-
documentId: params.documentId,
|
|
1400
|
-
targetLocale: params.targetLocale,
|
|
1401
|
-
collectionPath,
|
|
1402
|
-
},
|
|
1403
|
-
}).log(ctx.logger);
|
|
1404
|
-
}
|
|
1405
|
-
const sourceRecord = source;
|
|
1406
|
-
const targetRecord = target;
|
|
1407
|
-
const sourceFields = sourceRecord.fields ?? {};
|
|
1408
|
-
const targetFields = targetRecord.fields ?? {};
|
|
1409
|
-
// 3. Merge.
|
|
1410
|
-
const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
|
|
1411
|
-
// 4. Hooks see the target-locale view as originalData (consistent
|
|
1412
|
-
// with how updateDocument scopes originalData to the active
|
|
1413
|
-
// locale) and the merged payload as the next `data`.
|
|
1414
|
-
const hooks = await resolveHooks(definition);
|
|
1415
|
-
const copyToLocaleMarker = {
|
|
1416
|
-
sourceLocale: params.sourceLocale,
|
|
1417
|
-
targetLocale: params.targetLocale,
|
|
1418
|
-
};
|
|
1419
|
-
await invokeHook(hooks?.beforeUpdate, {
|
|
1420
|
-
data: merged.data,
|
|
1421
|
-
originalData: targetFields,
|
|
1422
|
-
collectionPath,
|
|
1423
|
-
copyToLocale: copyToLocaleMarker,
|
|
1424
|
-
});
|
|
1425
|
-
// 5. Write. previousVersionId threads the current version id so the
|
|
1426
|
-
// storage primitive's cross-locale carry-forward fires for every
|
|
1427
|
-
// *other* locale (not source, not target — those rows are
|
|
1428
|
-
// rewritten by this call).
|
|
1429
|
-
const previousVersionId = targetRecord.document_version_id ?? undefined;
|
|
1430
|
-
await applyRichTextEmbed(ctx, merged.data);
|
|
1431
|
-
const writeResult = await db.commands.documents.createDocumentVersion({
|
|
1432
|
-
documentId: params.documentId,
|
|
1433
|
-
collectionId,
|
|
1434
|
-
collectionVersion: ctx.collectionVersion,
|
|
1435
|
-
collectionConfig: definition,
|
|
1436
|
-
action: 'copy_to_locale',
|
|
1437
|
-
documentData: merged.data,
|
|
1438
|
-
status: getDefaultStatus(definition),
|
|
1439
|
-
locale: params.targetLocale,
|
|
1440
|
-
previousVersionId,
|
|
1441
|
-
});
|
|
1442
|
-
const documentVersionId = extractVersionId(writeResult.document);
|
|
1443
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
1444
|
-
data: merged.data,
|
|
1445
|
-
originalData: targetFields,
|
|
1446
|
-
collectionPath,
|
|
1447
|
-
documentId: params.documentId,
|
|
1448
|
-
documentVersionId,
|
|
1449
|
-
// Path is sticky and source-locale-anchored; copy-to-locale never
|
|
1450
|
-
// touches it. Read it off the target envelope.
|
|
1451
|
-
path: targetRecord.path ?? '',
|
|
1452
|
-
copyToLocale: copyToLocaleMarker,
|
|
1453
|
-
});
|
|
1454
|
-
return {
|
|
1455
|
-
documentId: params.documentId,
|
|
1456
|
-
documentVersionId,
|
|
1457
|
-
sourceLocale: params.sourceLocale,
|
|
1458
|
-
targetLocale: params.targetLocale,
|
|
1459
|
-
fieldsUpdated: merged.fieldsUpdated,
|
|
1460
|
-
};
|
|
1461
|
-
});
|
|
1462
|
-
}
|
|
1463
|
-
// ---------------------------------------------------------------------------
|
|
1464
|
-
// deleteLocale
|
|
1465
|
-
// ---------------------------------------------------------------------------
|
|
1466
|
-
/**
|
|
1467
|
-
* Remove one content locale's data from a document, in place on the same
|
|
1468
|
-
* document, by writing a new immutable version that omits that locale's
|
|
1469
|
-
* store rows (every other locale and all non-localized `'all'` rows are
|
|
1470
|
-
* carried forward by the storage primitive).
|
|
1471
|
-
*
|
|
1472
|
-
* The default content locale is the document's anchor (path + source_locale)
|
|
1473
|
-
* and can never be removed — rejected up front. The new version lands as the
|
|
1474
|
-
* workflow's default status (a fresh draft), exactly like `copyToLocale`: the
|
|
1475
|
-
* previously-published version keeps serving — including the locale being
|
|
1476
|
-
* removed — until the new version is reviewed and published. The deletion is
|
|
1477
|
-
* recoverable: the prior version still holds the locale, so restoring it
|
|
1478
|
-
* brings the content back.
|
|
1479
|
-
*
|
|
1480
|
-
* Flow:
|
|
1481
|
-
* 1. `assertActorCanPerform('update')` — removing a translation is an edit.
|
|
1482
|
-
* 2. Reject `locale === defaultLocale`.
|
|
1483
|
-
* 3. Read the document in the target locale (validates existence; supplies
|
|
1484
|
-
* `originalData` for hooks and the availability set for the presence
|
|
1485
|
-
* check).
|
|
1486
|
-
* 4. Reject when the locale has no content to delete.
|
|
1487
|
-
* 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
|
|
1488
|
-
* 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
|
|
1489
|
-
* 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
|
|
1490
|
-
*/
|
|
1491
|
-
export async function deleteLocale(ctx, params) {
|
|
1492
|
-
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
|
|
1493
|
-
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
1494
|
-
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
1495
|
-
// The default locale anchors the document's path and source_locale —
|
|
1496
|
-
// it cannot be deleted (the other locales fall back to it).
|
|
1497
|
-
if (params.locale === defaultLocale) {
|
|
1498
|
-
throw ERR_VALIDATION({
|
|
1499
|
-
message: `cannot delete the default content locale ('${defaultLocale}')`,
|
|
1500
|
-
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
1501
|
-
}).log(ctx.logger);
|
|
1502
|
-
}
|
|
1503
|
-
// Read the document in the locale being removed — validates existence,
|
|
1504
|
-
// supplies originalData for hooks, and the availability set for the
|
|
1505
|
-
// content-presence check below.
|
|
1506
|
-
const target = await db.queries.documents.getDocumentById({
|
|
1507
|
-
collection_id: collectionId,
|
|
1508
|
-
document_id: params.documentId,
|
|
1509
|
-
locale: params.locale,
|
|
1510
|
-
reconstruct: true,
|
|
1511
|
-
lenient: true,
|
|
1512
|
-
requestContext: ctx.requestContext,
|
|
1513
|
-
});
|
|
1514
|
-
if (target == null) {
|
|
1515
|
-
throw ERR_NOT_FOUND({
|
|
1516
|
-
message: 'document not found',
|
|
1517
|
-
details: { documentId: params.documentId, collectionPath },
|
|
1518
|
-
}).log(ctx.logger);
|
|
1519
|
-
}
|
|
1520
|
-
const targetRecord = target;
|
|
1521
|
-
// `_availableVersionLocales` is the derived (path-coverage) set; it is
|
|
1522
|
-
// the same source the editor's Delete-Locale picker is built from, so a
|
|
1523
|
-
// locale offered in the UI resolves here. A partially-translated locale
|
|
1524
|
-
// that never reached full coverage is not deletable through this path.
|
|
1525
|
-
const available = targetRecord._availableVersionLocales ?? [];
|
|
1526
|
-
if (!available.includes(params.locale)) {
|
|
1527
|
-
throw ERR_NOT_FOUND({
|
|
1528
|
-
message: `locale '${params.locale}' has no content to delete`,
|
|
1529
|
-
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
1530
|
-
}).log(ctx.logger);
|
|
1531
|
-
}
|
|
1532
|
-
const hooks = await resolveHooks(definition);
|
|
1533
|
-
const deleteLocaleMarker = { locale: params.locale };
|
|
1534
|
-
const originalData = targetRecord.fields ?? {};
|
|
1535
|
-
await invokeHook(hooks?.beforeUpdate, {
|
|
1536
|
-
data: originalData,
|
|
1537
|
-
originalData,
|
|
1538
|
-
collectionPath,
|
|
1539
|
-
deleteLocale: deleteLocaleMarker,
|
|
1540
|
-
});
|
|
1541
|
-
const result = await db.commands.documents.deleteDocumentLocale({
|
|
1542
|
-
documentId: params.documentId,
|
|
1543
|
-
locale: params.locale,
|
|
1544
|
-
status: getDefaultStatus(definition),
|
|
1545
|
-
});
|
|
1546
|
-
if (result == null) {
|
|
1547
|
-
throw ERR_NOT_FOUND({
|
|
1548
|
-
message: 'document not found',
|
|
1549
|
-
details: { documentId: params.documentId, collectionPath },
|
|
1550
|
-
}).log(ctx.logger);
|
|
1551
|
-
}
|
|
1552
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
1553
|
-
data: originalData,
|
|
1554
|
-
originalData,
|
|
1555
|
-
collectionPath,
|
|
1556
|
-
documentId: params.documentId,
|
|
1557
|
-
documentVersionId: result.newVersionId,
|
|
1558
|
-
// Path is sticky and source-locale-anchored; deleting a translation
|
|
1559
|
-
// never touches it. Read it off the target envelope.
|
|
1560
|
-
path: targetRecord.path ?? '',
|
|
1561
|
-
deleteLocale: deleteLocaleMarker,
|
|
1562
|
-
});
|
|
1563
|
-
return {
|
|
1564
|
-
documentId: params.documentId,
|
|
1565
|
-
documentVersionId: result.newVersionId,
|
|
1566
|
-
locale: params.locale,
|
|
1567
|
-
};
|
|
1568
|
-
});
|
|
1569
|
-
}
|