@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, and restore flows.
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. Userland hooks that need to react
330
- * differently (e.g. tag the audit entry, skip search re-index) can branch
331
- * on its presence.
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.9.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.9.1"
82
+ "@byline/auth": "1.10.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.14",