@byline/core 4.17.0 → 4.19.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 +6 -3
- package/dist/@types/search-types.d.ts +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/errors.d.ts +10 -0
- package/dist/lib/errors.js +10 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +24 -0
- package/dist/services/document-lifecycle/committed-hook.js +79 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +3 -2
- package/dist/services/document-lifecycle/create.js +3 -2
- package/dist/services/document-lifecycle/delete-locale.js +7 -2
- package/dist/services/document-lifecycle/duplicate.js +7 -2
- package/dist/services/document-lifecycle/index.d.ts +2 -0
- package/dist/services/document-lifecycle/index.js +1 -0
- package/dist/services/document-lifecycle/restore.js +3 -2
- package/dist/services/document-lifecycle/update.js +5 -4
- package/dist/services/document-lifecycle.test.node.js +158 -0
- package/dist/services/singleton-lifecycle/internals.js +6 -1
- package/dist/services/singleton-lifecycle.test.node.js +10 -2
- package/dist/storage/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/package.json +2 -2
|
@@ -786,9 +786,12 @@ export interface IDocumentCommands {
|
|
|
786
786
|
createdBy?: string;
|
|
787
787
|
/**
|
|
788
788
|
* When updating an existing document, the version ID of the version being
|
|
789
|
-
* replaced.
|
|
790
|
-
*
|
|
791
|
-
*
|
|
789
|
+
* replaced. The adapter verifies this ID against the current live version
|
|
790
|
+
* while holding the document-row lock and throws `ERR_CONFLICT` when it is
|
|
791
|
+
* stale. A locale-specific write to an existing versioned document must
|
|
792
|
+
* provide this value so field rows for every other locale can be copied
|
|
793
|
+
* forward safely. `locale: 'all'` writes replace the complete locale tree
|
|
794
|
+
* and may omit it; when supplied, it must still be current.
|
|
792
795
|
*/
|
|
793
796
|
previousVersionId?: string;
|
|
794
797
|
/**
|
|
@@ -175,6 +175,13 @@ export declare const MAX_SEARCH_QUERY_LENGTH = 1024;
|
|
|
175
175
|
export interface SearchQuery {
|
|
176
176
|
/** The free-text query string, limited to {@link MAX_SEARCH_QUERY_LENGTH}. */
|
|
177
177
|
query: string;
|
|
178
|
+
/**
|
|
179
|
+
* Restrict full-text matching to these named `body` fields. Field names are
|
|
180
|
+
* the schema paths carried by {@link SearchField.name}; providers that do
|
|
181
|
+
* not advertise `capabilities.fullText.fieldScope` must reject the option
|
|
182
|
+
* rather than silently searching the complete body.
|
|
183
|
+
*/
|
|
184
|
+
fieldScope?: string[];
|
|
178
185
|
/**
|
|
179
186
|
* Explicit full-text matching semantics. Defaults to all concepts required
|
|
180
187
|
* with quoted spans treated as phrases.
|
|
@@ -287,6 +294,11 @@ export interface SearchCapabilities {
|
|
|
287
294
|
minimumShouldMatch: boolean;
|
|
288
295
|
/** Supports ordered phrase constraints. */
|
|
289
296
|
phrase: boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Supports restricting full-text matching to named `body` fields.
|
|
299
|
+
* Omission is treated as `false` for compatibility with existing providers.
|
|
300
|
+
*/
|
|
301
|
+
fieldScope?: boolean;
|
|
290
302
|
};
|
|
291
303
|
}
|
|
292
304
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
|
8
8
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
9
9
|
export * from './defaults/default-values.js';
|
|
10
10
|
export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
11
|
-
export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
11
|
+
export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
12
12
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
13
13
|
export { type BylineLogger, getLogger } from './lib/logger.js';
|
|
14
14
|
export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
|
20
20
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
21
21
|
export * from './defaults/default-values.js';
|
|
22
22
|
export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
23
|
-
export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
23
|
+
export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
24
24
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
25
25
|
export { getLogger } from './lib/logger.js';
|
|
26
26
|
export { AsyncRegistry, Registry } from './lib/registry.js';
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -88,12 +88,15 @@ export declare const ErrorCodes: {
|
|
|
88
88
|
readonly READ_RECURSION: 'ERR_READ_RECURSION';
|
|
89
89
|
readonly PATH_CONFLICT: 'ERR_PATH_CONFLICT';
|
|
90
90
|
readonly AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED';
|
|
91
|
+
readonly DOCUMENT_HOOK_COMMITTED: 'ERR_DOCUMENT_HOOK_COMMITTED';
|
|
91
92
|
readonly TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED';
|
|
92
93
|
};
|
|
93
94
|
/** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
|
|
94
95
|
export declare const TREE_PLACEMENT_STALE_MARKER = "[ERR_CONFLICT:TREE_PLACEMENT_STALE]";
|
|
95
96
|
/** Stable message fallback for `ERR_TREE_HOOK_COMMITTED` errors. */
|
|
96
97
|
export declare const TREE_HOOK_COMMITTED_MARKER = "[ERR_TREE_HOOK_COMMITTED]";
|
|
98
|
+
/** Stable message fallback for `ERR_DOCUMENT_HOOK_COMMITTED` errors. */
|
|
99
|
+
export declare const DOCUMENT_HOOK_COMMITTED_MARKER = "[ERR_DOCUMENT_HOOK_COMMITTED]";
|
|
97
100
|
export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
98
101
|
export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
99
102
|
export declare const ERR_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
@@ -121,6 +124,13 @@ export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstruc
|
|
|
121
124
|
* docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
|
|
122
125
|
*/
|
|
123
126
|
export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
127
|
+
/**
|
|
128
|
+
* A document version committed, but its post-commit `afterCreate` or
|
|
129
|
+
* `afterUpdate` hook failed. Callers still receive a rejection so existing
|
|
130
|
+
* SDK error handling keeps working, while hosts can distinguish this from a
|
|
131
|
+
* rolled-back write and reconcile their UI without replaying the mutation.
|
|
132
|
+
*/
|
|
133
|
+
export declare const ERR_DOCUMENT_HOOK_COMMITTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
124
134
|
/**
|
|
125
135
|
* A tree mutation and its audit row committed, but its post-commit
|
|
126
136
|
* `afterTreeChange` work failed. Callers still receive a rejection so they can
|
package/dist/lib/errors.js
CHANGED
|
@@ -122,12 +122,15 @@ export const ErrorCodes = {
|
|
|
122
122
|
READ_RECURSION: 'ERR_READ_RECURSION',
|
|
123
123
|
PATH_CONFLICT: 'ERR_PATH_CONFLICT',
|
|
124
124
|
AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED',
|
|
125
|
+
DOCUMENT_HOOK_COMMITTED: 'ERR_DOCUMENT_HOOK_COMMITTED',
|
|
125
126
|
TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED',
|
|
126
127
|
};
|
|
127
128
|
/** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
|
|
128
129
|
export const TREE_PLACEMENT_STALE_MARKER = '[ERR_CONFLICT:TREE_PLACEMENT_STALE]';
|
|
129
130
|
/** Stable message fallback for `ERR_TREE_HOOK_COMMITTED` errors. */
|
|
130
131
|
export const TREE_HOOK_COMMITTED_MARKER = '[ERR_TREE_HOOK_COMMITTED]';
|
|
132
|
+
/** Stable message fallback for `ERR_DOCUMENT_HOOK_COMMITTED` errors. */
|
|
133
|
+
export const DOCUMENT_HOOK_COMMITTED_MARKER = '[ERR_DOCUMENT_HOOK_COMMITTED]';
|
|
131
134
|
// ---------------------------------------------------------------------------
|
|
132
135
|
// Pre-instantiated factories
|
|
133
136
|
// ---------------------------------------------------------------------------
|
|
@@ -158,6 +161,13 @@ export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn
|
|
|
158
161
|
* docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
|
|
159
162
|
*/
|
|
160
163
|
export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
|
|
164
|
+
/**
|
|
165
|
+
* A document version committed, but its post-commit `afterCreate` or
|
|
166
|
+
* `afterUpdate` hook failed. Callers still receive a rejection so existing
|
|
167
|
+
* SDK error handling keeps working, while hosts can distinguish this from a
|
|
168
|
+
* rolled-back write and reconcile their UI without replaying the mutation.
|
|
169
|
+
*/
|
|
170
|
+
export const ERR_DOCUMENT_HOOK_COMMITTED = createErrorType(ErrorCodes.DOCUMENT_HOOK_COMMITTED, 'warn');
|
|
161
171
|
/**
|
|
162
172
|
* A tree mutation and its audit row committed, but its post-commit
|
|
163
173
|
* `afterTreeChange` work failed. Callers still receive a rejection so they can
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { ErrorCodes } from '../../lib/errors.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
export type DocumentHookCommittedPhase = 'afterCreate' | 'afterUpdate' | 'afterSave';
|
|
11
|
+
export type DocumentHookSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
|
|
12
|
+
export interface DocumentHookCommittedDetails {
|
|
13
|
+
phase: DocumentHookCommittedPhase;
|
|
14
|
+
documentId: string;
|
|
15
|
+
documentVersionId: string;
|
|
16
|
+
sideEffectCode: DocumentHookSideEffectCode;
|
|
17
|
+
}
|
|
18
|
+
/** Read and validate the public, non-sensitive metadata from a committed hook failure. */
|
|
19
|
+
export declare function getDocumentHookCommittedDetails(error: unknown): DocumentHookCommittedDetails | null;
|
|
20
|
+
/**
|
|
21
|
+
* Run a hook after its document version has committed, preserving rejection
|
|
22
|
+
* semantics while making the committed state machine-readable to hosts.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runCommittedDocumentHook(ctx: DocumentLifecycleContext, details: Omit<DocumentHookCommittedDetails, 'sideEffectCode'>, callback: () => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { DOCUMENT_HOOK_COMMITTED_MARKER, ERR_DOCUMENT_HOOK_COMMITTED, ErrorCodes, } from '../../lib/errors.js';
|
|
9
|
+
function readStringProperty(value, key) {
|
|
10
|
+
try {
|
|
11
|
+
if ((typeof value !== 'object' || value === null) && typeof value !== 'function') {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const property = Reflect.get(value, key);
|
|
15
|
+
return typeof property === 'string' ? property : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sideEffectCode(error) {
|
|
22
|
+
return readStringProperty(error, 'code') === ErrorCodes.STORAGE
|
|
23
|
+
? ErrorCodes.STORAGE
|
|
24
|
+
: ErrorCodes.UNHANDLED;
|
|
25
|
+
}
|
|
26
|
+
/** Read and validate the public, non-sensitive metadata from a committed hook failure. */
|
|
27
|
+
export function getDocumentHookCommittedDetails(error) {
|
|
28
|
+
if (readStringProperty(error, 'code') !== ErrorCodes.DOCUMENT_HOOK_COMMITTED)
|
|
29
|
+
return null;
|
|
30
|
+
let details;
|
|
31
|
+
try {
|
|
32
|
+
details = Reflect.get(error, 'details');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (typeof details !== 'object' || details === null)
|
|
38
|
+
return null;
|
|
39
|
+
const phase = readStringProperty(details, 'phase');
|
|
40
|
+
const documentId = readStringProperty(details, 'documentId');
|
|
41
|
+
const documentVersionId = readStringProperty(details, 'documentVersionId');
|
|
42
|
+
const failureCode = readStringProperty(details, 'sideEffectCode');
|
|
43
|
+
if (phase !== 'afterCreate' && phase !== 'afterUpdate' && phase !== 'afterSave')
|
|
44
|
+
return null;
|
|
45
|
+
if (!documentId || !documentVersionId)
|
|
46
|
+
return null;
|
|
47
|
+
if (failureCode !== ErrorCodes.STORAGE && failureCode !== ErrorCodes.UNHANDLED)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
phase,
|
|
51
|
+
documentId,
|
|
52
|
+
documentVersionId,
|
|
53
|
+
sideEffectCode: failureCode,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Run a hook after its document version has committed, preserving rejection
|
|
58
|
+
* semantics while making the committed state machine-readable to hosts.
|
|
59
|
+
*/
|
|
60
|
+
export async function runCommittedDocumentHook(ctx, details, callback) {
|
|
61
|
+
try {
|
|
62
|
+
await callback();
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
const committedError = ERR_DOCUMENT_HOOK_COMMITTED({
|
|
66
|
+
message: `${DOCUMENT_HOOK_COMMITTED_MARKER} document write committed but ${details.phase} failed`,
|
|
67
|
+
cause,
|
|
68
|
+
captureStack: true,
|
|
69
|
+
details: { ...details, sideEffectCode: sideEffectCode(cause) },
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
committedError.log(ctx.logger);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Diagnostic logging cannot erase the machine-readable committed result.
|
|
76
|
+
}
|
|
77
|
+
throw committedError;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
14
15
|
import { mergeLocaleData } from './merge-locale-data.js';
|
|
15
16
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
@@ -133,7 +134,7 @@ export async function copyToLocale(ctx, params) {
|
|
|
133
134
|
previousVersionId,
|
|
134
135
|
});
|
|
135
136
|
const documentVersionId = extractVersionId(writeResult.document);
|
|
136
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
137
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId: params.documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
|
|
137
138
|
data: merged.data,
|
|
138
139
|
originalData: targetFields,
|
|
139
140
|
collectionPath,
|
|
@@ -143,7 +144,7 @@ export async function copyToLocale(ctx, params) {
|
|
|
143
144
|
// touches it. Read it off the target envelope.
|
|
144
145
|
path: targetRecord.path ?? '',
|
|
145
146
|
copyToLocale: copyToLocaleMarker,
|
|
146
|
-
});
|
|
147
|
+
}));
|
|
147
148
|
return {
|
|
148
149
|
documentId: params.documentId,
|
|
149
150
|
documentVersionId,
|
|
@@ -15,6 +15,7 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
17
|
import { requireTreeAuditCapability } from './audit.js';
|
|
18
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
18
19
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
19
20
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
20
21
|
import { appendTreeRoot } from './tree.js';
|
|
@@ -101,13 +102,13 @@ export async function createDocument(ctx, params) {
|
|
|
101
102
|
ctx.logger.error({ err, documentId }, 'failed to auto-place new document in tree');
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
|
-
await invokeHook(hooks?.afterCreate, {
|
|
105
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterCreate', documentId, documentVersionId }, () => invokeHook(hooks?.afterCreate, {
|
|
105
106
|
data,
|
|
106
107
|
collectionPath,
|
|
107
108
|
documentId,
|
|
108
109
|
documentVersionId,
|
|
109
110
|
path: resolvedPath,
|
|
110
|
-
});
|
|
111
|
+
}));
|
|
111
112
|
return { documentId, documentVersionId };
|
|
112
113
|
});
|
|
113
114
|
}
|
|
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { actorId, invokeHook } from './internals.js';
|
|
14
15
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
15
16
|
/**
|
|
@@ -106,7 +107,11 @@ export async function deleteLocale(ctx, params) {
|
|
|
106
107
|
return deleted;
|
|
107
108
|
},
|
|
108
109
|
});
|
|
109
|
-
await
|
|
110
|
+
await runCommittedDocumentHook(ctx, {
|
|
111
|
+
phase: 'afterUpdate',
|
|
112
|
+
documentId: params.documentId,
|
|
113
|
+
documentVersionId: result.newVersionId,
|
|
114
|
+
}, () => invokeHook(hooks?.afterUpdate, {
|
|
110
115
|
data: originalData,
|
|
111
116
|
originalData,
|
|
112
117
|
collectionPath,
|
|
@@ -116,7 +121,7 @@ export async function deleteLocale(ctx, params) {
|
|
|
116
121
|
// never touches it. Read it off the target envelope.
|
|
117
122
|
path: targetRecord.path ?? '',
|
|
118
123
|
deleteLocale: deleteLocaleMarker,
|
|
119
|
-
});
|
|
124
|
+
}));
|
|
120
125
|
return {
|
|
121
126
|
documentId: params.documentId,
|
|
122
127
|
documentVersionId: result.newVersionId,
|
|
@@ -12,6 +12,7 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { slugify } from '../../utils/slugify.js';
|
|
13
13
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
14
14
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
15
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
15
16
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
16
17
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
17
18
|
/**
|
|
@@ -191,14 +192,18 @@ export async function duplicateDocument(ctx, params) {
|
|
|
191
192
|
const newDocumentId = extractDocumentId(result.document);
|
|
192
193
|
const newDocumentVersionId = extractVersionId(result.document);
|
|
193
194
|
// 8. afterCreate hook with duplicate marker.
|
|
194
|
-
await
|
|
195
|
+
await runCommittedDocumentHook(ctx, {
|
|
196
|
+
phase: 'afterCreate',
|
|
197
|
+
documentId: newDocumentId,
|
|
198
|
+
documentVersionId: newDocumentVersionId,
|
|
199
|
+
}, () => invokeHook(hooks?.afterCreate, {
|
|
195
200
|
data: clonedFields,
|
|
196
201
|
collectionPath,
|
|
197
202
|
documentId: newDocumentId,
|
|
198
203
|
documentVersionId: newDocumentVersionId,
|
|
199
204
|
path: finalPath,
|
|
200
205
|
duplicate: duplicateMarker,
|
|
201
|
-
});
|
|
206
|
+
}));
|
|
202
207
|
return {
|
|
203
208
|
documentId: newDocumentId,
|
|
204
209
|
documentVersionId: newDocumentVersionId,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
23
|
* re-exported here — the public surface is exactly this barrel).
|
|
24
24
|
*/
|
|
25
|
+
export { getDocumentHookCommittedDetails } from './committed-hook.js';
|
|
25
26
|
export { copyToLocale } from './copy-to-locale.js';
|
|
26
27
|
export { createDocument } from './create.js';
|
|
27
28
|
export { deleteDocument } from './delete.js';
|
|
@@ -33,6 +34,7 @@ export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
|
33
34
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
34
35
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
35
36
|
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
37
|
+
export type { DocumentHookCommittedDetails, DocumentHookCommittedPhase, DocumentHookSideEffectCode, } from './committed-hook.js';
|
|
36
38
|
export type { DocumentLifecycleContext } from './context.js';
|
|
37
39
|
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
38
40
|
export type { CreateDocumentResult } from './create.js';
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
23
|
* re-exported here — the public surface is exactly this barrel).
|
|
24
24
|
*/
|
|
25
|
+
export { getDocumentHookCommittedDetails } from './committed-hook.js';
|
|
25
26
|
export { copyToLocale } from './copy-to-locale.js';
|
|
26
27
|
export { createDocument } from './create.js';
|
|
27
28
|
export { deleteDocument } from './delete.js';
|
|
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
14
15
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
15
16
|
/**
|
|
@@ -142,7 +143,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
142
143
|
const documentVersionId = extractVersionId(result.document);
|
|
143
144
|
// 7. afterUpdate. Restore is path-sticky: the canonical path comes
|
|
144
145
|
// from the current version's envelope (originalData), not the source.
|
|
145
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
146
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
|
|
146
147
|
data: sourceFields,
|
|
147
148
|
originalData,
|
|
148
149
|
collectionPath,
|
|
@@ -150,7 +151,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
150
151
|
documentVersionId,
|
|
151
152
|
path: originalData.path ?? '',
|
|
152
153
|
restore: restoreContext,
|
|
153
|
-
});
|
|
154
|
+
}));
|
|
154
155
|
return {
|
|
155
156
|
documentId,
|
|
156
157
|
documentVersionId,
|
|
@@ -14,6 +14,7 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
17
18
|
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
18
19
|
import { persistExistingDocumentVersion } from './persistence.js';
|
|
19
20
|
import { selfHealTreePlacement } from './tree.js';
|
|
@@ -92,14 +93,14 @@ export async function updateDocument(ctx, params) {
|
|
|
92
93
|
// Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
|
|
93
94
|
// save re-trees a stray (system step, best-effort, no-op when placed).
|
|
94
95
|
await selfHealTreePlacement(ctx, documentId);
|
|
95
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
96
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
|
|
96
97
|
data,
|
|
97
98
|
originalData,
|
|
98
99
|
collectionPath,
|
|
99
100
|
documentId,
|
|
100
101
|
documentVersionId,
|
|
101
102
|
path: pathForCommand ?? originalData.path,
|
|
102
|
-
});
|
|
103
|
+
}));
|
|
103
104
|
return { documentId, documentVersionId };
|
|
104
105
|
});
|
|
105
106
|
}
|
|
@@ -206,14 +207,14 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
206
207
|
// save re-trees a stray (system step, best-effort, no-op when placed).
|
|
207
208
|
await selfHealTreePlacement(ctx, documentId);
|
|
208
209
|
// 7. afterUpdate hook.
|
|
209
|
-
await invokeHook(hooks?.afterUpdate, {
|
|
210
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
|
|
210
211
|
data: nextData,
|
|
211
212
|
originalData,
|
|
212
213
|
collectionPath,
|
|
213
214
|
documentId,
|
|
214
215
|
documentVersionId,
|
|
215
216
|
path: pathForCommand ?? originalData.path,
|
|
216
|
-
});
|
|
217
|
+
}));
|
|
217
218
|
return { documentId, documentVersionId };
|
|
218
219
|
});
|
|
219
220
|
}
|
|
@@ -335,6 +335,36 @@ describe('Document lifecycle service', () => {
|
|
|
335
335
|
documentVersionId: 'ver-1',
|
|
336
336
|
}));
|
|
337
337
|
});
|
|
338
|
+
it('classifies an afterCreate failure as post-commit and retains committed ids', async () => {
|
|
339
|
+
const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
|
|
340
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
341
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
342
|
+
await expect(createDocument(buildCtx(db, definition), { data: { title: 'X' } })).rejects.toMatchObject({
|
|
343
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
344
|
+
details: {
|
|
345
|
+
phase: 'afterCreate',
|
|
346
|
+
documentId: 'doc-1',
|
|
347
|
+
documentVersionId: 'ver-1',
|
|
348
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
352
|
+
});
|
|
353
|
+
it('preserves committed classification when diagnostic logging also fails', async () => {
|
|
354
|
+
const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
|
|
355
|
+
const { db } = createMockDb();
|
|
356
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
357
|
+
const ctx = buildCtx(db, definition);
|
|
358
|
+
ctx.logger = {
|
|
359
|
+
...noopLogger,
|
|
360
|
+
log: vi.fn(() => {
|
|
361
|
+
throw new Error('logger failed');
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
await expect(createDocument(ctx, { data: { title: 'X' } })).rejects.toMatchObject({
|
|
365
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
366
|
+
});
|
|
367
|
+
});
|
|
338
368
|
it('afterCreate receives the resolved canonical path', async () => {
|
|
339
369
|
const afterCreate = vi.fn();
|
|
340
370
|
const { db } = createMockDb();
|
|
@@ -591,6 +621,25 @@ describe('Document lifecycle service', () => {
|
|
|
591
621
|
documentVersionId: 'ver-1',
|
|
592
622
|
}));
|
|
593
623
|
});
|
|
624
|
+
it('classifies an afterUpdate failure as post-commit and retains committed ids', async () => {
|
|
625
|
+
const afterUpdate = vi.fn().mockRejectedValue(new Error('search unavailable'));
|
|
626
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
627
|
+
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
628
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
629
|
+
await expect(updateDocument(buildCtx(db, definition), {
|
|
630
|
+
documentId: 'doc-1',
|
|
631
|
+
data: { title: 'New' },
|
|
632
|
+
})).rejects.toMatchObject({
|
|
633
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
634
|
+
details: {
|
|
635
|
+
phase: 'afterUpdate',
|
|
636
|
+
documentId: 'doc-1',
|
|
637
|
+
documentVersionId: 'ver-1',
|
|
638
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
642
|
+
});
|
|
594
643
|
it('afterUpdate carries the sticky path forward when none is supplied', async () => {
|
|
595
644
|
const afterUpdate = vi.fn();
|
|
596
645
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -832,6 +881,31 @@ describe('Document lifecycle service', () => {
|
|
|
832
881
|
patches: [],
|
|
833
882
|
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
834
883
|
});
|
|
884
|
+
it('classifies a post-patch afterUpdate failure without replaying the version write', async () => {
|
|
885
|
+
const afterUpdate = vi
|
|
886
|
+
.fn()
|
|
887
|
+
.mockRejectedValue(Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE }));
|
|
888
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
889
|
+
getDocumentById.mockResolvedValue({
|
|
890
|
+
document_version_id: 'previous-version',
|
|
891
|
+
fields: { title: 'Old' },
|
|
892
|
+
});
|
|
893
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
894
|
+
await expect(updateDocumentWithPatches(buildCtx(db, definition), {
|
|
895
|
+
documentId: 'doc-1',
|
|
896
|
+
documentVersionId: 'previous-version',
|
|
897
|
+
patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
|
|
898
|
+
})).rejects.toMatchObject({
|
|
899
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
900
|
+
details: {
|
|
901
|
+
phase: 'afterUpdate',
|
|
902
|
+
documentId: 'doc-1',
|
|
903
|
+
documentVersionId: 'ver-1',
|
|
904
|
+
sideEffectCode: ErrorCodes.STORAGE,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
908
|
+
});
|
|
835
909
|
it('throws ERR_CONFLICT on version mismatch', async () => {
|
|
836
910
|
const { db, getDocumentById } = createMockDb();
|
|
837
911
|
getDocumentById.mockResolvedValue({
|
|
@@ -1069,6 +1143,30 @@ describe('Document lifecycle service', () => {
|
|
|
1069
1143
|
});
|
|
1070
1144
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
|
|
1071
1145
|
});
|
|
1146
|
+
it('classifies a delete-locale afterUpdate failure after the new version commits', async () => {
|
|
1147
|
+
const { db, deleteDocumentLocale, getDocumentById } = createMockDb();
|
|
1148
|
+
getDocumentById.mockResolvedValue({
|
|
1149
|
+
document_version_id: 'ver-1',
|
|
1150
|
+
document_id: 'doc-1',
|
|
1151
|
+
path: 'current-path',
|
|
1152
|
+
_availableVersionLocales: ['en', 'fr'],
|
|
1153
|
+
fields: { title: 'Bonjour' },
|
|
1154
|
+
});
|
|
1155
|
+
const definition = {
|
|
1156
|
+
...minimalCollection,
|
|
1157
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
1158
|
+
};
|
|
1159
|
+
await expect(deleteLocale(buildCtx(db, definition), { documentId: 'doc-1', locale: 'fr' })).rejects.toMatchObject({
|
|
1160
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
1161
|
+
details: {
|
|
1162
|
+
phase: 'afterUpdate',
|
|
1163
|
+
documentId: 'doc-1',
|
|
1164
|
+
documentVersionId: 'ver-2',
|
|
1165
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
expect(deleteDocumentLocale).toHaveBeenCalledOnce();
|
|
1169
|
+
});
|
|
1072
1170
|
it('cancels an active schedule as an effect of an ordinary status transition', async () => {
|
|
1073
1171
|
const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
|
|
1074
1172
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
@@ -1947,6 +2045,26 @@ describe('Document lifecycle service', () => {
|
|
|
1947
2045
|
restore: { sourceVersionId },
|
|
1948
2046
|
}));
|
|
1949
2047
|
});
|
|
2048
|
+
it('classifies a restore afterUpdate failure after the restored version commits', async () => {
|
|
2049
|
+
const { db, sourceVersionId, createDocumentVersion } = setupRestore();
|
|
2050
|
+
const definition = {
|
|
2051
|
+
...minimalCollection,
|
|
2052
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2053
|
+
};
|
|
2054
|
+
await expect(restoreDocumentVersion(buildCtx(db, definition), {
|
|
2055
|
+
documentId: 'doc-1',
|
|
2056
|
+
sourceVersionId,
|
|
2057
|
+
})).rejects.toMatchObject({
|
|
2058
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2059
|
+
details: {
|
|
2060
|
+
phase: 'afterUpdate',
|
|
2061
|
+
documentId: 'doc-1',
|
|
2062
|
+
documentVersionId: 'ver-restored',
|
|
2063
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
2064
|
+
},
|
|
2065
|
+
});
|
|
2066
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2067
|
+
});
|
|
1950
2068
|
it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
|
|
1951
2069
|
const { db, sourceVersionId } = setupRestore();
|
|
1952
2070
|
const ctx = buildCtx(db);
|
|
@@ -2246,6 +2364,24 @@ describe('Document lifecycle service', () => {
|
|
|
2246
2364
|
expect(typeof afterCtx.path).toBe('string');
|
|
2247
2365
|
expect(afterCtx.path).toBe(writtenPath);
|
|
2248
2366
|
});
|
|
2367
|
+
it('classifies a duplicate afterCreate failure with the committed document ids', async () => {
|
|
2368
|
+
const withHooks = {
|
|
2369
|
+
...localizedCollection,
|
|
2370
|
+
hooks: { afterCreate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2371
|
+
};
|
|
2372
|
+
const mocks = createMockDb();
|
|
2373
|
+
setupSource(mocks);
|
|
2374
|
+
await expect(duplicateDocument(buildCtx(mocks.db, withHooks), { sourceDocumentId: 'doc-source' })).rejects.toMatchObject({
|
|
2375
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2376
|
+
details: {
|
|
2377
|
+
phase: 'afterCreate',
|
|
2378
|
+
documentId: 'doc-new',
|
|
2379
|
+
documentVersionId: 'ver-new',
|
|
2380
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
2381
|
+
},
|
|
2382
|
+
});
|
|
2383
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
2384
|
+
});
|
|
2249
2385
|
it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
|
|
2250
2386
|
const mocks = createMockDb();
|
|
2251
2387
|
setupSource(mocks);
|
|
@@ -2537,6 +2673,28 @@ describe('Document lifecycle service', () => {
|
|
|
2537
2673
|
// Sticky path read off the target-locale envelope.
|
|
2538
2674
|
expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
|
|
2539
2675
|
});
|
|
2676
|
+
it('classifies a copy-to-locale afterUpdate failure after the version commits', async () => {
|
|
2677
|
+
const withHooks = {
|
|
2678
|
+
...mixedCollection,
|
|
2679
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2680
|
+
};
|
|
2681
|
+
const { mocks } = setupSourceTarget();
|
|
2682
|
+
await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
|
|
2683
|
+
documentId: 'doc-1',
|
|
2684
|
+
sourceLocale: 'en',
|
|
2685
|
+
targetLocale: 'fr',
|
|
2686
|
+
overwrite: false,
|
|
2687
|
+
})).rejects.toMatchObject({
|
|
2688
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2689
|
+
details: {
|
|
2690
|
+
phase: 'afterUpdate',
|
|
2691
|
+
documentId: 'doc-1',
|
|
2692
|
+
documentVersionId: 'ver-new',
|
|
2693
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
2694
|
+
},
|
|
2695
|
+
});
|
|
2696
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
2697
|
+
});
|
|
2540
2698
|
it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
|
|
2541
2699
|
const { mocks } = setupSourceTarget();
|
|
2542
2700
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { resolveHooks } from '../../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { DbErrorCodes, ERR_CONFLICT, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
|
+
import { runCommittedDocumentHook } from '../document-lifecycle/committed-hook.js';
|
|
11
12
|
import { extractDocumentId, extractVersionId } from '../document-lifecycle/internals.js';
|
|
12
13
|
/**
|
|
13
14
|
* Assert the kind-aware singleton update ability without touching storage.
|
|
@@ -104,7 +105,11 @@ export async function commitSingletonSave(params) {
|
|
|
104
105
|
},
|
|
105
106
|
};
|
|
106
107
|
});
|
|
107
|
-
await
|
|
108
|
+
await runCommittedDocumentHook(ctx, {
|
|
109
|
+
phase: 'afterSave',
|
|
110
|
+
documentId: committed.documentId,
|
|
111
|
+
documentVersionId: committed.documentVersionId,
|
|
112
|
+
}, () => invokeSingletonHook(hooks?.afterSave, committed.afterSaveContext));
|
|
108
113
|
return {
|
|
109
114
|
documentId: committed.documentId,
|
|
110
115
|
documentVersionId: committed.documentVersionId,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AdminAuth, createRequestContext } from '@byline/auth';
|
|
9
9
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import { DbErrorCodes } from '../lib/errors.js';
|
|
10
|
+
import { DbErrorCodes, ErrorCodes } from '../lib/errors.js';
|
|
11
11
|
import { copySingletonToLocale, resolveSingletonDocumentId, restoreSingletonVersion, updateSingleton, } from './singleton-lifecycle/index.js';
|
|
12
12
|
const noopLogger = {
|
|
13
13
|
log: vi.fn(),
|
|
@@ -334,7 +334,15 @@ describe('singleton lifecycle service', () => {
|
|
|
334
334
|
},
|
|
335
335
|
},
|
|
336
336
|
});
|
|
337
|
-
await expect(updateSingleton(harness.ctx, { data: { title: 'Committed' } })).rejects.
|
|
337
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'Committed' } })).rejects.toMatchObject({
|
|
338
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
339
|
+
details: {
|
|
340
|
+
phase: 'afterSave',
|
|
341
|
+
documentId: 'doc-1',
|
|
342
|
+
documentVersionId: 'ver-1',
|
|
343
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
338
346
|
expect(harness.committedTransactions()).toBe(1);
|
|
339
347
|
expect(harness.mappedDocumentId()).toBe('doc-1');
|
|
340
348
|
expect(harness.currentVersion()?.document_version_id).toBe('ver-1');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export type DocumentVersionParentConflictReason = 'missing' | 'stale';
|
|
9
|
+
export interface DocumentVersionParentAssertion {
|
|
10
|
+
documentId: string;
|
|
11
|
+
locale?: string;
|
|
12
|
+
previousVersionId?: string;
|
|
13
|
+
currentVersionId: string | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Protect immutable-version carry-forward from an absent or stale parent.
|
|
17
|
+
* Callers must obtain `currentVersionId` while holding the logical document's
|
|
18
|
+
* write lock; this helper owns only the adapter-independent invariant.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertDocumentVersionParent(params: DocumentVersionParentAssertion): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { ERR_CONFLICT } from '../lib/errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Protect immutable-version carry-forward from an absent or stale parent.
|
|
11
|
+
* Callers must obtain `currentVersionId` while holding the logical document's
|
|
12
|
+
* write lock; this helper owns only the adapter-independent invariant.
|
|
13
|
+
*/
|
|
14
|
+
export function assertDocumentVersionParent(params) {
|
|
15
|
+
const localeScopedWrite = params.locale != null && params.locale !== 'all';
|
|
16
|
+
const reason = localeScopedWrite && params.currentVersionId != null && params.previousVersionId == null
|
|
17
|
+
? 'missing'
|
|
18
|
+
: params.previousVersionId != null && params.previousVersionId !== params.currentVersionId
|
|
19
|
+
? 'stale'
|
|
20
|
+
: null;
|
|
21
|
+
if (reason == null)
|
|
22
|
+
return;
|
|
23
|
+
throw ERR_CONFLICT({
|
|
24
|
+
message: reason === 'missing'
|
|
25
|
+
? 'previous document version is required for a locale-scoped write'
|
|
26
|
+
: 'previous document version is stale',
|
|
27
|
+
details: {
|
|
28
|
+
reason,
|
|
29
|
+
documentId: params.documentId,
|
|
30
|
+
previousVersionId: params.previousVersionId ?? null,
|
|
31
|
+
currentVersionId: params.currentVersionId,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { ErrorCodes } from '../lib/errors.js';
|
|
10
|
+
import { assertDocumentVersionParent } from './document-version-parent.js';
|
|
11
|
+
describe('assertDocumentVersionParent', () => {
|
|
12
|
+
it.each([
|
|
13
|
+
{ label: 'the first version', locale: 'en', currentVersionId: null },
|
|
14
|
+
{ label: "an existing locale: 'all' write", locale: 'all', currentVersionId: 'ver-current' },
|
|
15
|
+
{ label: 'an existing locale-less full-tree write', currentVersionId: 'ver-current' },
|
|
16
|
+
])('allows an omitted parent for $label', ({ locale, currentVersionId }) => {
|
|
17
|
+
expect(() => assertDocumentVersionParent({ documentId: 'doc-1', locale, currentVersionId })).not.toThrow();
|
|
18
|
+
});
|
|
19
|
+
it('allows the current parent for a locale-scoped write', () => {
|
|
20
|
+
expect(() => assertDocumentVersionParent({
|
|
21
|
+
documentId: 'doc-1',
|
|
22
|
+
locale: 'fr',
|
|
23
|
+
previousVersionId: 'ver-current',
|
|
24
|
+
currentVersionId: 'ver-current',
|
|
25
|
+
})).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
it('classifies an omitted locale-scoped parent as missing', () => {
|
|
28
|
+
expect(() => assertDocumentVersionParent({
|
|
29
|
+
documentId: 'doc-1',
|
|
30
|
+
locale: 'fr',
|
|
31
|
+
currentVersionId: 'ver-current',
|
|
32
|
+
})).toThrowError(expect.objectContaining({
|
|
33
|
+
code: ErrorCodes.CONFLICT,
|
|
34
|
+
message: 'previous document version is required for a locale-scoped write',
|
|
35
|
+
details: {
|
|
36
|
+
reason: 'missing',
|
|
37
|
+
documentId: 'doc-1',
|
|
38
|
+
previousVersionId: null,
|
|
39
|
+
currentVersionId: 'ver-current',
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
it.each(['fr', 'all', undefined])('classifies a mismatched parent as stale for locale %s', (locale) => {
|
|
44
|
+
expect(() => assertDocumentVersionParent({
|
|
45
|
+
documentId: 'doc-1',
|
|
46
|
+
locale,
|
|
47
|
+
previousVersionId: 'ver-stale',
|
|
48
|
+
currentVersionId: 'ver-current',
|
|
49
|
+
})).toThrowError(expect.objectContaining({
|
|
50
|
+
code: ErrorCodes.CONFLICT,
|
|
51
|
+
message: 'previous document version is stale',
|
|
52
|
+
details: {
|
|
53
|
+
reason: 'stale',
|
|
54
|
+
documentId: 'doc-1',
|
|
55
|
+
previousVersionId: 'ver-stale',
|
|
56
|
+
currentVersionId: 'ver-current',
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
export { fingerprintCollection } from './collection-fingerprint.js';
|
|
9
|
+
export { assertDocumentVersionParent, type DocumentVersionParentAssertion, type DocumentVersionParentConflictReason, } from './document-version-parent.js';
|
|
9
10
|
export { ALL_STORE_TYPES, type FieldStoreKind, type FieldStoreMapping, fieldTypeToStore, fieldTypeToStoreType, type StoreType, } from './field-store-map.js';
|
|
10
11
|
export { flattenFieldSetData } from './storage-flatten.js';
|
|
11
12
|
export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, } from './storage-restore.js';
|
package/dist/storage/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
export { fingerprintCollection } from './collection-fingerprint.js';
|
|
9
|
+
export { assertDocumentVersionParent, } from './document-version-parent.js';
|
|
9
10
|
export { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, } from './field-store-map.js';
|
|
10
11
|
export { flattenFieldSetData } from './storage-flatten.js';
|
|
11
12
|
export { extractFlattenedFieldValue, restoreFieldSetData, } from './storage-restore.js';
|
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.19.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"sharp": "^0.35.4",
|
|
88
88
|
"uuid": "^14.0.2",
|
|
89
89
|
"zod": "^4.4.3",
|
|
90
|
-
"@byline/auth": "4.
|
|
90
|
+
"@byline/auth": "4.19.0"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
93
|
"@biomejs/biome": "2.5.10",
|