@byline/core 4.18.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.
@@ -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. If provided and `locale` is a specific locale (not 'all'),
790
- * field-value rows for other locales are copied forward from this version
791
- * into the new one so that per-locale content is not lost.
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
  /**
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';
@@ -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
@@ -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 invokeHook(hooks?.afterUpdate, {
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 invokeHook(hooks?.afterCreate, {
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 invokeSingletonHook(hooks?.afterSave, committed.afterSaveContext);
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.toThrow('notification failed');
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
+ });
@@ -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';
@@ -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.18.0",
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.18.0"
90
+ "@byline/auth": "4.19.0"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@biomejs/biome": "2.5.10",