@byline/core 1.9.1 → 1.10.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.
|
@@ -302,33 +302,52 @@ export declare function defineWorkflow(input?: DefineWorkflowInput): WorkflowCon
|
|
|
302
302
|
* Context passed to `beforeCreate` hooks.
|
|
303
303
|
*
|
|
304
304
|
* The hook can mutate `data` before it is persisted.
|
|
305
|
+
*
|
|
306
|
+
* `duplicate` is set only when the create originates from a
|
|
307
|
+
* `duplicateDocument` call. Userland hooks that need to react differently
|
|
308
|
+
* (e.g. skip outbound webhooks, tag analytics) can branch on its presence.
|
|
309
|
+
* When set, `data` carries the multi-locale tree shape (localized fields
|
|
310
|
+
* appear as `{ locale: value }` objects) — mirroring the multi-locale
|
|
311
|
+
* `data` precedent set by `restoreDocumentVersion`'s `beforeUpdate` hook.
|
|
305
312
|
*/
|
|
306
313
|
export interface BeforeCreateContext {
|
|
307
314
|
data: Record<string, any>;
|
|
308
315
|
collectionPath: string;
|
|
316
|
+
duplicate?: {
|
|
317
|
+
sourceDocumentId: string;
|
|
318
|
+
};
|
|
309
319
|
}
|
|
310
320
|
/**
|
|
311
321
|
* Context passed to `afterCreate` hooks.
|
|
312
322
|
*
|
|
313
323
|
* Includes the `documentId` and `documentVersionId` returned by storage
|
|
314
324
|
* so the hook can reference the persisted document.
|
|
325
|
+
*
|
|
326
|
+
* `duplicate` mirrors `BeforeCreateContext.duplicate` — present only when
|
|
327
|
+
* the create was triggered by `duplicateDocument`.
|
|
315
328
|
*/
|
|
316
329
|
export interface AfterCreateContext {
|
|
317
330
|
data: Record<string, any>;
|
|
318
331
|
collectionPath: string;
|
|
319
332
|
documentId: string;
|
|
320
333
|
documentVersionId: string;
|
|
334
|
+
duplicate?: {
|
|
335
|
+
sourceDocumentId: string;
|
|
336
|
+
};
|
|
321
337
|
}
|
|
322
338
|
/**
|
|
323
|
-
* Context passed to `beforeUpdate` hooks — PUT, patch,
|
|
339
|
+
* Context passed to `beforeUpdate` hooks — PUT, patch, restore, and
|
|
340
|
+
* copy-to-locale flows.
|
|
324
341
|
*
|
|
325
342
|
* `data` is the next version (mutable). `originalData` is the previous
|
|
326
343
|
* version as reconstructed from storage.
|
|
327
344
|
*
|
|
328
345
|
* `restore` is set only when the update originates from a "make current"
|
|
329
|
-
* action against a historical version.
|
|
330
|
-
*
|
|
331
|
-
*
|
|
346
|
+
* action against a historical version. `copyToLocale` is set only when
|
|
347
|
+
* the update originates from a Copy-to-Locale operation. The two are
|
|
348
|
+
* mutually exclusive in practice. Userland hooks that need to react
|
|
349
|
+
* differently (e.g. tag the audit entry, skip search re-index, suppress
|
|
350
|
+
* translation webhooks during a bulk seed) can branch on their presence.
|
|
332
351
|
*/
|
|
333
352
|
export interface BeforeUpdateContext {
|
|
334
353
|
data: Record<string, any>;
|
|
@@ -337,6 +356,10 @@ export interface BeforeUpdateContext {
|
|
|
337
356
|
restore?: {
|
|
338
357
|
sourceVersionId: string;
|
|
339
358
|
};
|
|
359
|
+
copyToLocale?: {
|
|
360
|
+
sourceLocale: string;
|
|
361
|
+
targetLocale: string;
|
|
362
|
+
};
|
|
340
363
|
}
|
|
341
364
|
/**
|
|
342
365
|
* Context passed to `afterUpdate` hooks.
|
|
@@ -346,6 +369,8 @@ export interface BeforeUpdateContext {
|
|
|
346
369
|
*
|
|
347
370
|
* `restore` mirrors `BeforeUpdateContext.restore` — present only when the
|
|
348
371
|
* update was triggered by restoring a historical version.
|
|
372
|
+
* `copyToLocale` mirrors `BeforeUpdateContext.copyToLocale` — present only
|
|
373
|
+
* when the update was triggered by `copyToLocale`.
|
|
349
374
|
*/
|
|
350
375
|
export interface AfterUpdateContext {
|
|
351
376
|
data: Record<string, any>;
|
|
@@ -356,6 +381,10 @@ export interface AfterUpdateContext {
|
|
|
356
381
|
restore?: {
|
|
357
382
|
sourceVersionId: string;
|
|
358
383
|
};
|
|
384
|
+
copyToLocale?: {
|
|
385
|
+
sourceLocale: string;
|
|
386
|
+
targetLocale: string;
|
|
387
|
+
};
|
|
359
388
|
}
|
|
360
389
|
/**
|
|
361
390
|
* Context passed to `beforeStatusChange` / `afterStatusChange` hooks.
|
|
@@ -119,6 +119,43 @@ export interface RestoreVersionResult {
|
|
|
119
119
|
documentVersionId: string;
|
|
120
120
|
sourceVersionId: string;
|
|
121
121
|
}
|
|
122
|
+
export interface CopyToLocaleResult {
|
|
123
|
+
documentId: string;
|
|
124
|
+
documentVersionId: string;
|
|
125
|
+
/** Source locale read for the copy. */
|
|
126
|
+
sourceLocale: string;
|
|
127
|
+
/** Target locale into which the source's localized leaves were written. */
|
|
128
|
+
targetLocale: string;
|
|
129
|
+
/**
|
|
130
|
+
* Number of localized field values copied from source to target. Useful
|
|
131
|
+
* for UI toasts ("Copied 4 fields from EN to FR"). A zero result means
|
|
132
|
+
* the source had no localized content to copy into the target under the
|
|
133
|
+
* chosen merge rule (e.g. `overwrite: false` and target was already
|
|
134
|
+
* fully populated).
|
|
135
|
+
*/
|
|
136
|
+
fieldsUpdated: number;
|
|
137
|
+
}
|
|
138
|
+
export interface DuplicateDocumentResult {
|
|
139
|
+
/** The newly-created document's id. */
|
|
140
|
+
documentId: string;
|
|
141
|
+
/** The newly-created version id (every duplicate starts at version 1). */
|
|
142
|
+
documentVersionId: string;
|
|
143
|
+
/** The id of the document this duplicate was cloned from. */
|
|
144
|
+
sourceDocumentId: string;
|
|
145
|
+
/**
|
|
146
|
+
* Final `path` written into `byline_document_paths` for the new document.
|
|
147
|
+
* Surfaced in the result so the UI can include it in success toasts /
|
|
148
|
+
* navigate to it directly.
|
|
149
|
+
*/
|
|
150
|
+
newPath: string;
|
|
151
|
+
/**
|
|
152
|
+
* `true` when the candidate path collided with an existing row and the
|
|
153
|
+
* lifecycle retried with a short-UUID suffix. UIs can surface a hint that
|
|
154
|
+
* the auto-generated path is uglier than usual so the editor knows to
|
|
155
|
+
* adjust it via the path widget.
|
|
156
|
+
*/
|
|
157
|
+
pathRetried: boolean;
|
|
158
|
+
}
|
|
122
159
|
/**
|
|
123
160
|
* Create a new document.
|
|
124
161
|
*
|
|
@@ -293,3 +330,72 @@ export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, pa
|
|
|
293
330
|
export declare function deleteDocument(ctx: DocumentLifecycleContext, params: {
|
|
294
331
|
documentId: string;
|
|
295
332
|
}): Promise<DeleteDocumentResult>;
|
|
333
|
+
/**
|
|
334
|
+
* Duplicate a document, cloning all of its locales into a brand-new
|
|
335
|
+
* document atomically.
|
|
336
|
+
*
|
|
337
|
+
* Flow:
|
|
338
|
+
* 1. `assertActorCanPerform('create')` — duplicating is a create at the
|
|
339
|
+
* ability level. The source must be readable (any RBAC scoping the
|
|
340
|
+
* caller has applies via the storage read).
|
|
341
|
+
* 2. Fetch the source with `locale: 'all'` so a single read carries the
|
|
342
|
+
* full multi-locale tree forward.
|
|
343
|
+
* 3. Deep-clone the source fields; strip block / array-item `_id` meta
|
|
344
|
+
* so the new doc gets fresh identities.
|
|
345
|
+
* 4. Append `" (copy)"` to the `useAsTitle` field's value(s).
|
|
346
|
+
* 5. Derive a candidate path from the default-locale suffixed title.
|
|
347
|
+
* 6. `hooks.beforeCreate({ data, collectionPath, duplicate })`.
|
|
348
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with `locale:
|
|
349
|
+
* 'all'`, `action: 'create'`, no `documentId` → fresh document_id.
|
|
350
|
+
* On `ERR_PATH_CONFLICT` retry once with the candidate path plus a
|
|
351
|
+
* 4-char UUID suffix; bounded to two attempts, no existence
|
|
352
|
+
* pre-check, no TOCTOU race.
|
|
353
|
+
* 8. `hooks.afterCreate({ data, collectionPath, documentId,
|
|
354
|
+
* documentVersionId, duplicate })`.
|
|
355
|
+
*
|
|
356
|
+
* The write is atomic at the storage layer — a partial duplicate is
|
|
357
|
+
* structurally impossible. Editors are expected to rename both the
|
|
358
|
+
* title and the system path after the operation; the UI surfaces a
|
|
359
|
+
* confirmation modal that calls this out.
|
|
360
|
+
*/
|
|
361
|
+
export declare function duplicateDocument(ctx: DocumentLifecycleContext, params: {
|
|
362
|
+
sourceDocumentId: string;
|
|
363
|
+
}): Promise<DuplicateDocumentResult>;
|
|
364
|
+
/**
|
|
365
|
+
* Copy a document's content from one locale into another, in place on
|
|
366
|
+
* the same document.
|
|
367
|
+
*
|
|
368
|
+
* Reads the source and target locales separately (the storage layer
|
|
369
|
+
* resolves localized fields to flat single-locale shapes when given a
|
|
370
|
+
* specific `resolveLocale`). A schema-aware merge walker decides, leaf
|
|
371
|
+
* by leaf, whether to take the source's value or keep the target's,
|
|
372
|
+
* driven by the `overwrite` flag. The merged tree is written via
|
|
373
|
+
* `createDocumentVersion({ action: 'copy_to_locale', locale: target })`
|
|
374
|
+
* — the existing cross-locale carry-forward in the storage primitive
|
|
375
|
+
* preserves every *other* locale's rows untouched.
|
|
376
|
+
*
|
|
377
|
+
* Non-localized fields are never altered by this operation: they live
|
|
378
|
+
* on `locale: 'all'` rows and the merge walker passes the target's
|
|
379
|
+
* value through so the write does not blank them.
|
|
380
|
+
*
|
|
381
|
+
* Path is sticky and lives on default-locale only; this operation never
|
|
382
|
+
* touches `byline_document_paths`. Status resets to the workflow
|
|
383
|
+
* default — translations land as drafts.
|
|
384
|
+
*
|
|
385
|
+
* Flow:
|
|
386
|
+
* 1. `assertActorCanPerform('update')` — same gate as a translation save.
|
|
387
|
+
* 2. Reject if `sourceLocale === targetLocale`.
|
|
388
|
+
* 3. Fetch source via `getDocumentById({ locale: sourceLocale })`.
|
|
389
|
+
* 4. Fetch target via `getDocumentById({ locale: targetLocale })`.
|
|
390
|
+
* 5. `mergeLocaleData(definition.fields, source.fields, target.fields, overwrite)`.
|
|
391
|
+
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, copyToLocale })`.
|
|
392
|
+
* 7. `createDocumentVersion({ documentId, action: 'copy_to_locale',
|
|
393
|
+
* locale: targetLocale, documentData, previousVersionId, status })`.
|
|
394
|
+
* 8. `hooks.afterUpdate({ ..., copyToLocale })`.
|
|
395
|
+
*/
|
|
396
|
+
export declare function copyToLocale(ctx: DocumentLifecycleContext, params: {
|
|
397
|
+
documentId: string;
|
|
398
|
+
sourceLocale: string;
|
|
399
|
+
targetLocale: string;
|
|
400
|
+
overwrite: boolean;
|
|
401
|
+
}): Promise<CopyToLocaleResult>;
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { normalizeCollectionHook, } from '../@types/index.js';
|
|
8
|
+
import { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, } from '../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
|
|
10
|
-
import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, } from '../lib/errors.js';
|
|
10
|
+
import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, ErrorCodes, } from '../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../lib/logger.js';
|
|
12
12
|
import { applyPatches } from '../patches/index.js';
|
|
13
13
|
import { normaliseDateFields } from '../utils/normalise-dates.js';
|
|
@@ -702,3 +702,505 @@ export async function deleteDocument(ctx, params) {
|
|
|
702
702
|
return { deletedVersionCount };
|
|
703
703
|
});
|
|
704
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* Strip the synthetic `_id` / `_type` meta keys from every block and
|
|
707
|
+
* array-item node in a reconstructed document tree.
|
|
708
|
+
*
|
|
709
|
+
* Reconstructed `locale: 'all'` trees carry stable `_id` values for
|
|
710
|
+
* blocks and array items (see CLAUDE.md → "Block/array items carry a
|
|
711
|
+
* stable `_id`"). For a *duplicate*, the new document is conceptually a
|
|
712
|
+
* fresh entity — its blocks should get fresh meta ids rather than
|
|
713
|
+
* inheriting the source's. Mutates the tree in place.
|
|
714
|
+
*
|
|
715
|
+
* Distinct from `restoreDocumentVersion`, which deliberately preserves
|
|
716
|
+
* `_id`s so block identity is stable across history.
|
|
717
|
+
*/
|
|
718
|
+
function stripMetaIdsInPlace(value) {
|
|
719
|
+
if (Array.isArray(value)) {
|
|
720
|
+
for (const item of value) {
|
|
721
|
+
stripMetaIdsInPlace(item);
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (value != null && typeof value === 'object' && !(value instanceof Date)) {
|
|
726
|
+
const obj = value;
|
|
727
|
+
delete obj._id;
|
|
728
|
+
delete obj._type;
|
|
729
|
+
for (const key of Object.keys(obj)) {
|
|
730
|
+
stripMetaIdsInPlace(obj[key]);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
736
|
+
* duplicate's data tree. Handles both shapes:
|
|
737
|
+
*
|
|
738
|
+
* - Localized title — `fields[useAsTitle]` is `{ en: '...', fr: '...' }`,
|
|
739
|
+
* suffix is applied to every locale's value.
|
|
740
|
+
* - Non-localized title — `fields[useAsTitle]` is a plain string;
|
|
741
|
+
* suffix appended once.
|
|
742
|
+
*
|
|
743
|
+
* No-op when the collection has no `useAsTitle` or the title is null /
|
|
744
|
+
* undefined; the duplicate proceeds with the source's title verbatim and
|
|
745
|
+
* the editor can rename it. Mutates the tree in place.
|
|
746
|
+
*/
|
|
747
|
+
function applyDuplicateTitleSuffix(definition, fields, suffix) {
|
|
748
|
+
const titleField = definition.useAsTitle;
|
|
749
|
+
if (titleField == null)
|
|
750
|
+
return;
|
|
751
|
+
const value = fields[titleField];
|
|
752
|
+
if (value == null)
|
|
753
|
+
return;
|
|
754
|
+
if (typeof value === 'string') {
|
|
755
|
+
fields[titleField] = value + suffix;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
759
|
+
const localized = value;
|
|
760
|
+
for (const loc of Object.keys(localized)) {
|
|
761
|
+
const v = localized[loc];
|
|
762
|
+
if (typeof v === 'string') {
|
|
763
|
+
localized[loc] = v + suffix;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Compute the candidate path for a duplicate.
|
|
770
|
+
*
|
|
771
|
+
* Reads the default-locale value of `definition.useAsPath` (peeling the
|
|
772
|
+
* localized-shape wrapper if present) and runs it through the existing
|
|
773
|
+
* `derivePath` helper. Falls back to `crypto.randomUUID()` when no source
|
|
774
|
+
* value is available — matches `createDocument`'s behaviour for paths
|
|
775
|
+
* that can't be slugged.
|
|
776
|
+
*/
|
|
777
|
+
function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifier) {
|
|
778
|
+
const useAsPath = definition.useAsPath;
|
|
779
|
+
if (useAsPath == null) {
|
|
780
|
+
return crypto.randomUUID();
|
|
781
|
+
}
|
|
782
|
+
const raw = fields[useAsPath];
|
|
783
|
+
// Peel the localized wrapper to find the default-locale value.
|
|
784
|
+
let sourceValue = raw;
|
|
785
|
+
if (raw != null && typeof raw === 'object' && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
786
|
+
sourceValue = raw[defaultLocale];
|
|
787
|
+
}
|
|
788
|
+
return derivePath(definition, { [useAsPath]: sourceValue }, defaultLocale, slugifier);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Duplicate a document, cloning all of its locales into a brand-new
|
|
792
|
+
* document atomically.
|
|
793
|
+
*
|
|
794
|
+
* Flow:
|
|
795
|
+
* 1. `assertActorCanPerform('create')` — duplicating is a create at the
|
|
796
|
+
* ability level. The source must be readable (any RBAC scoping the
|
|
797
|
+
* caller has applies via the storage read).
|
|
798
|
+
* 2. Fetch the source with `locale: 'all'` so a single read carries the
|
|
799
|
+
* full multi-locale tree forward.
|
|
800
|
+
* 3. Deep-clone the source fields; strip block / array-item `_id` meta
|
|
801
|
+
* so the new doc gets fresh identities.
|
|
802
|
+
* 4. Append `" (copy)"` to the `useAsTitle` field's value(s).
|
|
803
|
+
* 5. Derive a candidate path from the default-locale suffixed title.
|
|
804
|
+
* 6. `hooks.beforeCreate({ data, collectionPath, duplicate })`.
|
|
805
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with `locale:
|
|
806
|
+
* 'all'`, `action: 'create'`, no `documentId` → fresh document_id.
|
|
807
|
+
* On `ERR_PATH_CONFLICT` retry once with the candidate path plus a
|
|
808
|
+
* 4-char UUID suffix; bounded to two attempts, no existence
|
|
809
|
+
* pre-check, no TOCTOU race.
|
|
810
|
+
* 8. `hooks.afterCreate({ data, collectionPath, documentId,
|
|
811
|
+
* documentVersionId, duplicate })`.
|
|
812
|
+
*
|
|
813
|
+
* The write is atomic at the storage layer — a partial duplicate is
|
|
814
|
+
* structurally impossible. Editors are expected to rename both the
|
|
815
|
+
* title and the system path after the operation; the UI surfaces a
|
|
816
|
+
* confirmation modal that calls this out.
|
|
817
|
+
*/
|
|
818
|
+
export async function duplicateDocument(ctx, params) {
|
|
819
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
820
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
821
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
|
|
822
|
+
const slugifier = ctx.slugifier ?? slugify;
|
|
823
|
+
const hooks = definition.hooks;
|
|
824
|
+
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
825
|
+
const source = await db.queries.documents.getDocumentById({
|
|
826
|
+
collection_id: collectionId,
|
|
827
|
+
document_id: params.sourceDocumentId,
|
|
828
|
+
locale: 'all',
|
|
829
|
+
reconstruct: true,
|
|
830
|
+
lenient: true,
|
|
831
|
+
requestContext: ctx.requestContext,
|
|
832
|
+
});
|
|
833
|
+
if (source == null) {
|
|
834
|
+
throw ERR_NOT_FOUND({
|
|
835
|
+
message: 'source document not found',
|
|
836
|
+
details: { sourceDocumentId: params.sourceDocumentId, collectionPath },
|
|
837
|
+
}).log(ctx.logger);
|
|
838
|
+
}
|
|
839
|
+
const sourceRecord = source;
|
|
840
|
+
const sourceFields = sourceRecord.fields ?? {};
|
|
841
|
+
// 2. Deep clone — we'll mutate (suffix titles, strip meta ids).
|
|
842
|
+
const clonedFields = structuredClone(sourceFields);
|
|
843
|
+
// 3. Fresh block / array-item identities for the new doc.
|
|
844
|
+
stripMetaIdsInPlace(clonedFields);
|
|
845
|
+
// 4. Suffix titles per locale (or once if non-localized).
|
|
846
|
+
const titleSuffix = ' (copy)';
|
|
847
|
+
applyDuplicateTitleSuffix(definition, clonedFields, titleSuffix);
|
|
848
|
+
// 5. Derive candidate path from the (now suffixed) default-locale title.
|
|
849
|
+
const candidatePath = deriveDuplicateCandidatePath(definition, clonedFields, defaultLocale, slugifier);
|
|
850
|
+
// 6. beforeCreate hook with duplicate marker.
|
|
851
|
+
const duplicateMarker = { sourceDocumentId: params.sourceDocumentId };
|
|
852
|
+
await invokeHook(hooks?.beforeCreate, {
|
|
853
|
+
data: clonedFields,
|
|
854
|
+
collectionPath,
|
|
855
|
+
duplicate: duplicateMarker,
|
|
856
|
+
});
|
|
857
|
+
// 7. Atomic write. Try the candidate path; on ERR_PATH_CONFLICT
|
|
858
|
+
// retry once with a 4-char UUID suffix.
|
|
859
|
+
const defaultStatus = getDefaultStatus(definition);
|
|
860
|
+
let finalPath = candidatePath;
|
|
861
|
+
let pathRetried = false;
|
|
862
|
+
let result;
|
|
863
|
+
try {
|
|
864
|
+
result = await db.commands.documents
|
|
865
|
+
.createDocumentVersion({
|
|
866
|
+
collectionId,
|
|
867
|
+
collectionVersion: ctx.collectionVersion,
|
|
868
|
+
collectionConfig: definition,
|
|
869
|
+
action: 'create',
|
|
870
|
+
documentData: clonedFields,
|
|
871
|
+
path: finalPath,
|
|
872
|
+
status: defaultStatus,
|
|
873
|
+
locale: 'all',
|
|
874
|
+
})
|
|
875
|
+
.catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
if (!isPathConflictError(err)) {
|
|
879
|
+
throw err;
|
|
880
|
+
}
|
|
881
|
+
// Single retry with a short UUID suffix. crypto.randomUUID() is
|
|
882
|
+
// 36 chars; take the first 4 hex digits for a compact disambiguator.
|
|
883
|
+
const shortDisambiguator = crypto.randomUUID().slice(0, 4);
|
|
884
|
+
finalPath = `${candidatePath}-${shortDisambiguator}`;
|
|
885
|
+
pathRetried = true;
|
|
886
|
+
ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
|
|
887
|
+
result = await db.commands.documents
|
|
888
|
+
.createDocumentVersion({
|
|
889
|
+
collectionId,
|
|
890
|
+
collectionVersion: ctx.collectionVersion,
|
|
891
|
+
collectionConfig: definition,
|
|
892
|
+
action: 'create',
|
|
893
|
+
documentData: clonedFields,
|
|
894
|
+
path: finalPath,
|
|
895
|
+
status: defaultStatus,
|
|
896
|
+
locale: 'all',
|
|
897
|
+
})
|
|
898
|
+
.catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
|
|
899
|
+
}
|
|
900
|
+
const newDocumentId = extractDocumentId(result.document);
|
|
901
|
+
const newDocumentVersionId = extractVersionId(result.document);
|
|
902
|
+
// 8. afterCreate hook with duplicate marker.
|
|
903
|
+
await invokeHook(hooks?.afterCreate, {
|
|
904
|
+
data: clonedFields,
|
|
905
|
+
collectionPath,
|
|
906
|
+
documentId: newDocumentId,
|
|
907
|
+
documentVersionId: newDocumentVersionId,
|
|
908
|
+
duplicate: duplicateMarker,
|
|
909
|
+
});
|
|
910
|
+
return {
|
|
911
|
+
documentId: newDocumentId,
|
|
912
|
+
documentVersionId: newDocumentVersionId,
|
|
913
|
+
sourceDocumentId: params.sourceDocumentId,
|
|
914
|
+
newPath: finalPath,
|
|
915
|
+
pathRetried,
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Detect whether an error is the `ERR_PATH_CONFLICT` raised by
|
|
921
|
+
* `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
|
|
922
|
+
* keep the conflict-handling path separate from genuine errors.
|
|
923
|
+
*/
|
|
924
|
+
function isPathConflictError(err) {
|
|
925
|
+
return (err != null &&
|
|
926
|
+
typeof err === 'object' &&
|
|
927
|
+
err.code === ErrorCodes.PATH_CONFLICT);
|
|
928
|
+
}
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
// Copy-to-Locale merge walker
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
/**
|
|
933
|
+
* Treat null, undefined, and empty string as "no value" for the purpose
|
|
934
|
+
* of the `overwrite: false` merge rule. We intentionally do NOT treat
|
|
935
|
+
* `0`, `false`, or `[]` / `{}` as empty — they are meaningful values an
|
|
936
|
+
* editor may have set deliberately.
|
|
937
|
+
*/
|
|
938
|
+
function isEmptyLeafValue(value) {
|
|
939
|
+
return value == null || value === '';
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Build the payload `copyToLocale` will write into the target locale.
|
|
943
|
+
*
|
|
944
|
+
* Walks `definition.fields` and the two reconstructed data trees in
|
|
945
|
+
* lockstep, applying the merge rule at every leaf:
|
|
946
|
+
*
|
|
947
|
+
* - **Localized leaf, `overwrite: true`** — take source's value (even
|
|
948
|
+
* when source is empty; overwriting means overwriting).
|
|
949
|
+
* - **Localized leaf, `overwrite: false`** — take source's value only
|
|
950
|
+
* when target is empty AND source is non-empty. Otherwise keep
|
|
951
|
+
* target's value. Empties under this rule are treated by
|
|
952
|
+
* `isEmptyLeafValue` — `null` / `undefined` / `''`.
|
|
953
|
+
* - **Non-localized leaf** — always keep target's value. Non-localized
|
|
954
|
+
* fields live on `locale: 'all'` rows in storage and would be wiped
|
|
955
|
+
* by the upcoming write if we did not pass them through verbatim.
|
|
956
|
+
*
|
|
957
|
+
* Structure (number of array items, blocks, etc.) follows the *target*
|
|
958
|
+
* tree — copy-to-locale never restructures the document; it only fills
|
|
959
|
+
* in localized leaves at positions the target already has.
|
|
960
|
+
*
|
|
961
|
+
* Pure: mutates nothing. The returned `data` is a fresh tree suitable
|
|
962
|
+
* to pass to `createDocumentVersion`.
|
|
963
|
+
*/
|
|
964
|
+
function mergeLocaleData(fields, sourceData, targetData, overwrite) {
|
|
965
|
+
const source = (sourceData ?? {});
|
|
966
|
+
const target = (targetData ?? {});
|
|
967
|
+
const out = {};
|
|
968
|
+
let fieldsUpdated = 0;
|
|
969
|
+
for (const field of fields) {
|
|
970
|
+
const updated = mergeFieldValue(field, source[field.name], target[field.name], overwrite);
|
|
971
|
+
out[field.name] = updated.value;
|
|
972
|
+
fieldsUpdated += updated.fieldsUpdated;
|
|
973
|
+
}
|
|
974
|
+
return { data: out, fieldsUpdated };
|
|
975
|
+
}
|
|
976
|
+
function mergeFieldValue(field, sourceValue, targetValue, overwrite) {
|
|
977
|
+
if (isGroupField(field)) {
|
|
978
|
+
const childSource = isPlainObject(sourceValue) ? sourceValue : {};
|
|
979
|
+
const childTarget = isPlainObject(targetValue) ? targetValue : {};
|
|
980
|
+
const merged = mergeLocaleData(field.fields, childSource, childTarget, overwrite);
|
|
981
|
+
return { value: merged.data, fieldsUpdated: merged.fieldsUpdated };
|
|
982
|
+
}
|
|
983
|
+
if (isArrayField(field)) {
|
|
984
|
+
if (!Array.isArray(targetValue)) {
|
|
985
|
+
// Target has no array here — keep that. Source is not authoritative
|
|
986
|
+
// for structure under copy-to-locale.
|
|
987
|
+
return { value: targetValue, fieldsUpdated: 0 };
|
|
988
|
+
}
|
|
989
|
+
const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
|
|
990
|
+
const mergedItems = [];
|
|
991
|
+
let count = 0;
|
|
992
|
+
for (let i = 0; i < targetValue.length; i++) {
|
|
993
|
+
const tItem = targetValue[i];
|
|
994
|
+
const sItem = sourceItems[i];
|
|
995
|
+
if (!isPlainObject(tItem)) {
|
|
996
|
+
mergedItems.push(tItem);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
const itemMerge = mergeLocaleData(field.fields, isPlainObject(sItem) ? sItem : {}, tItem, overwrite);
|
|
1000
|
+
// Preserve `_id` / `_type` meta on the target item — same identity
|
|
1001
|
+
// is carried forward across this update.
|
|
1002
|
+
const merged = { ...itemMerge.data };
|
|
1003
|
+
if (tItem._id !== undefined)
|
|
1004
|
+
merged._id = tItem._id;
|
|
1005
|
+
if (tItem._type !== undefined)
|
|
1006
|
+
merged._type = tItem._type;
|
|
1007
|
+
mergedItems.push(merged);
|
|
1008
|
+
count += itemMerge.fieldsUpdated;
|
|
1009
|
+
}
|
|
1010
|
+
return { value: mergedItems, fieldsUpdated: count };
|
|
1011
|
+
}
|
|
1012
|
+
if (isBlocksField(field)) {
|
|
1013
|
+
if (!Array.isArray(targetValue)) {
|
|
1014
|
+
return { value: targetValue, fieldsUpdated: 0 };
|
|
1015
|
+
}
|
|
1016
|
+
const sourceItems = Array.isArray(sourceValue) ? sourceValue : [];
|
|
1017
|
+
const mergedItems = [];
|
|
1018
|
+
let count = 0;
|
|
1019
|
+
for (let i = 0; i < targetValue.length; i++) {
|
|
1020
|
+
const tItem = targetValue[i];
|
|
1021
|
+
if (!isPlainObject(tItem)) {
|
|
1022
|
+
mergedItems.push(tItem);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const blockType = tItem._type;
|
|
1026
|
+
const block = field.blocks.find((b) => b.blockType === blockType);
|
|
1027
|
+
if (block == null) {
|
|
1028
|
+
// Unknown block — pass through unchanged.
|
|
1029
|
+
mergedItems.push(tItem);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const sItem = sourceItems[i];
|
|
1033
|
+
const itemMerge = mergeLocaleData(block.fields, isPlainObject(sItem) && sItem._type === blockType ? sItem : {}, tItem, overwrite);
|
|
1034
|
+
const merged = { ...itemMerge.data };
|
|
1035
|
+
if (tItem._id !== undefined)
|
|
1036
|
+
merged._id = tItem._id;
|
|
1037
|
+
merged._type = blockType;
|
|
1038
|
+
mergedItems.push(merged);
|
|
1039
|
+
count += itemMerge.fieldsUpdated;
|
|
1040
|
+
}
|
|
1041
|
+
return { value: mergedItems, fieldsUpdated: count };
|
|
1042
|
+
}
|
|
1043
|
+
// Leaf field.
|
|
1044
|
+
const localized = field.localized === true;
|
|
1045
|
+
if (!localized) {
|
|
1046
|
+
// Non-localized leaves live on locale: 'all' rows. Pass the target's
|
|
1047
|
+
// value through verbatim so the write does not wipe them.
|
|
1048
|
+
return { value: targetValue, fieldsUpdated: 0 };
|
|
1049
|
+
}
|
|
1050
|
+
if (overwrite) {
|
|
1051
|
+
return {
|
|
1052
|
+
value: sourceValue,
|
|
1053
|
+
fieldsUpdated: sourceValue === targetValue ? 0 : 1,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
// overwrite: false — fill only when target is empty AND source has content.
|
|
1057
|
+
if (isEmptyLeafValue(targetValue) && !isEmptyLeafValue(sourceValue)) {
|
|
1058
|
+
return { value: sourceValue, fieldsUpdated: 1 };
|
|
1059
|
+
}
|
|
1060
|
+
return { value: targetValue, fieldsUpdated: 0 };
|
|
1061
|
+
}
|
|
1062
|
+
function isPlainObject(value) {
|
|
1063
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
1064
|
+
}
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
// copyToLocale
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
/**
|
|
1069
|
+
* Copy a document's content from one locale into another, in place on
|
|
1070
|
+
* the same document.
|
|
1071
|
+
*
|
|
1072
|
+
* Reads the source and target locales separately (the storage layer
|
|
1073
|
+
* resolves localized fields to flat single-locale shapes when given a
|
|
1074
|
+
* specific `resolveLocale`). A schema-aware merge walker decides, leaf
|
|
1075
|
+
* by leaf, whether to take the source's value or keep the target's,
|
|
1076
|
+
* driven by the `overwrite` flag. The merged tree is written via
|
|
1077
|
+
* `createDocumentVersion({ action: 'copy_to_locale', locale: target })`
|
|
1078
|
+
* — the existing cross-locale carry-forward in the storage primitive
|
|
1079
|
+
* preserves every *other* locale's rows untouched.
|
|
1080
|
+
*
|
|
1081
|
+
* Non-localized fields are never altered by this operation: they live
|
|
1082
|
+
* on `locale: 'all'` rows and the merge walker passes the target's
|
|
1083
|
+
* value through so the write does not blank them.
|
|
1084
|
+
*
|
|
1085
|
+
* Path is sticky and lives on default-locale only; this operation never
|
|
1086
|
+
* touches `byline_document_paths`. Status resets to the workflow
|
|
1087
|
+
* default — translations land as drafts.
|
|
1088
|
+
*
|
|
1089
|
+
* Flow:
|
|
1090
|
+
* 1. `assertActorCanPerform('update')` — same gate as a translation save.
|
|
1091
|
+
* 2. Reject if `sourceLocale === targetLocale`.
|
|
1092
|
+
* 3. Fetch source via `getDocumentById({ locale: sourceLocale })`.
|
|
1093
|
+
* 4. Fetch target via `getDocumentById({ locale: targetLocale })`.
|
|
1094
|
+
* 5. `mergeLocaleData(definition.fields, source.fields, target.fields, overwrite)`.
|
|
1095
|
+
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, copyToLocale })`.
|
|
1096
|
+
* 7. `createDocumentVersion({ documentId, action: 'copy_to_locale',
|
|
1097
|
+
* locale: targetLocale, documentData, previousVersionId, status })`.
|
|
1098
|
+
* 8. `hooks.afterUpdate({ ..., copyToLocale })`.
|
|
1099
|
+
*/
|
|
1100
|
+
export async function copyToLocale(ctx, params) {
|
|
1101
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
|
|
1102
|
+
const { db, definition, collectionId, collectionPath } = ctx;
|
|
1103
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
1104
|
+
if (params.sourceLocale === params.targetLocale) {
|
|
1105
|
+
throw ERR_VALIDATION({
|
|
1106
|
+
message: 'sourceLocale and targetLocale must differ',
|
|
1107
|
+
details: {
|
|
1108
|
+
documentId: params.documentId,
|
|
1109
|
+
sourceLocale: params.sourceLocale,
|
|
1110
|
+
targetLocale: params.targetLocale,
|
|
1111
|
+
},
|
|
1112
|
+
}).log(ctx.logger);
|
|
1113
|
+
}
|
|
1114
|
+
// 1. Source read.
|
|
1115
|
+
const source = await db.queries.documents.getDocumentById({
|
|
1116
|
+
collection_id: collectionId,
|
|
1117
|
+
document_id: params.documentId,
|
|
1118
|
+
locale: params.sourceLocale,
|
|
1119
|
+
reconstruct: true,
|
|
1120
|
+
lenient: true,
|
|
1121
|
+
requestContext: ctx.requestContext,
|
|
1122
|
+
});
|
|
1123
|
+
if (source == null) {
|
|
1124
|
+
throw ERR_NOT_FOUND({
|
|
1125
|
+
message: 'document not found in source locale',
|
|
1126
|
+
details: {
|
|
1127
|
+
documentId: params.documentId,
|
|
1128
|
+
sourceLocale: params.sourceLocale,
|
|
1129
|
+
collectionPath,
|
|
1130
|
+
},
|
|
1131
|
+
}).log(ctx.logger);
|
|
1132
|
+
}
|
|
1133
|
+
// 2. Target read — needed for both originalData (hooks) and to
|
|
1134
|
+
// preserve non-localized values + structural shape.
|
|
1135
|
+
const target = await db.queries.documents.getDocumentById({
|
|
1136
|
+
collection_id: collectionId,
|
|
1137
|
+
document_id: params.documentId,
|
|
1138
|
+
locale: params.targetLocale,
|
|
1139
|
+
reconstruct: true,
|
|
1140
|
+
lenient: true,
|
|
1141
|
+
requestContext: ctx.requestContext,
|
|
1142
|
+
});
|
|
1143
|
+
if (target == null) {
|
|
1144
|
+
throw ERR_NOT_FOUND({
|
|
1145
|
+
message: 'document not found in target locale',
|
|
1146
|
+
details: {
|
|
1147
|
+
documentId: params.documentId,
|
|
1148
|
+
targetLocale: params.targetLocale,
|
|
1149
|
+
collectionPath,
|
|
1150
|
+
},
|
|
1151
|
+
}).log(ctx.logger);
|
|
1152
|
+
}
|
|
1153
|
+
const sourceRecord = source;
|
|
1154
|
+
const targetRecord = target;
|
|
1155
|
+
const sourceFields = sourceRecord.fields ?? {};
|
|
1156
|
+
const targetFields = targetRecord.fields ?? {};
|
|
1157
|
+
// 3. Merge.
|
|
1158
|
+
const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
|
|
1159
|
+
// 4. Hooks see the target-locale view as originalData (consistent
|
|
1160
|
+
// with how updateDocument scopes originalData to the active
|
|
1161
|
+
// locale) and the merged payload as the next `data`.
|
|
1162
|
+
const hooks = definition.hooks;
|
|
1163
|
+
const copyToLocaleMarker = {
|
|
1164
|
+
sourceLocale: params.sourceLocale,
|
|
1165
|
+
targetLocale: params.targetLocale,
|
|
1166
|
+
};
|
|
1167
|
+
await invokeHook(hooks?.beforeUpdate, {
|
|
1168
|
+
data: merged.data,
|
|
1169
|
+
originalData: targetFields,
|
|
1170
|
+
collectionPath,
|
|
1171
|
+
copyToLocale: copyToLocaleMarker,
|
|
1172
|
+
});
|
|
1173
|
+
// 5. Write. previousVersionId threads the current version id so the
|
|
1174
|
+
// storage primitive's cross-locale carry-forward fires for every
|
|
1175
|
+
// *other* locale (not source, not target — those rows are
|
|
1176
|
+
// rewritten by this call).
|
|
1177
|
+
const previousVersionId = targetRecord.document_version_id ?? undefined;
|
|
1178
|
+
const writeResult = await db.commands.documents.createDocumentVersion({
|
|
1179
|
+
documentId: params.documentId,
|
|
1180
|
+
collectionId,
|
|
1181
|
+
collectionVersion: ctx.collectionVersion,
|
|
1182
|
+
collectionConfig: definition,
|
|
1183
|
+
action: 'copy_to_locale',
|
|
1184
|
+
documentData: merged.data,
|
|
1185
|
+
status: getDefaultStatus(definition),
|
|
1186
|
+
locale: params.targetLocale,
|
|
1187
|
+
previousVersionId,
|
|
1188
|
+
});
|
|
1189
|
+
const documentVersionId = extractVersionId(writeResult.document);
|
|
1190
|
+
await invokeHook(hooks?.afterUpdate, {
|
|
1191
|
+
data: merged.data,
|
|
1192
|
+
originalData: targetFields,
|
|
1193
|
+
collectionPath,
|
|
1194
|
+
documentId: params.documentId,
|
|
1195
|
+
documentVersionId,
|
|
1196
|
+
copyToLocale: copyToLocaleMarker,
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
documentId: params.documentId,
|
|
1200
|
+
documentVersionId,
|
|
1201
|
+
sourceLocale: params.sourceLocale,
|
|
1202
|
+
targetLocale: params.targetLocale,
|
|
1203
|
+
fieldsUpdated: merged.fieldsUpdated,
|
|
1204
|
+
};
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
11
|
-
import { changeDocumentStatus, createDocument, deleteDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
10
|
+
import { BylineError, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
|
|
11
|
+
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Fixtures / Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -918,6 +918,585 @@ describe('Document lifecycle service', () => {
|
|
|
918
918
|
});
|
|
919
919
|
});
|
|
920
920
|
// -----------------------------------------------------------------------
|
|
921
|
+
// duplicateDocument
|
|
922
|
+
// -----------------------------------------------------------------------
|
|
923
|
+
describe('duplicateDocument', () => {
|
|
924
|
+
/** Collection with a localized title (drives the per-locale suffix path). */
|
|
925
|
+
const localizedCollection = {
|
|
926
|
+
path: 'articles',
|
|
927
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
928
|
+
useAsTitle: 'title',
|
|
929
|
+
useAsPath: 'title',
|
|
930
|
+
fields: [
|
|
931
|
+
{ name: 'title', type: 'text', localized: true },
|
|
932
|
+
{ name: 'tagline', type: 'text' },
|
|
933
|
+
],
|
|
934
|
+
};
|
|
935
|
+
/** Helper: seed the source read with `locale: 'all'`. */
|
|
936
|
+
function setupSource(mocks, opts) {
|
|
937
|
+
const sourceDocumentId = opts?.sourceDocumentId ?? 'doc-source';
|
|
938
|
+
const sourceFields = opts?.sourceFields ?? {
|
|
939
|
+
title: { en: 'Hello', fr: 'Bonjour' },
|
|
940
|
+
tagline: 'A tagline',
|
|
941
|
+
};
|
|
942
|
+
mocks.getDocumentById.mockResolvedValue({
|
|
943
|
+
document_version_id: 'ver-source',
|
|
944
|
+
document_id: sourceDocumentId,
|
|
945
|
+
path: 'hello',
|
|
946
|
+
status: 'draft',
|
|
947
|
+
created_at: new Date(),
|
|
948
|
+
updated_at: new Date(),
|
|
949
|
+
fields: sourceFields,
|
|
950
|
+
});
|
|
951
|
+
mocks.createDocumentVersion.mockResolvedValue({
|
|
952
|
+
document: { id: 'ver-new', document_id: 'doc-new' },
|
|
953
|
+
fieldCount: 5,
|
|
954
|
+
});
|
|
955
|
+
return { sourceDocumentId, sourceFields };
|
|
956
|
+
}
|
|
957
|
+
it('reads the source with locale: "all" and writes via createDocumentVersion with locale: "all", action: "create", no documentId', async () => {
|
|
958
|
+
const mocks = createMockDb();
|
|
959
|
+
const { sourceDocumentId } = setupSource(mocks);
|
|
960
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
961
|
+
const result = await duplicateDocument(ctx, { sourceDocumentId });
|
|
962
|
+
expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
|
|
963
|
+
collection_id: 'col-1',
|
|
964
|
+
document_id: sourceDocumentId,
|
|
965
|
+
locale: 'all',
|
|
966
|
+
reconstruct: true,
|
|
967
|
+
lenient: true,
|
|
968
|
+
}));
|
|
969
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
970
|
+
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
971
|
+
expect(call.action).toBe('create');
|
|
972
|
+
expect(call.locale).toBe('all');
|
|
973
|
+
expect(call.documentId).toBeUndefined();
|
|
974
|
+
expect(result.documentId).toBe('doc-new');
|
|
975
|
+
expect(result.documentVersionId).toBe('ver-new');
|
|
976
|
+
expect(result.sourceDocumentId).toBe(sourceDocumentId);
|
|
977
|
+
expect(result.pathRetried).toBe(false);
|
|
978
|
+
});
|
|
979
|
+
it('appends " (copy)" to every locale of a localized useAsTitle field', async () => {
|
|
980
|
+
const mocks = createMockDb();
|
|
981
|
+
setupSource(mocks, {
|
|
982
|
+
sourceFields: {
|
|
983
|
+
title: { en: 'Hello', fr: 'Bonjour' },
|
|
984
|
+
tagline: 'A tagline',
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
988
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
989
|
+
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
990
|
+
expect(call.documentData.title).toEqual({
|
|
991
|
+
en: 'Hello (copy)',
|
|
992
|
+
fr: 'Bonjour (copy)',
|
|
993
|
+
});
|
|
994
|
+
// Non-title fields pass through untouched.
|
|
995
|
+
expect(call.documentData.tagline).toBe('A tagline');
|
|
996
|
+
});
|
|
997
|
+
it('appends " (copy)" once to a non-localized useAsTitle field', async () => {
|
|
998
|
+
const nonLocalizedCollection = {
|
|
999
|
+
...localizedCollection,
|
|
1000
|
+
fields: [
|
|
1001
|
+
{ name: 'title', type: 'text' },
|
|
1002
|
+
{ name: 'tagline', type: 'text' },
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
const mocks = createMockDb();
|
|
1006
|
+
setupSource(mocks, {
|
|
1007
|
+
sourceFields: {
|
|
1008
|
+
title: 'Hello',
|
|
1009
|
+
tagline: 'A tagline',
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
const ctx = buildCtx(mocks.db, nonLocalizedCollection);
|
|
1013
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1014
|
+
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
1015
|
+
expect(call.documentData.title).toBe('Hello (copy)');
|
|
1016
|
+
});
|
|
1017
|
+
it('derives the candidate path from the default-locale suffixed title', async () => {
|
|
1018
|
+
const mocks = createMockDb();
|
|
1019
|
+
setupSource(mocks, {
|
|
1020
|
+
sourceFields: {
|
|
1021
|
+
title: { en: 'Hello World', fr: 'Bonjour Monde' },
|
|
1022
|
+
tagline: 'A tagline',
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1026
|
+
const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1027
|
+
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
1028
|
+
// Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
|
|
1029
|
+
expect(call.path).toBe('hello-world-copy');
|
|
1030
|
+
expect(result.newPath).toBe('hello-world-copy');
|
|
1031
|
+
});
|
|
1032
|
+
it('strips _id and _type metadata from blocks and array items so the new doc gets fresh identities', async () => {
|
|
1033
|
+
const mocks = createMockDb();
|
|
1034
|
+
setupSource(mocks, {
|
|
1035
|
+
sourceFields: {
|
|
1036
|
+
title: { en: 'Hello', fr: 'Bonjour' },
|
|
1037
|
+
tagline: 'A tagline',
|
|
1038
|
+
sections: [
|
|
1039
|
+
{
|
|
1040
|
+
_id: 'section-id-1',
|
|
1041
|
+
_type: 'section',
|
|
1042
|
+
heading: 'Intro',
|
|
1043
|
+
blocks: [
|
|
1044
|
+
{ _id: 'block-id-1', _type: 'photoBlock', display: 'wide' },
|
|
1045
|
+
{ _id: 'block-id-2', _type: 'textBlock', body: 'inner' },
|
|
1046
|
+
],
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1052
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1053
|
+
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
1054
|
+
const sections = call.documentData.sections;
|
|
1055
|
+
expect(sections[0]._id).toBeUndefined();
|
|
1056
|
+
expect(sections[0]._type).toBeUndefined();
|
|
1057
|
+
expect(sections[0].blocks[0]._id).toBeUndefined();
|
|
1058
|
+
expect(sections[0].blocks[1]._id).toBeUndefined();
|
|
1059
|
+
// Content survives — only meta is stripped.
|
|
1060
|
+
expect(sections[0].heading).toBe('Intro');
|
|
1061
|
+
expect(sections[0].blocks[0].display).toBe('wide');
|
|
1062
|
+
});
|
|
1063
|
+
it('does not mutate the source object returned by getDocumentById (deep-clones before suffix / strip)', async () => {
|
|
1064
|
+
const originalFields = {
|
|
1065
|
+
title: { en: 'Hello', fr: 'Bonjour' },
|
|
1066
|
+
tagline: 'A tagline',
|
|
1067
|
+
sections: [{ _id: 'sec-1', heading: 'Intro' }],
|
|
1068
|
+
};
|
|
1069
|
+
const mocks = createMockDb();
|
|
1070
|
+
mocks.getDocumentById.mockResolvedValue({
|
|
1071
|
+
document_version_id: 'ver-source',
|
|
1072
|
+
document_id: 'doc-source',
|
|
1073
|
+
path: 'hello',
|
|
1074
|
+
status: 'draft',
|
|
1075
|
+
created_at: new Date(),
|
|
1076
|
+
updated_at: new Date(),
|
|
1077
|
+
fields: originalFields,
|
|
1078
|
+
});
|
|
1079
|
+
mocks.createDocumentVersion.mockResolvedValue({
|
|
1080
|
+
document: { id: 'ver-new', document_id: 'doc-new' },
|
|
1081
|
+
fieldCount: 5,
|
|
1082
|
+
});
|
|
1083
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1084
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1085
|
+
// Source title should be unchanged in memory.
|
|
1086
|
+
expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
|
|
1087
|
+
expect(originalFields.sections[0]?._id).toBe('sec-1');
|
|
1088
|
+
});
|
|
1089
|
+
it('retries once with a short-UUID suffix when the candidate path collides', async () => {
|
|
1090
|
+
const mocks = createMockDb();
|
|
1091
|
+
setupSource(mocks);
|
|
1092
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1093
|
+
// First call: throw a path-conflict error. Second call: succeed.
|
|
1094
|
+
let attempt = 0;
|
|
1095
|
+
mocks.createDocumentVersion.mockImplementation(() => {
|
|
1096
|
+
attempt += 1;
|
|
1097
|
+
if (attempt === 1) {
|
|
1098
|
+
const err = ERR_PATH_CONFLICT({ message: 'path conflict' });
|
|
1099
|
+
return Promise.reject(err);
|
|
1100
|
+
}
|
|
1101
|
+
return Promise.resolve({
|
|
1102
|
+
document: { id: 'ver-new', document_id: 'doc-new' },
|
|
1103
|
+
fieldCount: 5,
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1107
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
|
|
1108
|
+
const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
|
|
1109
|
+
const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
|
|
1110
|
+
expect(retryPath.startsWith(`${firstPath}-`)).toBe(true);
|
|
1111
|
+
// 4-char UUID slice
|
|
1112
|
+
expect(retryPath.length).toBe(firstPath.length + 5);
|
|
1113
|
+
expect(result.pathRetried).toBe(true);
|
|
1114
|
+
expect(result.newPath).toBe(retryPath);
|
|
1115
|
+
});
|
|
1116
|
+
it('only retries once — a second conflict propagates to the caller', async () => {
|
|
1117
|
+
const mocks = createMockDb();
|
|
1118
|
+
setupSource(mocks);
|
|
1119
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1120
|
+
// Both attempts throw path-conflict.
|
|
1121
|
+
mocks.createDocumentVersion.mockImplementation(() => {
|
|
1122
|
+
return Promise.reject(ERR_PATH_CONFLICT({ message: 'path conflict' }));
|
|
1123
|
+
});
|
|
1124
|
+
await expect(duplicateDocument(ctx, { sourceDocumentId: 'doc-source' })).rejects.toMatchObject({ code: ErrorCodes.PATH_CONFLICT });
|
|
1125
|
+
// Bounded to exactly two attempts.
|
|
1126
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
|
|
1127
|
+
});
|
|
1128
|
+
it('throws ERR_NOT_FOUND when the source document does not exist', async () => {
|
|
1129
|
+
const mocks = createMockDb();
|
|
1130
|
+
mocks.getDocumentById.mockResolvedValue(null);
|
|
1131
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1132
|
+
try {
|
|
1133
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-missing' });
|
|
1134
|
+
expect.fail('expected ERR_NOT_FOUND');
|
|
1135
|
+
}
|
|
1136
|
+
catch (err) {
|
|
1137
|
+
expect(err.code).toBe(ErrorCodes.NOT_FOUND);
|
|
1138
|
+
}
|
|
1139
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1140
|
+
});
|
|
1141
|
+
it('fires beforeCreate / afterCreate hooks with a duplicate marker', async () => {
|
|
1142
|
+
const beforeCreate = vi.fn();
|
|
1143
|
+
const afterCreate = vi.fn();
|
|
1144
|
+
const withHooks = {
|
|
1145
|
+
...localizedCollection,
|
|
1146
|
+
hooks: {
|
|
1147
|
+
beforeCreate,
|
|
1148
|
+
afterCreate,
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
const mocks = createMockDb();
|
|
1152
|
+
setupSource(mocks, { sourceDocumentId: 'doc-source' });
|
|
1153
|
+
const ctx = buildCtx(mocks.db, withHooks);
|
|
1154
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1155
|
+
expect(beforeCreate).toHaveBeenCalledOnce();
|
|
1156
|
+
const beforeCtx = beforeCreate.mock.calls[0]?.[0];
|
|
1157
|
+
expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
|
|
1158
|
+
expect(beforeCtx.collectionPath).toBe('articles');
|
|
1159
|
+
// Hook sees the multi-locale shape (mirrors restoreDocumentVersion's
|
|
1160
|
+
// multi-locale-data precedent on beforeUpdate).
|
|
1161
|
+
expect(beforeCtx.data.title).toEqual({ en: 'Hello (copy)', fr: 'Bonjour (copy)' });
|
|
1162
|
+
expect(afterCreate).toHaveBeenCalledOnce();
|
|
1163
|
+
const afterCtx = afterCreate.mock.calls[0]?.[0];
|
|
1164
|
+
expect(afterCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
|
|
1165
|
+
expect(afterCtx.documentId).toBe('doc-new');
|
|
1166
|
+
expect(afterCtx.documentVersionId).toBe('ver-new');
|
|
1167
|
+
});
|
|
1168
|
+
it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
|
|
1169
|
+
const mocks = createMockDb();
|
|
1170
|
+
setupSource(mocks);
|
|
1171
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1172
|
+
// Replace super-admin with an admin who only has read.
|
|
1173
|
+
const actor = new AdminAuth({
|
|
1174
|
+
id: 'reader',
|
|
1175
|
+
abilities: ['collections.articles.read'],
|
|
1176
|
+
});
|
|
1177
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
1178
|
+
try {
|
|
1179
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1180
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
1181
|
+
}
|
|
1182
|
+
catch (err) {
|
|
1183
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
1184
|
+
expect(err.message).toContain('collections.articles.create');
|
|
1185
|
+
}
|
|
1186
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1187
|
+
});
|
|
1188
|
+
it('rejects when requestContext is absent (ERR_UNAUTHENTICATED)', async () => {
|
|
1189
|
+
const mocks = createMockDb();
|
|
1190
|
+
setupSource(mocks);
|
|
1191
|
+
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
1192
|
+
ctx.requestContext = undefined;
|
|
1193
|
+
try {
|
|
1194
|
+
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
1195
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
1196
|
+
}
|
|
1197
|
+
catch (err) {
|
|
1198
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
1199
|
+
}
|
|
1200
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
// -----------------------------------------------------------------------
|
|
1204
|
+
// copyToLocale
|
|
1205
|
+
// -----------------------------------------------------------------------
|
|
1206
|
+
describe('copyToLocale', () => {
|
|
1207
|
+
/** Collection with mixed localized / non-localized fields, including
|
|
1208
|
+
* nested structure (array of groups, blocks). */
|
|
1209
|
+
const mixedCollection = {
|
|
1210
|
+
path: 'articles',
|
|
1211
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
1212
|
+
useAsTitle: 'title',
|
|
1213
|
+
fields: [
|
|
1214
|
+
{ name: 'title', type: 'text', localized: true },
|
|
1215
|
+
{ name: 'tagline', type: 'text', localized: true },
|
|
1216
|
+
{ name: 'sku', type: 'text' /* non-localized */ },
|
|
1217
|
+
{
|
|
1218
|
+
name: 'sections',
|
|
1219
|
+
type: 'array',
|
|
1220
|
+
fields: [
|
|
1221
|
+
{ name: 'heading', type: 'text', localized: true },
|
|
1222
|
+
{ name: 'order', type: 'integer' /* non-localized */ },
|
|
1223
|
+
],
|
|
1224
|
+
},
|
|
1225
|
+
],
|
|
1226
|
+
};
|
|
1227
|
+
function setupSourceTarget(opts) {
|
|
1228
|
+
const mocks = createMockDb();
|
|
1229
|
+
const sourceFields = opts?.sourceFields ?? {
|
|
1230
|
+
title: 'Hello',
|
|
1231
|
+
tagline: 'World',
|
|
1232
|
+
sku: 'SKU-1',
|
|
1233
|
+
sections: [{ _id: 'sec-1', heading: 'Intro', order: 1 }],
|
|
1234
|
+
};
|
|
1235
|
+
const targetFields = opts?.targetFields ?? {
|
|
1236
|
+
title: '',
|
|
1237
|
+
tagline: 'Already translated',
|
|
1238
|
+
sku: 'SKU-1',
|
|
1239
|
+
sections: [{ _id: 'sec-1', heading: '', order: 1 }],
|
|
1240
|
+
};
|
|
1241
|
+
const currentVersionId = opts?.currentVersionId ?? 'ver-current';
|
|
1242
|
+
mocks.getDocumentById.mockImplementation(async (params) => {
|
|
1243
|
+
if (params.locale === 'en') {
|
|
1244
|
+
return {
|
|
1245
|
+
document_version_id: currentVersionId,
|
|
1246
|
+
document_id: 'doc-1',
|
|
1247
|
+
path: 'hello',
|
|
1248
|
+
status: 'draft',
|
|
1249
|
+
fields: sourceFields,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
if (params.locale === 'fr') {
|
|
1253
|
+
return {
|
|
1254
|
+
document_version_id: currentVersionId,
|
|
1255
|
+
document_id: 'doc-1',
|
|
1256
|
+
path: 'hello',
|
|
1257
|
+
status: 'draft',
|
|
1258
|
+
fields: targetFields,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
return null;
|
|
1262
|
+
});
|
|
1263
|
+
mocks.createDocumentVersion.mockResolvedValue({
|
|
1264
|
+
document: { id: 'ver-new', document_id: 'doc-1' },
|
|
1265
|
+
fieldCount: 5,
|
|
1266
|
+
});
|
|
1267
|
+
return { mocks, sourceFields, targetFields, currentVersionId };
|
|
1268
|
+
}
|
|
1269
|
+
it('reads both source and target locales, writes target with action="copy_to_locale", locale=target, previousVersionId threaded', async () => {
|
|
1270
|
+
const { mocks, currentVersionId } = setupSourceTarget();
|
|
1271
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1272
|
+
const result = await copyToLocale(ctx, {
|
|
1273
|
+
documentId: 'doc-1',
|
|
1274
|
+
sourceLocale: 'en',
|
|
1275
|
+
targetLocale: 'fr',
|
|
1276
|
+
overwrite: false,
|
|
1277
|
+
});
|
|
1278
|
+
// Source + target read.
|
|
1279
|
+
const readCalls = mocks.getDocumentById.mock.calls.map((c) => c[0]);
|
|
1280
|
+
expect(readCalls.some((p) => p.locale === 'en')).toBe(true);
|
|
1281
|
+
expect(readCalls.some((p) => p.locale === 'fr')).toBe(true);
|
|
1282
|
+
// Single write to target locale.
|
|
1283
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
1284
|
+
const writeCall = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
1285
|
+
expect(writeCall.action).toBe('copy_to_locale');
|
|
1286
|
+
expect(writeCall.locale).toBe('fr');
|
|
1287
|
+
expect(writeCall.documentId).toBe('doc-1');
|
|
1288
|
+
expect(writeCall.previousVersionId).toBe(currentVersionId);
|
|
1289
|
+
expect(writeCall.path).toBeUndefined();
|
|
1290
|
+
// Result envelope.
|
|
1291
|
+
expect(result.documentId).toBe('doc-1');
|
|
1292
|
+
expect(result.sourceLocale).toBe('en');
|
|
1293
|
+
expect(result.targetLocale).toBe('fr');
|
|
1294
|
+
});
|
|
1295
|
+
it('overwrite=false: fills empty target slots from source, preserves populated target slots, never touches non-localized fields', async () => {
|
|
1296
|
+
const { mocks } = setupSourceTarget({
|
|
1297
|
+
sourceFields: {
|
|
1298
|
+
title: 'EN Title',
|
|
1299
|
+
tagline: 'EN Tagline',
|
|
1300
|
+
sku: 'SKU-EN',
|
|
1301
|
+
sections: [{ _id: 'sec-1', heading: 'EN Heading', order: 5 }],
|
|
1302
|
+
},
|
|
1303
|
+
targetFields: {
|
|
1304
|
+
title: '', // empty → should be filled
|
|
1305
|
+
tagline: 'FR Tagline Already', // populated → should be kept
|
|
1306
|
+
sku: 'SKU-FR', // non-localized, target value preserved
|
|
1307
|
+
sections: [{ _id: 'sec-1', heading: '', order: 9 }],
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1311
|
+
const result = await copyToLocale(ctx, {
|
|
1312
|
+
documentId: 'doc-1',
|
|
1313
|
+
sourceLocale: 'en',
|
|
1314
|
+
targetLocale: 'fr',
|
|
1315
|
+
overwrite: false,
|
|
1316
|
+
});
|
|
1317
|
+
const data = mocks.createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
1318
|
+
expect(data.title).toBe('EN Title'); // filled
|
|
1319
|
+
expect(data.tagline).toBe('FR Tagline Already'); // kept
|
|
1320
|
+
expect(data.sku).toBe('SKU-FR'); // non-localized: target preserved
|
|
1321
|
+
expect(data.sections[0].heading).toBe('EN Heading'); // filled
|
|
1322
|
+
expect(data.sections[0].order).toBe(9); // non-localized: target preserved
|
|
1323
|
+
expect(data.sections[0]._id).toBe('sec-1'); // identity preserved
|
|
1324
|
+
expect(result.fieldsUpdated).toBe(2); // title + sections[0].heading
|
|
1325
|
+
});
|
|
1326
|
+
it('overwrite=true: replaces every localized leaf with source value, even when source is empty', async () => {
|
|
1327
|
+
const { mocks } = setupSourceTarget({
|
|
1328
|
+
sourceFields: {
|
|
1329
|
+
title: 'EN Title',
|
|
1330
|
+
tagline: '', // empty source
|
|
1331
|
+
sku: 'SKU-EN',
|
|
1332
|
+
sections: [{ _id: 'sec-1', heading: 'EN Heading', order: 5 }],
|
|
1333
|
+
},
|
|
1334
|
+
targetFields: {
|
|
1335
|
+
title: 'FR Title',
|
|
1336
|
+
tagline: 'FR Tagline',
|
|
1337
|
+
sku: 'SKU-FR',
|
|
1338
|
+
sections: [{ _id: 'sec-1', heading: 'FR Heading', order: 9 }],
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1342
|
+
await copyToLocale(ctx, {
|
|
1343
|
+
documentId: 'doc-1',
|
|
1344
|
+
sourceLocale: 'en',
|
|
1345
|
+
targetLocale: 'fr',
|
|
1346
|
+
overwrite: true,
|
|
1347
|
+
});
|
|
1348
|
+
const data = mocks.createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
1349
|
+
expect(data.title).toBe('EN Title'); // overwritten
|
|
1350
|
+
expect(data.tagline).toBe(''); // overwritten even though source is empty
|
|
1351
|
+
expect(data.sku).toBe('SKU-FR'); // non-localized: still target preserved
|
|
1352
|
+
expect(data.sections[0].heading).toBe('EN Heading'); // overwritten
|
|
1353
|
+
expect(data.sections[0].order).toBe(9); // non-localized preserved
|
|
1354
|
+
});
|
|
1355
|
+
it('does not pass `path` — path is sticky on copy-to-locale', async () => {
|
|
1356
|
+
const { mocks } = setupSourceTarget();
|
|
1357
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1358
|
+
await copyToLocale(ctx, {
|
|
1359
|
+
documentId: 'doc-1',
|
|
1360
|
+
sourceLocale: 'en',
|
|
1361
|
+
targetLocale: 'fr',
|
|
1362
|
+
overwrite: false,
|
|
1363
|
+
});
|
|
1364
|
+
expect(mocks.createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
|
|
1365
|
+
});
|
|
1366
|
+
it('rejects when sourceLocale === targetLocale (ERR_VALIDATION)', async () => {
|
|
1367
|
+
const { mocks } = setupSourceTarget();
|
|
1368
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1369
|
+
try {
|
|
1370
|
+
await copyToLocale(ctx, {
|
|
1371
|
+
documentId: 'doc-1',
|
|
1372
|
+
sourceLocale: 'en',
|
|
1373
|
+
targetLocale: 'en',
|
|
1374
|
+
overwrite: false,
|
|
1375
|
+
});
|
|
1376
|
+
expect.fail('expected ERR_VALIDATION');
|
|
1377
|
+
}
|
|
1378
|
+
catch (err) {
|
|
1379
|
+
expect(err.code).toBe(ErrorCodes.VALIDATION);
|
|
1380
|
+
}
|
|
1381
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1382
|
+
});
|
|
1383
|
+
it('throws ERR_NOT_FOUND when the source-locale read returns null', async () => {
|
|
1384
|
+
const mocks = createMockDb();
|
|
1385
|
+
mocks.getDocumentById.mockResolvedValue(null); // both reads fail
|
|
1386
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1387
|
+
try {
|
|
1388
|
+
await copyToLocale(ctx, {
|
|
1389
|
+
documentId: 'doc-1',
|
|
1390
|
+
sourceLocale: 'en',
|
|
1391
|
+
targetLocale: 'fr',
|
|
1392
|
+
overwrite: false,
|
|
1393
|
+
});
|
|
1394
|
+
expect.fail('expected ERR_NOT_FOUND');
|
|
1395
|
+
}
|
|
1396
|
+
catch (err) {
|
|
1397
|
+
expect(err.code).toBe(ErrorCodes.NOT_FOUND);
|
|
1398
|
+
}
|
|
1399
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1400
|
+
});
|
|
1401
|
+
it('throws ERR_NOT_FOUND when the target-locale read returns null', async () => {
|
|
1402
|
+
const mocks = createMockDb();
|
|
1403
|
+
// Source returns a doc; target returns null.
|
|
1404
|
+
mocks.getDocumentById.mockImplementation(async (params) => {
|
|
1405
|
+
if (params.locale === 'en') {
|
|
1406
|
+
return {
|
|
1407
|
+
document_version_id: 'ver-current',
|
|
1408
|
+
document_id: 'doc-1',
|
|
1409
|
+
path: 'hello',
|
|
1410
|
+
status: 'draft',
|
|
1411
|
+
fields: { title: 'EN Title' },
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
return null;
|
|
1415
|
+
});
|
|
1416
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1417
|
+
try {
|
|
1418
|
+
await copyToLocale(ctx, {
|
|
1419
|
+
documentId: 'doc-1',
|
|
1420
|
+
sourceLocale: 'en',
|
|
1421
|
+
targetLocale: 'fr',
|
|
1422
|
+
overwrite: false,
|
|
1423
|
+
});
|
|
1424
|
+
expect.fail('expected ERR_NOT_FOUND');
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
expect(err.code).toBe(ErrorCodes.NOT_FOUND);
|
|
1428
|
+
}
|
|
1429
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1430
|
+
});
|
|
1431
|
+
it('fires beforeUpdate / afterUpdate with the copyToLocale discriminator', async () => {
|
|
1432
|
+
const beforeUpdate = vi.fn();
|
|
1433
|
+
const afterUpdate = vi.fn();
|
|
1434
|
+
const withHooks = {
|
|
1435
|
+
...mixedCollection,
|
|
1436
|
+
hooks: { beforeUpdate, afterUpdate },
|
|
1437
|
+
};
|
|
1438
|
+
const { mocks } = setupSourceTarget();
|
|
1439
|
+
const ctx = buildCtx(mocks.db, withHooks);
|
|
1440
|
+
await copyToLocale(ctx, {
|
|
1441
|
+
documentId: 'doc-1',
|
|
1442
|
+
sourceLocale: 'en',
|
|
1443
|
+
targetLocale: 'fr',
|
|
1444
|
+
overwrite: false,
|
|
1445
|
+
});
|
|
1446
|
+
expect(beforeUpdate).toHaveBeenCalledOnce();
|
|
1447
|
+
expect(beforeUpdate.mock.calls[0]?.[0].copyToLocale).toEqual({
|
|
1448
|
+
sourceLocale: 'en',
|
|
1449
|
+
targetLocale: 'fr',
|
|
1450
|
+
});
|
|
1451
|
+
expect(afterUpdate).toHaveBeenCalledOnce();
|
|
1452
|
+
expect(afterUpdate.mock.calls[0]?.[0].copyToLocale).toEqual({
|
|
1453
|
+
sourceLocale: 'en',
|
|
1454
|
+
targetLocale: 'fr',
|
|
1455
|
+
});
|
|
1456
|
+
});
|
|
1457
|
+
it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
|
|
1458
|
+
const { mocks } = setupSourceTarget();
|
|
1459
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1460
|
+
const actor = new AdminAuth({
|
|
1461
|
+
id: 'reader',
|
|
1462
|
+
abilities: ['collections.articles.read'],
|
|
1463
|
+
});
|
|
1464
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
1465
|
+
try {
|
|
1466
|
+
await copyToLocale(ctx, {
|
|
1467
|
+
documentId: 'doc-1',
|
|
1468
|
+
sourceLocale: 'en',
|
|
1469
|
+
targetLocale: 'fr',
|
|
1470
|
+
overwrite: false,
|
|
1471
|
+
});
|
|
1472
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
1476
|
+
expect(err.message).toContain('collections.articles.update');
|
|
1477
|
+
}
|
|
1478
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1479
|
+
});
|
|
1480
|
+
it('rejects when requestContext is absent (ERR_UNAUTHENTICATED)', async () => {
|
|
1481
|
+
const { mocks } = setupSourceTarget();
|
|
1482
|
+
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
1483
|
+
ctx.requestContext = undefined;
|
|
1484
|
+
try {
|
|
1485
|
+
await copyToLocale(ctx, {
|
|
1486
|
+
documentId: 'doc-1',
|
|
1487
|
+
sourceLocale: 'en',
|
|
1488
|
+
targetLocale: 'fr',
|
|
1489
|
+
overwrite: false,
|
|
1490
|
+
});
|
|
1491
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
1492
|
+
}
|
|
1493
|
+
catch (err) {
|
|
1494
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
1495
|
+
}
|
|
1496
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
// -----------------------------------------------------------------------
|
|
921
1500
|
// Ability enforcement (Phase 4)
|
|
922
1501
|
// -----------------------------------------------------------------------
|
|
923
1502
|
describe('ability enforcement', () => {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.10.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.2",
|
|
82
|
-
"@byline/auth": "1.
|
|
82
|
+
"@byline/auth": "1.10.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.14",
|