@byline/core 4.10.2 → 4.11.1

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.
@@ -594,15 +594,29 @@ export interface IDocumentCommands {
594
594
  excludeVersionId?: string;
595
595
  }): Promise<number>;
596
596
  /**
597
- * Soft-delete a document by setting `is_deleted = true` on ALL of its
598
- * versions. The `current_documents` view automatically filters these out,
599
- * so the document disappears from listings without physically removing data.
597
+ * Soft-delete a document by marking ALL of its path rows inactive and
598
+ * setting `is_deleted = true` on ALL of its versions in one transaction.
599
+ * Live views and path resolution filter the tombstones without physically
600
+ * removing retained content or path values.
600
601
  *
601
602
  * Returns the number of version rows marked as deleted.
602
603
  */
603
604
  softDeleteDocument(params: {
604
605
  document_id: string;
605
606
  }): Promise<number>;
607
+ /**
608
+ * Restore a fully soft-deleted document by reactivating ALL of its path
609
+ * rows and versions in one transaction. Returns `0` when the document is
610
+ * missing, versionless, already live, or in a legacy partially-live state.
611
+ *
612
+ * This storage primitive does not reconstruct tree placement or
613
+ * search/cache projections.
614
+ *
615
+ * Returns the number of version rows restored.
616
+ */
617
+ restoreSoftDeletedDocument(params: {
618
+ document_id: string;
619
+ }): Promise<number>;
606
620
  /**
607
621
  * Remove one content locale's data from a document by writing a new
608
622
  * immutable version that carries forward every store row except the target
@@ -42,6 +42,7 @@ function createMockDb(options) {
42
42
  setDocumentStatus: vi.fn(fail),
43
43
  archivePublishedVersions: vi.fn(fail),
44
44
  softDeleteDocument: vi.fn(fail),
45
+ restoreSoftDeletedDocument: vi.fn(fail),
45
46
  deleteDocumentLocale: vi.fn(fail),
46
47
  setOrderKey: vi.fn(fail),
47
48
  placeTreeNode: vi.fn(fail),
@@ -29,6 +29,7 @@ function makeAdapter(options) {
29
29
  setDocumentStatus: vi.fn(fail),
30
30
  archivePublishedVersions: vi.fn(fail),
31
31
  softDeleteDocument: vi.fn(fail),
32
+ restoreSoftDeletedDocument: vi.fn(fail),
32
33
  deleteDocumentLocale: vi.fn(fail),
33
34
  setOrderKey: vi.fn(fail),
34
35
  placeTreeNode: vi.fn(fail),
@@ -31,15 +31,16 @@ export interface DocumentLifecycleContext {
31
31
  /** The collection `path` string (e.g. `'docs'`, `'news'`). */
32
32
  collectionPath: string;
33
33
  /**
34
- * Storage provider for this collection. Required when the collection
35
- * has any upload-capable image/file field, so that the original files
36
- * and their persisted variants can be cleaned up on document deletion.
34
+ * Storage provider for this collection. Required when the collection has
35
+ * upload-capable image/file fields so lifecycle writes can persist sources,
36
+ * generate variants, and clean up superseded objects.
37
37
  *
38
38
  * Resolved by the route layer as:
39
39
  * `field.upload?.storage ?? serverConfig.storage`
40
40
  *
41
- * Optional callers whose collections have no upload-capable fields
42
- * are unaffected.
41
+ * Whole-document soft delete deliberately retains uploaded objects for
42
+ * restoration. Optional — callers whose collections have no upload-capable
43
+ * fields are unaffected.
43
44
  */
44
45
  storage?: IStorageProvider;
45
46
  /** Structured logger instance. Provided via the DI registry. */
@@ -86,7 +86,7 @@ export async function createDocument(ctx, params) {
86
86
  orderKey,
87
87
  createdBy: actorId(ctx),
88
88
  })
89
- .catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale));
89
+ .catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
90
90
  const documentId = extractDocumentId(result.document);
91
91
  const documentVersionId = extractVersionId(result.document);
92
92
  // `tree: true` collections place every document in the tree by default:
@@ -8,7 +8,7 @@
8
8
  import { ErrorCodes } from '../../lib/errors.js';
9
9
  import type { DocumentLifecycleContext } from './context.js';
10
10
  export type DeleteDocumentOutcome = 'committed' | 'committed-with-side-effect-failures';
11
- export type DeleteDocumentSideEffectPhase = 'storageCleanup' | 'afterTreeChange' | 'afterDelete';
11
+ export type DeleteDocumentSideEffectPhase = 'afterTreeChange' | 'afterDelete';
12
12
  export type DeleteDocumentSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
13
13
  export interface DeleteDocumentSideEffectFailure {
14
14
  phase: DeleteDocumentSideEffectPhase;
@@ -28,24 +28,16 @@ export type DeleteDocumentResult = DeleteDocumentCommittedResult | DeleteDocumen
28
28
  /**
29
29
  * Soft-delete a document.
30
30
  *
31
- * Marks all versions of the document as deleted (`is_deleted = true`). The
32
- * `current_documents` view automatically filters deleted rows, so the
33
- * document disappears from all list / page queries without physically
34
- * removing data.
35
- *
36
- * When the collection has any upload-capable image/file field and
37
- * `ctx.storage` is provided, every original file and persisted variant
38
- * across those fields is also removed from storage after the DB
39
- * soft-delete succeeds. Variant paths are read from the field value's
40
- * `variants` array (no re-derivation from `upload.sizes`), so cleanup
41
- * stays correct even if the size set changed between upload and delete.
42
- * File cleanup failures are logged but are non-fatal.
31
+ * Marks every version and path row for the document as deleted. Live views
32
+ * and path resolution exclude those tombstones, while content, retained path
33
+ * values, uploaded sources, and persisted variants remain available for a
34
+ * later restoration workflow.
43
35
  *
44
36
  * Flow:
45
- * 1. Fetch current document (reconstruct when upload-capable fields exist)
37
+ * 1. Fetch current document metadata, including its original path
46
38
  * 2. `hooks.beforeDelete({ documentId, collectionPath })`
47
39
  * 3. `db.commands.documents.softDeleteDocument({ document_id })`
48
- * 4. Storage file + variant cleanup (skipped when no upload fields, non-fatal)
40
+ * 4. Tree-change hooks, when applicable
49
41
  * 5. `hooks.afterDelete({ documentId, collectionPath })`
50
42
  */
51
43
  export declare function deleteDocument(ctx: DocumentLifecycleContext, params: {
@@ -9,8 +9,6 @@ import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
- import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
13
- import { walkFieldTree } from '../walk-field-tree.js';
14
12
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
15
13
  import { invokeHook } from './internals.js';
16
14
  import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
@@ -35,24 +33,16 @@ function serializeSideEffectFailure(phase, error) {
35
33
  /**
36
34
  * Soft-delete a document.
37
35
  *
38
- * Marks all versions of the document as deleted (`is_deleted = true`). The
39
- * `current_documents` view automatically filters deleted rows, so the
40
- * document disappears from all list / page queries without physically
41
- * removing data.
42
- *
43
- * When the collection has any upload-capable image/file field and
44
- * `ctx.storage` is provided, every original file and persisted variant
45
- * across those fields is also removed from storage after the DB
46
- * soft-delete succeeds. Variant paths are read from the field value's
47
- * `variants` array (no re-derivation from `upload.sizes`), so cleanup
48
- * stays correct even if the size set changed between upload and delete.
49
- * File cleanup failures are logged but are non-fatal.
36
+ * Marks every version and path row for the document as deleted. Live views
37
+ * and path resolution exclude those tombstones, while content, retained path
38
+ * values, uploaded sources, and persisted variants remain available for a
39
+ * later restoration workflow.
50
40
  *
51
41
  * Flow:
52
- * 1. Fetch current document (reconstruct when upload-capable fields exist)
42
+ * 1. Fetch current document metadata, including its original path
53
43
  * 2. `hooks.beforeDelete({ documentId, collectionPath })`
54
44
  * 3. `db.commands.documents.softDeleteDocument({ document_id })`
55
- * 4. Storage file + variant cleanup (skipped when no upload fields, non-fatal)
45
+ * 4. Tree-change hooks, when applicable
56
46
  * 5. `hooks.afterDelete({ documentId, collectionPath })`
57
47
  */
58
48
  export async function deleteDocument(ctx, params) {
@@ -60,17 +50,13 @@ export async function deleteDocument(ctx, params) {
60
50
  const { db, collectionPath, definition, logger } = ctx;
61
51
  assertActorCanPerform(ctx.requestContext, collectionPath, 'delete');
62
52
  const hooks = await resolveHooks(definition);
63
- // 1. Verify the document exists.
64
- // For collections that have any upload-capable image/file field
65
- // AND a storage provider, fetch with reconstruct: true so we
66
- // can read the stored file paths (and persisted variant paths)
67
- // from the field values before the DB rows are deleted.
68
- const storage = ctx.storage;
69
- const isUploadCollection = hasUploadField(definition) && storage != null;
53
+ // 1. Verify the document exists. Soft delete retains field rows and
54
+ // uploaded objects, so only the envelope and original path projection
55
+ // are needed for hooks.
70
56
  const latest = await db.queries.documents.getDocumentById({
71
57
  collection_id: ctx.collectionId,
72
58
  document_id: params.documentId,
73
- reconstruct: isUploadCollection,
59
+ reconstruct: false,
74
60
  });
75
61
  if (latest == null) {
76
62
  throw ERR_NOT_FOUND({
@@ -78,42 +64,12 @@ export async function deleteDocument(ctx, params) {
78
64
  details: { documentId: params.documentId },
79
65
  }).log(ctx.logger);
80
66
  }
81
- // Collect storage paths for every upload-capable field on the doc:
82
- // the original file plus every persisted variant. The schema/data
83
- // walk descends into `group` / `array` / `blocks`, so upload fields
84
- // nested in repeating structures are cleaned up too. Reading the
85
- // variants from the field value (rather than re-deriving from
86
- // `upload.sizes`) keeps cleanup correct even when the size set
87
- // changes between upload and delete.
88
- const storagePathsToDelete = [];
89
- if (isUploadCollection) {
90
- const data = latest?.fields;
91
- for (const leaf of walkFieldTree(definition.fields, data)) {
92
- if (!isUploadField(leaf.field))
93
- continue;
94
- const fieldValue = leaf.value;
95
- if (!fieldValue || typeof fieldValue !== 'object')
96
- continue;
97
- const stored = fieldValue;
98
- if (typeof stored.storagePath === 'string') {
99
- storagePathsToDelete.push(stored.storagePath);
100
- }
101
- if (Array.isArray(stored.variants)) {
102
- for (const variant of stored.variants) {
103
- if (variant && typeof variant.storagePath === 'string') {
104
- storagePathsToDelete.push(variant.storagePath);
105
- }
106
- }
107
- }
108
- }
109
- }
110
67
  const hookCtx = {
111
68
  documentId: params.documentId,
112
69
  collectionPath,
113
- // The current document was fetched above (reconstructed only for
114
- // upload collections, but the envelope carries the locale-resolved
115
- // `path` projection either way). Surface it so delete hooks can purge
116
- // the specific document/URL.
70
+ // The non-reconstructed envelope carries the locale-resolved `path`
71
+ // projection. Capture it before the path row becomes inactive so
72
+ // delete hooks can purge the specific document/URL.
117
73
  path: latest.path ?? '',
118
74
  };
119
75
  // 2. beforeDelete hook.
@@ -124,9 +80,7 @@ export async function deleteDocument(ctx, params) {
124
80
  // back, so soft-deleted documents cannot leak live edges.
125
81
  // whole-document delete mints no new version, so the version stream
126
82
  // never records it — the audit log is the only place a deletion is
127
- // accountable (docs/07-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
128
- // DB↔external side-effect and stays OUTSIDE the transaction — it is
129
- // post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
83
+ // accountable (docs/07-auth-and-security/02-auditability.md).
130
84
  const treeAudit = definition.tree === true ? requireTreeAuditCapability(db) : undefined;
131
85
  const audit = treeAudit ?? requireAuditCapability(db);
132
86
  const actor = auditActor(ctx);
@@ -150,25 +104,7 @@ export async function deleteDocument(ctx, params) {
150
104
  // Everything below is post-commit. Each operation and the logger get an
151
105
  // independent attempt; none can turn the committed delete into a rejection.
152
106
  const sideEffectFailures = [];
153
- // 4. Clean up every storage file. Returned failures omit paths, while
154
- // internal logs retain the target needed for operational reconciliation.
155
- if (storage && storagePathsToDelete.length > 0) {
156
- for (const storagePath of storagePathsToDelete) {
157
- try {
158
- await storage.delete(storagePath);
159
- }
160
- catch (error) {
161
- sideEffectFailures.push(serializeSideEffectFailure('storageCleanup', error));
162
- try {
163
- logger.error({ err: error, documentId: params.documentId, storagePath }, 'failed to delete storage file');
164
- }
165
- catch {
166
- // Diagnostic logging must not interrupt the remaining cleanup attempts.
167
- }
168
- }
169
- }
170
- }
171
- // 5-6. Both post-commit hook families get an independent attempt. A tree
107
+ // 4-5. Both post-commit hook families get an independent attempt. A tree
172
108
  // invalidation failure must not prevent afterDelete consumers (search,
173
109
  // cache removal) from running, or vice versa.
174
110
  try {
@@ -172,7 +172,7 @@ export async function duplicateDocument(ctx, params) {
172
172
  orderKey,
173
173
  createdBy: actorId(ctx),
174
174
  })
175
- .catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale));
175
+ .catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
176
176
  }
177
177
  catch (err) {
178
178
  if (!isPathConflictError(err)) {
@@ -197,7 +197,7 @@ export async function duplicateDocument(ctx, params) {
197
197
  orderKey,
198
198
  createdBy: actorId(ctx),
199
199
  })
200
- .catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale));
200
+ .catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
201
201
  }
202
202
  const newDocumentId = extractDocumentId(result.document);
203
203
  const newDocumentVersionId = extractVersionId(result.document);
@@ -75,7 +75,7 @@ export declare function extractDocumentId(document: any): string;
75
75
  * name substring; it stays targeted to the path constraint so unrelated
76
76
  * unique violations aren't spuriously rebranded as path conflicts.
77
77
  */
78
- export declare function rethrowPathConflict(db: IDbAdapter, err: unknown, path: string, locale: string): never;
78
+ export declare function rethrowPathConflict(db: IDbAdapter, err: unknown, path: string, locale: string, operation: 'create' | 'update' | 'duplicate'): never;
79
79
  /**
80
80
  * Detect whether an error is the `ERR_PATH_CONFLICT` raised by
81
81
  * `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
@@ -142,12 +142,17 @@ export function extractDocumentId(document) {
142
142
  * name substring; it stays targeted to the path constraint so unrelated
143
143
  * unique violations aren't spuriously rebranded as path conflicts.
144
144
  */
145
- export function rethrowPathConflict(db, err, path, locale) {
145
+ export function rethrowPathConflict(db, err, path, locale, operation) {
146
146
  const classification = db.classifyError?.(err);
147
147
  if (classification?.code === DbErrorCodes.UNIQUE_VIOLATION &&
148
148
  classification.constraint?.includes('document_paths_collection_locale_path')) {
149
+ const message = operation === 'create'
150
+ ? `cannot create a document at path "${path}" because a live document already uses it in this collection (locale: ${locale})`
151
+ : operation === 'update'
152
+ ? `cannot update this document to path "${path}" because a live document already uses it in this collection (locale: ${locale})`
153
+ : `cannot duplicate this document to path "${path}" because a live document already uses it in this collection (locale: ${locale})`;
149
154
  throw ERR_PATH_CONFLICT({
150
- message: `path "${path}" is already in use in this collection (locale: ${locale})`,
155
+ message,
151
156
  details: { path, locale, constraint: classification.constraint },
152
157
  });
153
158
  }
@@ -10,32 +10,51 @@ import { ErrorCodes } from '../../lib/errors.js';
10
10
  import { rethrowPathConflict } from './internals.js';
11
11
  const adapterWith = (c) => ({ classifyError: c === undefined ? undefined : () => c });
12
12
  describe('rethrowPathConflict', () => {
13
- it('maps a unique violation on the path constraint to ERR_PATH_CONFLICT', () => {
13
+ it.each([
14
+ [
15
+ 'create',
16
+ 'cannot create a document at path "news/hello" because a live document already uses it in this collection (locale: en)',
17
+ ],
18
+ [
19
+ 'update',
20
+ 'cannot update this document to path "news/hello" because a live document already uses it in this collection (locale: en)',
21
+ ],
22
+ [
23
+ 'duplicate',
24
+ 'cannot duplicate this document to path "news/hello" because a live document already uses it in this collection (locale: en)',
25
+ ],
26
+ ])('maps a path unique violation for %s with live-occupant language', (operation, message) => {
27
+ const constraint = 'byline_document_paths_document_paths_collection_locale_path';
14
28
  const db = adapterWith({
15
29
  code: 'DB_UNIQUE_VIOLATION',
16
- constraint: 'byline_document_paths_document_paths_collection_locale_path',
30
+ constraint,
17
31
  });
18
32
  try {
19
- rethrowPathConflict(db, new Error('raw'), 'news/hello', 'en');
33
+ rethrowPathConflict(db, new Error('raw'), 'news/hello', 'en', operation);
20
34
  throw new Error('should have thrown');
21
35
  }
22
36
  catch (e) {
23
- expect(e.code).toBe(ErrorCodes.PATH_CONFLICT);
37
+ expect(e).toMatchObject({
38
+ code: ErrorCodes.PATH_CONFLICT,
39
+ message,
40
+ details: { path: 'news/hello', locale: 'en', constraint },
41
+ });
42
+ expect(e.details).not.toHaveProperty('documentId');
24
43
  }
25
44
  });
26
45
  it('rethrows raw when the unique violation is on a different constraint', () => {
27
46
  const raw = new Error('raw');
28
47
  const db = adapterWith({ code: 'DB_UNIQUE_VIOLATION', constraint: 'some_other_unique' });
29
- expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
48
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en', 'create')).toThrow(raw);
30
49
  });
31
50
  it('rethrows raw for DB_UNKNOWN', () => {
32
51
  const raw = new Error('raw');
33
52
  const db = adapterWith({ code: 'DB_UNKNOWN' });
34
- expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
53
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en', 'create')).toThrow(raw);
35
54
  });
36
55
  it('rethrows raw when the adapter has no classifyError', () => {
37
56
  const raw = new Error('raw');
38
57
  const db = adapterWith(undefined);
39
- expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
58
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en', 'create')).toThrow(raw);
40
59
  });
41
60
  });
@@ -104,7 +104,7 @@ export async function updateDocumentSystemFields(ctx, params) {
104
104
  locale: sourceLocale,
105
105
  path: pathForCommand,
106
106
  })
107
- .catch((err) => rethrowPathConflict(db, err, pathForCommand, sourceLocale));
107
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand, sourceLocale, 'update'));
108
108
  await audit.append({
109
109
  documentId: params.documentId,
110
110
  collectionId,
@@ -91,7 +91,7 @@ export async function updateDocument(ctx, params) {
91
91
  previousVersionId: originalData.document_version_id,
92
92
  createdBy: actorId(ctx),
93
93
  })
94
- .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
94
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
95
95
  const documentId = extractDocumentId(result.document) || params.documentId;
96
96
  const documentVersionId = extractVersionId(result.document);
97
97
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -210,7 +210,7 @@ export async function updateDocumentWithPatches(ctx, params) {
210
210
  previousVersionId: originalData.document_version_id,
211
211
  createdBy: actorId(ctx),
212
212
  })
213
- .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
213
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
214
214
  const documentId = extractDocumentId(result.document) || params.documentId;
215
215
  const documentVersionId = extractVersionId(result.document);
216
216
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -7,7 +7,7 @@
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, DbErrorCodes, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
10
+ import { BylineError, DbErrorCodes, ErrorCodes } from '../lib/errors.js';
11
11
  import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
@@ -77,6 +77,7 @@ function createMockDb() {
77
77
  setDocumentStatus,
78
78
  archivePublishedVersions,
79
79
  softDeleteDocument,
80
+ restoreSoftDeletedDocument: vi.fn(),
80
81
  deleteDocumentLocale: vi.fn(),
81
82
  setOrderKey: vi.fn(),
82
83
  placeTreeNode: vi.fn(),
@@ -154,6 +155,12 @@ function createMockDb() {
154
155
  withTransaction,
155
156
  };
156
157
  }
158
+ function pathUniqueViolation() {
159
+ return Object.assign(new Error('duplicate key value violates unique constraint'), {
160
+ code: '23505',
161
+ constraint: 'idx_document_paths_collection_locale_path',
162
+ });
163
+ }
157
164
  const noopLogger = {
158
165
  log: vi.fn(),
159
166
  fatal: vi.fn(),
@@ -315,6 +322,20 @@ describe('Document lifecycle service', () => {
315
322
  const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
316
323
  expect(persistedPath).toBe('custom/route');
317
324
  });
325
+ it('describes a create path conflict as ownership by a live document', async () => {
326
+ const { db, createDocumentVersion } = createMockDb();
327
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
328
+ const ctx = buildCtx(db);
329
+ await expect(createDocument(ctx, { data: { title: 'Home' }, path: 'home' })).rejects.toMatchObject({
330
+ code: ErrorCodes.PATH_CONFLICT,
331
+ message: 'cannot create a document at path "home" because a live document already uses it in this collection (locale: en)',
332
+ details: {
333
+ path: 'home',
334
+ locale: 'en',
335
+ constraint: 'idx_document_paths_collection_locale_path',
336
+ },
337
+ });
338
+ });
318
339
  it('falls back to a UUID when no useAsPath and no explicit path', async () => {
319
340
  const { db, createDocumentVersion } = createMockDb();
320
341
  const ctx = buildCtx(db); // minimalCollection has no useAsPath
@@ -632,10 +653,7 @@ describe('Document lifecycle service', () => {
632
653
  // Simulate the pg driver throwing a unique-violation on the path
633
654
  // constraint when the upsert tries to claim a slug owned by another
634
655
  // document in the same (collection, locale).
635
- createDocumentVersion.mockRejectedValueOnce(Object.assign(new Error('duplicate key value violates unique constraint'), {
636
- code: '23505',
637
- constraint: 'idx_document_paths_collection_locale_path',
638
- }));
656
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
639
657
  const ctx = buildCtx(db);
640
658
  try {
641
659
  await updateDocument(ctx, {
@@ -647,7 +665,15 @@ describe('Document lifecycle service', () => {
647
665
  }
648
666
  catch (err) {
649
667
  expect(err).toBeInstanceOf(BylineError);
650
- expect(err.code).toBe(ErrorCodes.PATH_CONFLICT);
668
+ expect(err).toMatchObject({
669
+ code: ErrorCodes.PATH_CONFLICT,
670
+ message: 'cannot update this document to path "home" because a live document already uses it in this collection (locale: en)',
671
+ details: {
672
+ path: 'home',
673
+ locale: 'en',
674
+ constraint: 'idx_document_paths_collection_locale_path',
675
+ },
676
+ });
651
677
  }
652
678
  });
653
679
  it('rethrows non-23505 errors unchanged', async () => {
@@ -749,6 +775,31 @@ describe('Document lifecycle service', () => {
749
775
  documentVersionId: 'ver-1',
750
776
  }));
751
777
  });
778
+ it('describes a patched path conflict as ownership by a live document', async () => {
779
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
780
+ getDocumentById.mockResolvedValue({
781
+ document_version_id: 'ver-current',
782
+ path: 'old-slug',
783
+ source_locale: 'de',
784
+ fields: { title: 'Old' },
785
+ });
786
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
787
+ const ctx = buildCtx(db);
788
+ await expect(updateDocumentWithPatches(ctx, {
789
+ documentId: 'doc-1',
790
+ patches: [],
791
+ path: 'new-slug',
792
+ locale: 'de',
793
+ })).rejects.toMatchObject({
794
+ code: ErrorCodes.PATH_CONFLICT,
795
+ message: 'cannot update this document to path "new-slug" because a live document already uses it in this collection (locale: de)',
796
+ details: {
797
+ path: 'new-slug',
798
+ locale: 'de',
799
+ constraint: 'idx_document_paths_collection_locale_path',
800
+ },
801
+ });
802
+ });
752
803
  it('normalizes patched and hook-produced numeric values before persistence', async () => {
753
804
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
754
805
  getDocumentById.mockResolvedValue({
@@ -1078,7 +1129,7 @@ describe('Document lifecycle service', () => {
1078
1129
  expect(softDeleteDocument).not.toHaveBeenCalled();
1079
1130
  expect(afterDelete).not.toHaveBeenCalled();
1080
1131
  });
1081
- it('cleans up stored files for upload fields at any nesting depth', async () => {
1132
+ it('retains stored files when soft-deleting an upload-capable document', async () => {
1082
1133
  const { db, getDocumentById } = createMockDb();
1083
1134
  const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
1084
1135
  const definition = {
@@ -1121,14 +1172,8 @@ describe('Document lifecycle service', () => {
1121
1172
  storage: { delete: storageDelete },
1122
1173
  };
1123
1174
  await deleteDocument(ctx, { documentId: 'doc-1' });
1124
- // reconstruct: true because the collection is upload-capable
1125
- expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
1126
- expect(storageDelete.mock.calls.map((c) => c[0])).toEqual([
1127
- 'covers/original.jpg',
1128
- 'covers/thumb.avif',
1129
- 'files/a.pdf',
1130
- 'files/b.pdf',
1131
- ]);
1175
+ expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: false }));
1176
+ expect(storageDelete).not.toHaveBeenCalled();
1132
1177
  });
1133
1178
  it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
1134
1179
  const { db, getDocumentById } = createMockDb();
@@ -1172,37 +1217,22 @@ describe('Document lifecycle service', () => {
1172
1217
  logger: { ...noopLogger, error: vi.fn() },
1173
1218
  };
1174
1219
  const result = await deleteDocument(ctx, { documentId: 'doc-1' });
1175
- expect(storageDelete.mock.calls.map((call) => call[0])).toEqual([
1176
- 'private/original.pdf',
1177
- 'private/preview.pdf',
1178
- 'private/thumbnail.pdf',
1179
- ]);
1220
+ expect(storageDelete).not.toHaveBeenCalled();
1180
1221
  expect(result).toEqual({
1181
1222
  deletedVersionCount: 1,
1182
1223
  outcome: 'committed-with-side-effect-failures',
1183
- sideEffectFailures: [
1184
- { phase: 'storageCleanup', code: 'ERR_STORAGE' },
1185
- { phase: 'storageCleanup', code: 'ERR_UNHANDLED' },
1186
- { phase: 'afterDelete', code: 'ERR_UNHANDLED' },
1187
- ],
1224
+ sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
1188
1225
  });
1189
1226
  const serializedResult = JSON.stringify(result);
1190
- expect(serializedResult).not.toContain('storage unavailable');
1191
- expect(serializedResult).not.toContain('cleanup failed');
1192
1227
  expect(serializedResult).not.toContain('hook leaked');
1193
1228
  expect(serializedResult).not.toContain('private/');
1194
1229
  expect(serializedResult).not.toContain('ERR_SEARCH');
1195
- expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({
1196
- err: expect.objectContaining({ message: 'storage unavailable' }),
1230
+ expect(ctx.logger.error).toHaveBeenCalledTimes(2);
1231
+ expect(ctx.logger.error).toHaveBeenNthCalledWith(1, expect.objectContaining({ err: hookError, documentId: 'doc-1' }), 'afterDelete hook failed after document delete');
1232
+ expect(ctx.logger.error).toHaveBeenNthCalledWith(2, expect.objectContaining({
1197
1233
  documentId: 'doc-1',
1198
- storagePath: 'private/original.pdf',
1199
- }), 'failed to delete storage file');
1200
- expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({
1201
- err: expect.objectContaining({ message: 'cleanup failed' }),
1202
- documentId: 'doc-1',
1203
- storagePath: 'private/thumbnail.pdf',
1204
- }), 'failed to delete storage file');
1205
- expect(ctx.logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: hookError, documentId: 'doc-1' }), 'afterDelete hook failed after document delete');
1234
+ sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
1235
+ }), 'post-commit delete side effects failed');
1206
1236
  });
1207
1237
  });
1208
1238
  // -----------------------------------------------------------------------
@@ -1239,6 +1269,24 @@ describe('Document lifecycle service', () => {
1239
1269
  after: 'new-slug',
1240
1270
  }));
1241
1271
  });
1272
+ it('describes a system-field path conflict as ownership by a live document', async () => {
1273
+ const { db, getDocumentSystemFieldsForUpdate } = createMockDb();
1274
+ setupDoc(getDocumentSystemFieldsForUpdate);
1275
+ db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
1276
+ const ctx = buildCtx(db);
1277
+ await expect(updateDocumentSystemFields(ctx, {
1278
+ documentId: 'doc-1',
1279
+ path: 'new-slug',
1280
+ })).rejects.toMatchObject({
1281
+ code: ErrorCodes.PATH_CONFLICT,
1282
+ message: 'cannot update this document to path "new-slug" because a live document already uses it in this collection (locale: en)',
1283
+ details: {
1284
+ path: 'new-slug',
1285
+ locale: 'en',
1286
+ constraint: 'idx_document_paths_collection_locale_path',
1287
+ },
1288
+ });
1289
+ });
1242
1290
  it('does not write, audit, or fire the hook when the path is unchanged', async () => {
1243
1291
  const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1244
1292
  setupDoc(getDocumentSystemFieldsForUpdate);
@@ -1806,13 +1854,13 @@ describe('Document lifecycle service', () => {
1806
1854
  const mocks = createMockDb();
1807
1855
  setupSource(mocks);
1808
1856
  const ctx = buildCtx(mocks.db, localizedCollection);
1809
- // First call: throw a path-conflict error. Second call: succeed.
1857
+ // First call: throw a raw path unique violation. The lifecycle maps it
1858
+ // to ERR_PATH_CONFLICT, then the duplicate retry classifier handles it.
1810
1859
  let attempt = 0;
1811
1860
  mocks.createDocumentVersion.mockImplementation(() => {
1812
1861
  attempt += 1;
1813
1862
  if (attempt === 1) {
1814
- const err = ERR_PATH_CONFLICT({ message: 'path conflict' });
1815
- return Promise.reject(err);
1863
+ return Promise.reject(pathUniqueViolation());
1816
1864
  }
1817
1865
  return Promise.resolve({
1818
1866
  document: { id: 'ver-new', document_id: 'doc-new' },
@@ -1833,11 +1881,28 @@ describe('Document lifecycle service', () => {
1833
1881
  const mocks = createMockDb();
1834
1882
  setupSource(mocks);
1835
1883
  const ctx = buildCtx(mocks.db, localizedCollection);
1836
- // Both attempts throw path-conflict.
1884
+ // Both attempts throw raw path unique violations, exercising both
1885
+ // duplicateDocument conflict-mapping calls.
1837
1886
  mocks.createDocumentVersion.mockImplementation(() => {
1838
- return Promise.reject(ERR_PATH_CONFLICT({ message: 'path conflict' }));
1887
+ return Promise.reject(pathUniqueViolation());
1888
+ });
1889
+ let thrown;
1890
+ try {
1891
+ await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
1892
+ }
1893
+ catch (err) {
1894
+ thrown = err;
1895
+ }
1896
+ const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
1897
+ expect(thrown).toMatchObject({
1898
+ code: ErrorCodes.PATH_CONFLICT,
1899
+ message: `cannot duplicate this document to path "${retryPath}" because a live document already uses it in this collection (locale: en)`,
1900
+ details: {
1901
+ path: retryPath,
1902
+ locale: 'en',
1903
+ constraint: 'idx_document_paths_collection_locale_path',
1904
+ },
1839
1905
  });
1840
- await expect(duplicateDocument(ctx, { sourceDocumentId: 'doc-source' })).rejects.toMatchObject({ code: ErrorCodes.PATH_CONFLICT });
1841
1906
  // Bounded to exactly two attempts.
1842
1907
  expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
1843
1908
  });
@@ -59,6 +59,7 @@ function createMockDb() {
59
59
  setDocumentStatus: vi.fn(),
60
60
  archivePublishedVersions: vi.fn(),
61
61
  softDeleteDocument: vi.fn(),
62
+ restoreSoftDeletedDocument: vi.fn(),
62
63
  deleteDocumentLocale: vi.fn(),
63
64
  setOrderKey: vi.fn(),
64
65
  placeTreeNode: vi.fn(),
@@ -147,6 +147,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
147
147
  setDocumentStatus: vi.fn(),
148
148
  archivePublishedVersions: vi.fn(),
149
149
  softDeleteDocument: vi.fn(),
150
+ restoreSoftDeletedDocument: vi.fn(),
150
151
  deleteDocumentLocale: vi.fn(),
151
152
  setOrderKey: vi.fn(),
152
153
  placeTreeNode: vi.fn(),
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": "4.10.2",
5
+ "version": "4.11.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -82,7 +82,7 @@
82
82
  "sharp": "^0.35.3",
83
83
  "uuid": "^14.0.1",
84
84
  "zod": "^4.4.3",
85
- "@byline/auth": "4.10.2"
85
+ "@byline/auth": "4.11.1"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@biomejs/biome": "2.5.4",