@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
- }