@byline/core 4.10.1 → 4.11.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.
- package/dist/@types/db-types.d.ts +17 -3
- package/dist/services/collection-bootstrap.test.node.js +1 -0
- package/dist/services/discover-counter-groups.test.node.js +1 -0
- package/dist/services/document-lifecycle/context.d.ts +6 -5
- package/dist/services/document-lifecycle/create.js +1 -1
- package/dist/services/document-lifecycle/delete.d.ts +7 -15
- package/dist/services/document-lifecycle/delete.js +15 -79
- package/dist/services/document-lifecycle/duplicate.js +2 -2
- package/dist/services/document-lifecycle/internals.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.js +7 -2
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +26 -7
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/update.js +2 -2
- package/dist/services/document-lifecycle.test.node.js +108 -43
- package/dist/services/field-upload.test.node.js +1 -0
- package/dist/services/populate.test.node.js +1 -0
- package/package.json +2 -2
|
@@ -594,15 +594,29 @@ export interface IDocumentCommands {
|
|
|
594
594
|
excludeVersionId?: string;
|
|
595
595
|
}): Promise<number>;
|
|
596
596
|
/**
|
|
597
|
-
* Soft-delete a document by
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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 = '
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
65
|
-
//
|
|
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:
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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).
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 ?? '',
|
|
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 ?? '',
|
|
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,
|
|
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(
|
|
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
|
|
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('
|
|
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
|
-
|
|
1125
|
-
expect(
|
|
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.
|
|
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).
|
|
1196
|
-
|
|
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
|
-
|
|
1199
|
-
}), '
|
|
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
|
|
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
|
-
|
|
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
|
|
1884
|
+
// Both attempts throw raw path unique violations, exercising both
|
|
1885
|
+
// duplicateDocument conflict-mapping calls.
|
|
1837
1886
|
mocks.createDocumentVersion.mockImplementation(() => {
|
|
1838
|
-
return Promise.reject(
|
|
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
|
});
|
|
@@ -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.
|
|
5
|
+
"version": "4.11.0",
|
|
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.
|
|
85
|
+
"@byline/auth": "4.11.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@biomejs/biome": "2.5.4",
|