@byline/core 3.20.4 → 4.0.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.
Files changed (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -7,13 +7,31 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
13
13
  import { walkFieldTree } from '../walk-field-tree.js';
14
- import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
14
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
15
15
  import { invokeHook } from './internals.js';
16
- import { promoteChildrenAndRemove } from './tree.js';
16
+ import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
17
+ function readErrorCode(error) {
18
+ try {
19
+ if ((typeof error !== 'object' || error === null) && typeof error !== 'function') {
20
+ return undefined;
21
+ }
22
+ const value = Reflect.get(error, 'code');
23
+ return typeof value === 'string' ? value : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function serializeSideEffectFailure(phase, error) {
30
+ return {
31
+ phase,
32
+ code: readErrorCode(error) === ErrorCodes.STORAGE ? ErrorCodes.STORAGE : ErrorCodes.UNHANDLED,
33
+ };
34
+ }
17
35
  /**
18
36
  * Soft-delete a document.
19
37
  *
@@ -47,7 +65,8 @@ export async function deleteDocument(ctx, params) {
47
65
  // AND a storage provider, fetch with reconstruct: true so we
48
66
  // can read the stored file paths (and persisted variant paths)
49
67
  // from the field values before the DB rows are deleted.
50
- const isUploadCollection = hasUploadField(definition) && ctx.storage != null;
68
+ const storage = ctx.storage;
69
+ const isUploadCollection = hasUploadField(definition) && storage != null;
51
70
  const latest = await db.queries.documents.getDocumentById({
52
71
  collection_id: ctx.collectionId,
53
72
  document_id: params.documentId,
@@ -99,15 +118,20 @@ export async function deleteDocument(ctx, params) {
99
118
  };
100
119
  // 2. beforeDelete hook.
101
120
  await invokeHook(hooks?.beforeDelete, hookCtx);
102
- // 3. Soft-delete all versions, atomically with the audit record. A
121
+ // 3. Soft-delete all versions atomically with the document audit and,
122
+ // for tree collections, locked child promotion/removal plus every
123
+ // parent/child tree audit row. Any failure rolls the entire delete
124
+ // back, so soft-deleted documents cannot leak live edges.
103
125
  // whole-document delete mints no new version, so the version stream
104
126
  // never records it — the audit log is the only place a deletion is
105
127
  // accountable (docs/06-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
106
128
  // DB↔external side-effect and stays OUTSIDE the transaction — it is
107
129
  // post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
108
- const audit = requireAuditCapability(db);
130
+ const treeAudit = definition.tree === true ? requireTreeAuditCapability(db) : undefined;
131
+ const audit = treeAudit ?? requireAuditCapability(db);
109
132
  const actor = auditActor(ctx);
110
133
  let deletedVersionCount = 0;
134
+ let treeResult;
111
135
  await audit.withTransaction(async () => {
112
136
  deletedVersionCount = await db.commands.documents.softDeleteDocument({
113
137
  document_id: params.documentId,
@@ -119,36 +143,77 @@ export async function deleteDocument(ctx, params) {
119
143
  actorRealm: actor.actorRealm,
120
144
  action: AUDIT_ACTIONS.deleted,
121
145
  });
146
+ if (treeAudit != null) {
147
+ treeResult = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, treeAudit);
148
+ }
122
149
  });
123
- // 4. Clean up storage files. Non-fatal: logs errors but does not throw.
124
- if (ctx.storage && storagePathsToDelete.length > 0) {
150
+ // Everything below is post-commit. Each operation and the logger get an
151
+ // independent attempt; none can turn the committed delete into a rejection.
152
+ const sideEffectFailures = [];
153
+ // 4. Clean up every storage file. Returned failures omit paths, while
154
+ // internal logs retain the target needed for operational reconciliation.
155
+ if (storage && storagePathsToDelete.length > 0) {
125
156
  for (const storagePath of storagePathsToDelete) {
126
157
  try {
127
- await ctx.storage.delete(storagePath);
158
+ await storage.delete(storagePath);
128
159
  }
129
- catch (err) {
130
- logger.error({ err, storagePath }, 'failed to delete storage file');
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
+ }
131
168
  }
132
169
  }
133
170
  }
134
- // 5. Reconcile the document tree for `tree: true` collections. Byline
135
- // deletes are soft (the document row survives), so the table's
136
- // promote/cascade foreign keys never fire promote the node's
137
- // children to root and remove its edge here instead, firing the
138
- // structural-change invalidation event. Post-commit and best-effort,
139
- // like file cleanup: a failure here leaves the soft-delete intact
140
- // (status-at-edge already hides the deleted node's subtree from
141
- // reads) and is logged rather than thrown. See docs/04-collections/03-document-trees.md.
142
- if (definition.tree === true) {
171
+ // 5-6. Both post-commit hook families get an independent attempt. A tree
172
+ // invalidation failure must not prevent afterDelete consumers (search,
173
+ // cache removal) from running, or vice versa.
174
+ try {
175
+ if (treeResult != null) {
176
+ await firePromoteTreeChange(ctx, params.documentId, treeResult);
177
+ }
178
+ }
179
+ catch (error) {
180
+ sideEffectFailures.push(serializeSideEffectFailure('afterTreeChange', error));
181
+ try {
182
+ logger.error({ err: error, documentId: params.documentId }, 'afterTreeChange hook failed after document delete');
183
+ }
184
+ catch {
185
+ // Diagnostic logging must not affect the committed result.
186
+ }
187
+ }
188
+ try {
189
+ await invokeHook(hooks?.afterDelete, hookCtx);
190
+ }
191
+ catch (error) {
192
+ sideEffectFailures.push(serializeSideEffectFailure('afterDelete', error));
193
+ try {
194
+ logger.error({ err: error, documentId: params.documentId }, 'afterDelete hook failed after document delete');
195
+ }
196
+ catch {
197
+ // Diagnostic logging must not affect the committed result.
198
+ }
199
+ }
200
+ const [firstFailure, ...remainingFailures] = sideEffectFailures;
201
+ if (firstFailure != null) {
143
202
  try {
144
- await promoteChildrenAndRemove(ctx, { documentId: params.documentId });
203
+ logger.error({
204
+ documentId: params.documentId,
205
+ sideEffectFailures: [firstFailure, ...remainingFailures],
206
+ }, 'post-commit delete side effects failed');
145
207
  }
146
- catch (err) {
147
- logger.error({ err, documentId: params.documentId }, 'failed to reconcile document tree on delete');
208
+ catch {
209
+ // A reporting failure cannot change the already-committed outcome.
148
210
  }
211
+ return {
212
+ deletedVersionCount,
213
+ outcome: 'committed-with-side-effect-failures',
214
+ sideEffectFailures: [firstFailure, ...remainingFailures],
215
+ };
149
216
  }
150
- // 6. afterDelete hook.
151
- await invokeHook(hooks?.afterDelete, hookCtx);
152
- return { deletedVersionCount };
217
+ return { deletedVersionCount, outcome: 'committed', sideEffectFailures: [] };
153
218
  });
154
219
  }
@@ -35,7 +35,7 @@ export { updateDocument, updateDocumentWithPatches } from './update.js';
35
35
  export type { DocumentLifecycleContext } from './context.js';
36
36
  export type { CopyToLocaleResult } from './copy-to-locale.js';
37
37
  export type { CreateDocumentResult } from './create.js';
38
- export type { DeleteDocumentResult } from './delete.js';
38
+ export type { DeleteDocumentCommittedResult, DeleteDocumentCommittedWithSideEffectFailuresResult, DeleteDocumentOutcome, DeleteDocumentResult, DeleteDocumentSideEffectCode, DeleteDocumentSideEffectFailure, DeleteDocumentSideEffectPhase, } from './delete.js';
39
39
  export type { DeleteLocaleResult } from './delete-locale.js';
40
40
  export type { DuplicateDocumentResult } from './duplicate.js';
41
41
  export type { RestoreVersionResult } from './restore.js';
@@ -59,26 +59,6 @@ export declare function applyRichTextEmbed(ctx: DocumentLifecycleContext, data:
59
59
  * gets `order_key = NULL` and the existing "no ordering" behavior holds.
60
60
  */
61
61
  export declare function maybeAppendOrderKey(ctx: DocumentLifecycleContext, collectionPath: string): Promise<string | undefined>;
62
- /**
63
- * Append a document as the **last root** of its `tree: true` collection's tree.
64
- * Mints a fresh root-group `order_key` after the current trailing root. Used by
65
- * create's auto-place and update's self-heal so a tree collection never strands a
66
- * document in the "unplaced" limbo. Issues the storage command directly — the
67
- * caller has already asserted the relevant ability and this is a system step.
68
- */
69
- export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
70
- /**
71
- * Self-heal a genuinely-*unplaced* document on update: if the collection is a
72
- * tree and the document has no edge row, append it as a root (mirroring create's
73
- * auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
74
- * root or child is left exactly where it is — only strays (e.g. docs created
75
- * before the flag, or whose create-time auto-place failed) are re-treed.
76
- *
77
- * No-op for non-tree collections. Best-effort and post-version: a failure leaves
78
- * the document saved-but-unplaced and is logged, never thrown. See
79
- * docs/04-collections/03-document-trees.md.
80
- */
81
- export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
82
62
  /** Extract `id` from the document object returned by `createDocumentVersion`. */
83
63
  export declare function extractVersionId(document: any): string;
84
64
  /** Extract the logical document id from the document object returned by `createDocumentVersion`. */
@@ -17,6 +17,7 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
20
+ import { createRichTextDocumentReader } from '../richtext-populate.js';
20
21
  /**
21
22
  * Matches a canonical UUID (any version). Real admin / end-user actors carry
22
23
  * UUID ids (`uuidv7`); synthetic actors do not.
@@ -70,20 +71,38 @@ export async function applyRichTextEmbed(ctx, data) {
70
71
  // `initBylineCore()` (unit tests, isolated tooling) — they have no
71
72
  // adapter to invoke, so this is a soft no-op.
72
73
  let embed;
74
+ let richTextPopulate;
75
+ let collections;
73
76
  try {
74
- embed = getServerConfig().fields?.richText?.embed;
77
+ const config = getServerConfig();
78
+ embed = config.fields?.richText?.embed;
79
+ richTextPopulate = config.fields?.richText?.populate;
80
+ collections = config.collections;
75
81
  }
76
82
  catch {
77
83
  return;
78
84
  }
79
- if (embed == null)
85
+ if (embed == null || ctx.requestContext == null)
80
86
  return;
87
+ const readContext = createReadContext();
88
+ const requestContext = { ...ctx.requestContext, readMode: 'published' };
81
89
  await embedRichTextFields({
82
90
  fields: ctx.definition.fields,
83
91
  collectionPath: ctx.collectionPath,
84
92
  data,
85
93
  embed,
86
- readContext: createReadContext(),
94
+ readContext,
95
+ requestContext,
96
+ readMode: 'published',
97
+ readDocuments: createRichTextDocumentReader({
98
+ db: ctx.db,
99
+ collections,
100
+ requestContext,
101
+ readContext,
102
+ readMode: 'published',
103
+ locale: ctx.defaultLocale,
104
+ richTextPopulate,
105
+ }),
87
106
  logger: ctx.logger,
88
107
  });
89
108
  }
@@ -103,49 +122,6 @@ export async function maybeAppendOrderKey(ctx, collectionPath) {
103
122
  });
104
123
  return generateKeyBetween(last, null);
105
124
  }
106
- /**
107
- * Append a document as the **last root** of its `tree: true` collection's tree.
108
- * Mints a fresh root-group `order_key` after the current trailing root. Used by
109
- * create's auto-place and update's self-heal so a tree collection never strands a
110
- * document in the "unplaced" limbo. Issues the storage command directly — the
111
- * caller has already asserted the relevant ability and this is a system step.
112
- */
113
- export async function appendTreeRoot(ctx, documentId) {
114
- const roots = await ctx.db.queries.documents.getTreeChildren({
115
- collectionId: ctx.collectionId,
116
- parentDocumentId: null,
117
- });
118
- await ctx.db.commands.documents.placeTreeNode({
119
- collectionId: ctx.collectionId,
120
- documentId,
121
- parentDocumentId: null,
122
- beforeDocumentId: roots.at(-1)?.document_id ?? null,
123
- });
124
- }
125
- /**
126
- * Self-heal a genuinely-*unplaced* document on update: if the collection is a
127
- * tree and the document has no edge row, append it as a root (mirroring create's
128
- * auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
129
- * root or child is left exactly where it is — only strays (e.g. docs created
130
- * before the flag, or whose create-time auto-place failed) are re-treed.
131
- *
132
- * No-op for non-tree collections. Best-effort and post-version: a failure leaves
133
- * the document saved-but-unplaced and is logged, never thrown. See
134
- * docs/04-collections/03-document-trees.md.
135
- */
136
- export async function selfHealTreePlacement(ctx, documentId) {
137
- if (ctx.definition.tree !== true)
138
- return;
139
- try {
140
- const { placed } = await ctx.db.queries.documents.getTreeParent({ document_id: documentId });
141
- if (placed)
142
- return;
143
- await appendTreeRoot(ctx, documentId);
144
- }
145
- catch (err) {
146
- ctx.logger.error({ err, documentId }, 'failed to self-heal tree placement on update');
147
- }
148
- }
149
125
  /** Extract `id` from the document object returned by `createDocumentVersion`. */
150
126
  export function extractVersionId(document) {
151
127
  return document?.id ?? document?.document_version_id ?? '';
@@ -33,7 +33,7 @@ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, para
33
33
  *
34
34
  * Flow:
35
35
  * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
36
- * 2. `db.commands.documents.archivePublishedVersions(...)`
36
+ * 2. Archive published versions and append the status audit atomically
37
37
  * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
38
38
  */
39
39
  export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
@@ -124,7 +124,7 @@ export async function changeDocumentStatus(ctx, params) {
124
124
  *
125
125
  * Flow:
126
126
  * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
127
- * 2. `db.commands.documents.archivePublishedVersions(...)`
127
+ * 2. Archive published versions and append the status audit atomically
128
128
  * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
129
129
  */
130
130
  export async function unpublishDocument(ctx, params) {
@@ -153,8 +153,25 @@ export async function unpublishDocument(ctx, params) {
153
153
  collectionPath,
154
154
  path,
155
155
  });
156
- const archivedCount = await db.commands.documents.archivePublishedVersions({
157
- document_id: params.documentId,
156
+ const audit = requireAuditCapability(db);
157
+ const actor = auditActor(ctx);
158
+ const archivedCount = await audit.withTransaction(async () => {
159
+ const count = await db.commands.documents.archivePublishedVersions({
160
+ document_id: params.documentId,
161
+ });
162
+ if (count > 0) {
163
+ await audit.append({
164
+ documentId: params.documentId,
165
+ collectionId,
166
+ actorId: actor.actorId,
167
+ actorRealm: actor.actorRealm,
168
+ action: AUDIT_ACTIONS.statusChanged,
169
+ field: 'status',
170
+ before: 'published',
171
+ after: 'archived',
172
+ });
173
+ }
174
+ return count;
158
175
  });
159
176
  await invokeHook(hooks?.afterUnpublish, {
160
177
  documentId: params.documentId,
@@ -10,7 +10,13 @@ export interface UpdateDocumentSystemFieldsResult {
10
10
  documentId: string;
11
11
  /** The path actually written, or `undefined` when no path write occurred. */
12
12
  path?: string;
13
- /** Whether the advertised-locale set was rewritten this call. */
13
+ /** Whether either system field actually changed. */
14
+ changed: boolean;
15
+ /** Whether a no-op request emitted the reconciliation hook. */
16
+ reconciliation: boolean;
17
+ pathChanged: boolean;
18
+ availableLocalesChanged: boolean;
19
+ /** Whether the advertised-locale set was actually rewritten this call. */
14
20
  availableLocalesWritten: boolean;
15
21
  }
16
22
  /**
@@ -29,7 +35,8 @@ export interface UpdateDocumentSystemFieldsResult {
29
35
  *
30
36
  * Flow:
31
37
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
32
- * 2. Fetch the document to resolve its `source_locale` anchor + current path.
38
+ * 2. Inside the audit transaction, lock the logical document and read its
39
+ * authoritative source locale, path, and advertised locales.
33
40
  * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
34
41
  * rule (translation-locale path edits are dropped with a warn); a real
35
42
  * change is written via `updateDocumentPath`, mapping the unique-constraint
@@ -37,10 +44,14 @@ export interface UpdateDocumentSystemFieldsResult {
37
44
  * 4. `availableLocales` (when supplied): rewritten wholesale via
38
45
  * `setDocumentAvailableLocales`.
39
46
  *
40
- * No content hooks fire these are not content writes. Accountability for
41
- * these mutations is the document-grain audit log: each field that actually
42
- * changes records a `document.path.changed` / `document.locales.changed` row
43
- * atomically with the write (docs/06-auth-and-security/02-auditability.md Workstream 2).
47
+ * Content hooks do not fire because these are not content writes. Actual
48
+ * changes emit `afterSystemFieldsChange` after the audited write commits. A
49
+ * caller can pass `reconcile: true` to emit the same hook for a no-op retry
50
+ * after an earlier post-commit hook failure. Hook failures reject the call but
51
+ * never roll back the already-committed write/audit.
52
+ * Accountability for these mutations is the document-grain audit log: each
53
+ * changed field records a `document.path.changed` /
54
+ * `document.locales.changed` row atomically with the write.
44
55
  *
45
56
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
46
57
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
@@ -61,4 +72,6 @@ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext
61
72
  * included — replaces the set wholesale.
62
73
  */
63
74
  availableLocales?: string[];
75
+ /** Re-run `afterSystemFieldsChange` when requested values are already current. */
76
+ reconcile?: boolean;
64
77
  }): Promise<UpdateDocumentSystemFieldsResult>;
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { resolveHooks } from '../../@types/index.js';
8
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
9
- import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { ERR_AUDIT_UNSUPPORTED, ERR_NOT_FOUND } from '../../lib/errors.js';
10
11
  import { withLogContext } from '../../lib/logger.js';
11
12
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
12
- import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
13
+ import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './internals.js';
13
14
  /**
14
15
  * Write a document's system-managed, document-grain fields — `path` and the
15
16
  * editorial `availableLocales` set — **without** minting a new version or
@@ -26,7 +27,8 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
26
27
  *
27
28
  * Flow:
28
29
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
29
- * 2. Fetch the document to resolve its `source_locale` anchor + current path.
30
+ * 2. Inside the audit transaction, lock the logical document and read its
31
+ * authoritative source locale, path, and advertised locales.
30
32
  * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
31
33
  * rule (translation-locale path edits are dropped with a warn); a real
32
34
  * change is written via `updateDocumentPath`, mapping the unique-constraint
@@ -34,58 +36,67 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
34
36
  * 4. `availableLocales` (when supplied): rewritten wholesale via
35
37
  * `setDocumentAvailableLocales`.
36
38
  *
37
- * No content hooks fire these are not content writes. Accountability for
38
- * these mutations is the document-grain audit log: each field that actually
39
- * changes records a `document.path.changed` / `document.locales.changed` row
40
- * atomically with the write (docs/06-auth-and-security/02-auditability.md Workstream 2).
39
+ * Content hooks do not fire because these are not content writes. Actual
40
+ * changes emit `afterSystemFieldsChange` after the audited write commits. A
41
+ * caller can pass `reconcile: true` to emit the same hook for a no-op retry
42
+ * after an earlier post-commit hook failure. Hook failures reject the call but
43
+ * never roll back the already-committed write/audit.
44
+ * Accountability for these mutations is the document-grain audit log: each
45
+ * changed field records a `document.path.changed` /
46
+ * `document.locales.changed` row atomically with the write.
41
47
  *
42
48
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
43
49
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
44
50
  */
45
51
  export async function updateDocumentSystemFields(ctx, params) {
46
52
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
47
- const { db, collectionId, collectionPath, defaultLocale } = ctx;
53
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
48
54
  assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
49
55
  const requestLocale = params.locale ?? defaultLocale;
50
- // Resolve the document's source-locale anchor + current path. Both feed
51
- // the path source-locale guard below; the fetch also asserts existence.
52
- const latest = await db.queries.documents.getDocumentById({
53
- collection_id: collectionId,
54
- document_id: params.documentId,
55
- locale: requestLocale,
56
- reconstruct: true,
57
- });
58
- if (latest == null) {
59
- throw ERR_NOT_FOUND({
60
- message: 'document not found',
61
- details: { documentId: params.documentId },
62
- }).log(ctx.logger);
63
- }
64
- const originalData = latest;
65
- const sourceLocale = originalData.source_locale ?? defaultLocale;
66
- // Path: honour the same source-locale-only rule the versioned write
67
- // uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
68
- // write" (null/empty override, or a translation-locale save).
69
56
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
70
- const pathForCommand = resolvePathForUpdate({
71
- explicitPath,
72
- currentPath: originalData.path,
73
- requestLocale,
74
- sourceLocale,
75
- documentId: params.documentId,
76
- logger: ctx.logger,
77
- });
78
- // Both document-grain writes and their audit rows commit atomically.
79
- // These fields are non-versioned, so the version stream never records
80
- // them — the audit log is their only accountability home. One audit row
81
- // per field that actually changed (docs/06-auth-and-security/02-auditability.md).
82
- const currentPath = originalData.path;
83
- const currentLocales = originalData.availableLocales ?? [];
84
- const availableLocalesWritten = params.availableLocales !== undefined;
57
+ const requested = {
58
+ path: explicitPath !== null,
59
+ availableLocales: params.availableLocales !== undefined,
60
+ };
61
+ const requestedLocales = params.availableLocales === undefined ? undefined : [...new Set(params.availableLocales)];
85
62
  const audit = requireAuditCapability(db);
63
+ const getDocumentSystemFieldsForUpdate = db.queries.documents.getDocumentSystemFieldsForUpdate;
64
+ if (typeof getDocumentSystemFieldsForUpdate !== 'function') {
65
+ throw ERR_AUDIT_UNSUPPORTED({
66
+ message: 'audited system-field writes require a transaction-scoped lock/read capability',
67
+ });
68
+ }
69
+ const lockSystemFields = getDocumentSystemFieldsForUpdate.bind(db.queries.documents);
86
70
  const actor = auditActor(ctx);
87
- await audit.withTransaction(async () => {
88
- if (pathForCommand !== undefined) {
71
+ // The logical document row is the mutex for both document-grain fields.
72
+ // Reading after that lock prevents concurrent writers from auditing a
73
+ // stale before value or omitting an intermediate old path from the event.
74
+ const outcome = await audit.withTransaction(async () => {
75
+ const snapshot = await lockSystemFields({
76
+ collection_id: collectionId,
77
+ document_id: params.documentId,
78
+ });
79
+ if (snapshot == null) {
80
+ throw ERR_NOT_FOUND({
81
+ message: 'document not found',
82
+ details: { documentId: params.documentId },
83
+ }).log(ctx.logger);
84
+ }
85
+ const sourceLocale = snapshot.source_locale ?? defaultLocale;
86
+ const currentPath = snapshot.path ?? undefined;
87
+ const currentLocales = snapshot.availableLocales;
88
+ const nextLocales = requestedLocales ?? currentLocales;
89
+ const pathForCommand = resolvePathForUpdate({
90
+ explicitPath,
91
+ currentPath,
92
+ requestLocale,
93
+ sourceLocale,
94
+ documentId: params.documentId,
95
+ logger: ctx.logger,
96
+ });
97
+ const pathChanged = pathForCommand !== undefined && pathForCommand !== currentPath;
98
+ const availableLocalesChanged = requestedLocales !== undefined && !sameLocaleSet(currentLocales, nextLocales);
99
+ if (pathChanged) {
89
100
  await db.commands.documents
90
101
  .updateDocumentPath({
91
102
  documentId: params.documentId,
@@ -93,45 +104,72 @@ export async function updateDocumentSystemFields(ctx, params) {
93
104
  locale: sourceLocale,
94
105
  path: pathForCommand,
95
106
  })
96
- .catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
97
- if (pathForCommand !== currentPath) {
98
- await audit.append({
99
- documentId: params.documentId,
100
- collectionId,
101
- actorId: actor.actorId,
102
- actorRealm: actor.actorRealm,
103
- action: AUDIT_ACTIONS.pathChanged,
104
- field: 'path',
105
- before: currentPath ?? null,
106
- after: pathForCommand,
107
- });
108
- }
107
+ .catch((err) => rethrowPathConflict(err, pathForCommand, sourceLocale));
108
+ await audit.append({
109
+ documentId: params.documentId,
110
+ collectionId,
111
+ actorId: actor.actorId,
112
+ actorRealm: actor.actorRealm,
113
+ action: AUDIT_ACTIONS.pathChanged,
114
+ field: 'path',
115
+ before: currentPath ?? null,
116
+ after: pathForCommand,
117
+ });
109
118
  }
110
- // Advertised locales: rewrite the document-grain set wholesale.
111
- if (params.availableLocales !== undefined) {
119
+ if (availableLocalesChanged) {
112
120
  await db.commands.documents.setDocumentAvailableLocales({
113
121
  documentId: params.documentId,
114
122
  collectionId,
115
- availableLocales: params.availableLocales,
123
+ availableLocales: nextLocales,
124
+ });
125
+ await audit.append({
126
+ documentId: params.documentId,
127
+ collectionId,
128
+ actorId: actor.actorId,
129
+ actorRealm: actor.actorRealm,
130
+ action: AUDIT_ACTIONS.localesChanged,
131
+ field: 'availableLocales',
132
+ before: currentLocales,
133
+ after: nextLocales,
116
134
  });
117
- if (!sameLocaleSet(currentLocales, params.availableLocales)) {
118
- await audit.append({
119
- documentId: params.documentId,
120
- collectionId,
121
- actorId: actor.actorId,
122
- actorRealm: actor.actorRealm,
123
- action: AUDIT_ACTIONS.localesChanged,
124
- field: 'availableLocales',
125
- before: currentLocales,
126
- after: params.availableLocales,
127
- });
128
- }
129
135
  }
136
+ return {
137
+ pathForCommand,
138
+ pathChanged,
139
+ availableLocalesChanged,
140
+ previousPath: currentPath,
141
+ currentPath: pathChanged ? pathForCommand : currentPath,
142
+ previousAvailableLocales: [...currentLocales],
143
+ currentAvailableLocales: [...nextLocales],
144
+ };
130
145
  });
146
+ const changed = outcome.pathChanged || outcome.availableLocalesChanged;
147
+ const reconciliation = !changed && params.reconcile === true;
148
+ if (changed || reconciliation) {
149
+ const hooks = await resolveHooks(definition);
150
+ await invokeHook(hooks?.afterSystemFieldsChange, {
151
+ documentId: params.documentId,
152
+ collectionPath,
153
+ requested,
154
+ changed: {
155
+ path: outcome.pathChanged,
156
+ availableLocales: outcome.availableLocalesChanged,
157
+ },
158
+ reconciliation,
159
+ previousPath: outcome.previousPath,
160
+ currentPath: outcome.currentPath,
161
+ previousAvailableLocales: outcome.previousAvailableLocales,
162
+ currentAvailableLocales: outcome.currentAvailableLocales,
163
+ });
164
+ }
131
165
  return {
132
166
  documentId: params.documentId,
133
- path: pathForCommand,
134
- availableLocalesWritten,
167
+ path: outcome.pathChanged ? outcome.pathForCommand : undefined,
168
+ changed,
169
+ reconciliation,
170
+ pathChanged: outcome.pathChanged,
171
+ availableLocalesChanged: outcome.availableLocalesChanged,
172
+ availableLocalesWritten: outcome.availableLocalesChanged,
135
173
  };
136
174
  });
137
175
  }