@byline/core 4.18.0 → 5.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 (117) hide show
  1. package/dist/@types/db-types.d.ts +38 -4
  2. package/dist/@types/document-revision.d.ts +92 -0
  3. package/dist/@types/document-revision.js +8 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/config/attach-hooks.test.node.js +2 -1
  7. package/dist/config/config-hooks.test.node.js +2 -1
  8. package/dist/core.js +8 -0
  9. package/dist/core.test.node.js +27 -2
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/lib/errors.d.ts +25 -0
  13. package/dist/lib/errors.js +42 -0
  14. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  15. package/dist/lib/lock-conflict.test.node.js +31 -0
  16. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  17. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  18. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  19. package/dist/scheduler/scheduled-publication.js +27 -1
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  21. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  23. package/dist/scheduler/scheduler-boot.test.node.js +40 -12
  24. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  25. package/dist/scheduler/ticker.test.node.js +3 -2
  26. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  27. package/dist/services/collection-bootstrap.test.node.js +12 -3
  28. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  29. package/dist/services/discover-counter-groups.test.node.js +19 -3
  30. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  31. package/dist/services/document-lifecycle/audit.js +1 -0
  32. package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
  33. package/dist/services/document-lifecycle/committed-hook.js +96 -0
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +6 -21
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +42 -39
  40. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  41. package/dist/services/document-lifecycle/delete.js +24 -15
  42. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  43. package/dist/services/document-lifecycle/duplicate.js +55 -43
  44. package/dist/services/document-lifecycle/index.d.ts +5 -1
  45. package/dist/services/document-lifecycle/index.js +4 -1
  46. package/dist/services/document-lifecycle/persistence.js +18 -7
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  49. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  50. package/dist/services/document-lifecycle/reorder.js +83 -0
  51. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  52. package/dist/services/document-lifecycle/restore.js +21 -26
  53. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  54. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  55. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  56. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  57. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  58. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  59. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  60. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  61. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  62. package/dist/services/document-lifecycle/stale-document.js +95 -0
  63. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  64. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  65. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  66. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  67. package/dist/services/document-lifecycle/status.d.ts +5 -21
  68. package/dist/services/document-lifecycle/status.js +90 -133
  69. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  70. package/dist/services/document-lifecycle/system-fields.js +118 -116
  71. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  72. package/dist/services/document-lifecycle/tree.js +65 -39
  73. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  74. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  75. package/dist/services/document-lifecycle/update.d.ts +14 -6
  76. package/dist/services/document-lifecycle/update.js +104 -82
  77. package/dist/services/document-lifecycle.test.node.js +556 -70
  78. package/dist/services/field-upload.d.ts +1 -0
  79. package/dist/services/field-upload.js +5 -1
  80. package/dist/services/field-upload.test.node.d.ts +0 -7
  81. package/dist/services/field-upload.test.node.js +38 -2
  82. package/dist/services/populate.test.node.d.ts +0 -7
  83. package/dist/services/populate.test.node.js +19 -3
  84. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  85. package/dist/services/richtext-populate.test.node.js +3 -2
  86. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  87. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  88. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  89. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  90. package/dist/services/singleton-lifecycle/internals.js +75 -73
  91. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  92. package/dist/services/singleton-lifecycle/restore.js +3 -1
  93. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  94. package/dist/services/singleton-lifecycle/update.js +158 -91
  95. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  96. package/dist/services/singleton-lifecycle.test.node.js +75 -23
  97. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  98. package/dist/storage/db-adapter.test-helper.js +117 -0
  99. package/dist/storage/document-revision-capability.d.ts +3 -0
  100. package/dist/storage/document-revision-capability.js +15 -0
  101. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  102. package/dist/storage/document-revision-capability.test.node.js +53 -0
  103. package/dist/storage/document-revision.d.ts +12 -0
  104. package/dist/storage/document-revision.js +37 -0
  105. package/dist/storage/document-revision.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision.test.node.js +48 -0
  107. package/dist/storage/document-version-parent.d.ts +20 -0
  108. package/dist/storage/document-version-parent.js +34 -0
  109. package/dist/storage/document-version-parent.test.node.d.ts +8 -0
  110. package/dist/storage/document-version-parent.test.node.js +60 -0
  111. package/dist/storage/index.d.ts +1 -0
  112. package/dist/storage/index.js +1 -0
  113. package/dist/storage/read-snapshot.d.ts +3 -0
  114. package/dist/storage/read-snapshot.js +55 -0
  115. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  116. package/dist/storage/revision-store.test-helper.js +16 -0
  117. package/package.json +2 -2
@@ -10,10 +10,12 @@ import type { DocumentLifecycleContext } from './context.js';
10
10
  export interface UpdateDocumentResult {
11
11
  documentId: string;
12
12
  documentVersionId: string;
13
+ revision: number;
13
14
  }
14
15
  export interface UpdateDocumentWithPatchesResult {
15
16
  documentId: string;
16
17
  documentVersionId: string;
18
+ revision: number;
17
19
  }
18
20
  /**
19
21
  * Update a document via full replacement (PUT semantics).
@@ -28,8 +30,9 @@ export interface UpdateDocumentWithPatchesResult {
28
30
  * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
29
31
  * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
30
32
  */
31
- export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
33
+ type UpdateDocumentParams = {
32
34
  documentId: string;
35
+ expectedRevision: number;
33
36
  data: Record<string, any>;
34
37
  locale?: string;
35
38
  /**
@@ -46,27 +49,29 @@ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
46
49
  * available-locales sidebar widget. See docs/08-internationalization/index.md.
47
50
  */
48
51
  availableLocales?: string[];
49
- }): Promise<UpdateDocumentResult>;
52
+ };
53
+ export declare function updateDocument(ctx: DocumentLifecycleContext, params: UpdateDocumentParams): Promise<UpdateDocumentResult>;
54
+ /** Maintenance-only replacement; status is derived from the guarded observation. */
55
+ export declare function replaceDocumentFieldsPreservingStatus(ctx: DocumentLifecycleContext, params: Pick<UpdateDocumentParams, 'documentId' | 'data' | 'expectedRevision'>): Promise<UpdateDocumentResult>;
50
56
  /**
51
57
  * Update a document via patch application.
52
58
  *
53
59
  * Flow:
54
60
  * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
55
- * 2. Optimistic concurrency check on `documentVersionId`
61
+ * 2. Validate the observed document revision before preparation
56
62
  * 3. `applyPatches(definition, originalData, patches)` → `nextData`
57
63
  * 4. Normalize date and numeric fields
58
64
  * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
59
65
  * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
60
66
  * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
61
67
  *
62
- * @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
68
+ * @throws {BylineError} ERR_DOCUMENT_STALE if the observed revision is stale.
63
69
  * @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
64
70
  */
65
71
  export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext, params: {
66
72
  documentId: string;
73
+ expectedRevision: number;
67
74
  patches: DocumentPatch[];
68
- /** Client-supplied version ID for optimistic concurrency. */
69
- documentVersionId?: string;
70
75
  locale?: string;
71
76
  /**
72
77
  * Explicit path override (typically supplied alongside patches when
@@ -82,3 +87,6 @@ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext,
82
87
  */
83
88
  availableLocales?: string[];
84
89
  }): Promise<UpdateDocumentWithPatchesResult>;
90
+ /** Combined patch/content and optional metadata save, with one guarded commit. */
91
+ export declare function saveDocument(ctx: DocumentLifecycleContext, params: Parameters<typeof updateDocumentWithPatches>[1]): Promise<import("./system-fields.js").UpdateDocumentSystemFieldsResult | UpdateDocumentWithPatchesResult>;
92
+ export {};
@@ -7,43 +7,53 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_CONFLICT, ERR_NOT_FOUND, ERR_PATCH_FAILED } from '../../lib/errors.js';
10
+ import { ERR_PATCH_FAILED, ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { applyPatches } from '../../patches/index.js';
13
13
  import { normaliseDateFields } from '../../utils/normalise-dates.js';
14
- import { getDefaultStatus } from '../../workflow/workflow.js';
14
+ import { getDefaultStatus, getWorkflowStatuses } 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';
20
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
21
+ import { finishSystemFieldMutation, updateDocumentSystemFields, writeSystemFieldsInTransaction, } from './system-fields.js';
19
22
  import { selfHealTreePlacement } from './tree.js';
20
- /**
21
- * Update a document via full replacement (PUT semantics).
22
- *
23
- * Unlike the previous implementation, this now fetches the current version
24
- * from storage to provide a real `originalData` to hooks.
25
- *
26
- * Flow:
27
- * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
28
- * 2. Normalize date and numeric fields
29
- * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`, then normalize numerics again
30
- * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
31
- * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
32
- */
33
- export async function updateDocument(ctx, params) {
23
+ export function updateDocument(ctx, params) {
24
+ return replaceFields(ctx, params, 'editorial');
25
+ }
26
+ /** Maintenance-only replacement; status is derived from the guarded observation. */
27
+ export async function replaceDocumentFieldsPreservingStatus(ctx, params) {
28
+ assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
29
+ ctx.requestContext?.actor?.assertAbility('system.documentMaintenance');
30
+ return replaceFields(ctx, { ...params, locale: 'all' }, 'preserve');
31
+ }
32
+ function preservedStatus(ctx, status) {
33
+ if (status === null || !getWorkflowStatuses(ctx.definition).some((item) => item.name === status))
34
+ throw ERR_VALIDATION({
35
+ message: 'The observed status is no longer declared by this collection',
36
+ details: { status },
37
+ });
38
+ if (status === 'published')
39
+ assertActorCanPerform(ctx.requestContext, ctx.definition, 'publish');
40
+ return status;
41
+ }
42
+ async function replaceFields(ctx, params, policy) {
43
+ params = { ...params, availableLocales: params.availableLocales?.slice() };
34
44
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
35
- const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
45
+ const { db, definition, collectionPath, defaultLocale } = ctx;
36
46
  assertActorCanPerform(ctx.requestContext, definition, 'update');
37
- const hooks = await resolveHooks(definition);
38
47
  const data = params.data;
39
48
  // Fetch the real original so hooks get accurate originalData (fixes the
40
49
  // PUT handler bug where originalData === data).
41
- const latest = await db.queries.documents.getDocumentById({
42
- collection_id: collectionId,
43
- document_id: params.documentId,
44
- locale: params.locale ?? defaultLocale,
45
- reconstruct: true,
46
- });
50
+ const latest = await readDocumentForMutation(ctx, params);
51
+ if (policy === 'preserve')
52
+ preservedStatus(ctx, latest.status);
53
+ const hooks = await resolveHooks(definition);
54
+ const previousVersionId = latest.document_version_id;
55
+ const observedPath = latest.path;
56
+ const observedSourceLocale = latest.source_locale ?? defaultLocale;
47
57
  const originalData = latest ?? {};
48
58
  normaliseDateFields(data);
49
59
  normalizeNumericFields(definition.fields, data);
@@ -67,40 +77,55 @@ export async function updateDocument(ctx, params) {
67
77
  // The document's own content-locale anchor governs which save writes the
68
78
  // path row — not the mutable global default. Falls back to the global
69
79
  // default for rows predating source_locale (not yet backfilled).
70
- const sourceLocale = originalData.source_locale ?? defaultLocale;
80
+ const sourceLocale = observedSourceLocale;
71
81
  const pathForCommand = resolvePathForUpdate({
72
82
  explicitPath,
73
- currentPath: originalData.path,
83
+ currentPath: observedPath,
74
84
  requestLocale,
75
85
  sourceLocale,
76
86
  documentId: params.documentId,
77
87
  logger: ctx.logger,
78
88
  });
79
89
  await applyRichTextEmbed(ctx, data);
80
- const result = await persistExistingDocumentVersion(ctx, {
90
+ const committed = await commitGuardedDocumentMutation(ctx, {
81
91
  documentId: params.documentId,
82
- action: 'update',
83
- documentData: data,
84
- path: pathForCommand,
85
- availableLocales: params.availableLocales,
86
- status: defaultStatus,
92
+ expectedRevision: params.expectedRevision,
93
+ previousVersionId,
87
94
  locale: requestLocale,
88
- previousVersionId: originalData.document_version_id,
89
- }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
95
+ }, async (locked) => {
96
+ const systemFields = await writeSystemFieldsInTransaction(ctx, params, locked);
97
+ const result = await persistExistingDocumentVersion(ctx, {
98
+ documentId: params.documentId,
99
+ action: 'update',
100
+ documentData: data,
101
+ path: pathForCommand,
102
+ status: policy === 'preserve' ? preservedStatus(ctx, locked.status) : defaultStatus,
103
+ locale: requestLocale,
104
+ previousVersionId,
105
+ });
106
+ if (policy === 'preserve' && locked.status === 'published') {
107
+ await db.commands.documents.archivePublishedVersions({
108
+ document_id: params.documentId,
109
+ excludeVersionId: extractVersionId(result.document),
110
+ });
111
+ }
112
+ await selfHealTreePlacement(ctx, params.documentId);
113
+ return { value: { result, systemFields }, changed: true };
114
+ }, policy === 'preserve' ? { collectionLock: 'exclusive' } : {}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
115
+ const { result, systemFields } = committed.value;
116
+ const revision = committed.revision;
90
117
  const documentId = extractDocumentId(result.document) || params.documentId;
91
118
  const documentVersionId = extractVersionId(result.document);
92
- // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
93
- // save re-trees a stray (system step, best-effort, no-op when placed).
94
- await selfHealTreePlacement(ctx, documentId);
95
- await invokeHook(hooks?.afterUpdate, {
119
+ await finishSystemFieldMutation(ctx, params, { ...systemFields, documentVersionId }, revision);
120
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
96
121
  data,
97
122
  originalData,
98
123
  collectionPath,
99
124
  documentId,
100
125
  documentVersionId,
101
126
  path: pathForCommand ?? originalData.path,
102
- });
103
- return { documentId, documentVersionId };
127
+ }));
128
+ return { documentId, documentVersionId, revision };
104
129
  });
105
130
  }
106
131
  /**
@@ -108,46 +133,28 @@ export async function updateDocument(ctx, params) {
108
133
  *
109
134
  * Flow:
110
135
  * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
111
- * 2. Optimistic concurrency check on `documentVersionId`
136
+ * 2. Validate the observed document revision before preparation
112
137
  * 3. `applyPatches(definition, originalData, patches)` → `nextData`
113
138
  * 4. Normalize date and numeric fields
114
139
  * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
115
140
  * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
116
141
  * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
117
142
  *
118
- * @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
143
+ * @throws {BylineError} ERR_DOCUMENT_STALE if the observed revision is stale.
119
144
  * @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
120
145
  */
121
146
  export async function updateDocumentWithPatches(ctx, params) {
147
+ params = { ...params, availableLocales: params.availableLocales?.slice() };
122
148
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
123
- const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
149
+ const { db, definition, collectionPath, defaultLocale } = ctx;
124
150
  assertActorCanPerform(ctx.requestContext, definition, 'update');
125
- const hooks = await resolveHooks(definition);
126
151
  // 1. Fetch current document.
127
- const latest = await db.queries.documents.getDocumentById({
128
- collection_id: collectionId,
129
- document_id: params.documentId,
130
- locale: params.locale ?? defaultLocale,
131
- reconstruct: true,
132
- });
133
- if (latest == null) {
134
- throw ERR_NOT_FOUND({
135
- message: 'document not found',
136
- details: { documentId: params.documentId },
137
- }).log(ctx.logger);
138
- }
152
+ const latest = await readDocumentForMutation(ctx, params);
153
+ const hooks = await resolveHooks(definition);
154
+ const previousVersionId = latest.document_version_id;
155
+ const observedPath = latest.path;
156
+ const observedSourceLocale = latest.source_locale ?? defaultLocale;
139
157
  const originalData = latest;
140
- // 2. Optimistic concurrency check.
141
- if (params.documentVersionId &&
142
- params.documentVersionId !== originalData.document_version_id) {
143
- throw ERR_CONFLICT({
144
- message: 'document has been modified since you loaded it',
145
- details: {
146
- currentVersionId: originalData.document_version_id,
147
- yourVersionId: params.documentVersionId,
148
- },
149
- }).log(ctx.logger);
150
- }
151
158
  // 3. Apply patches (patches operate on flat field data, not the full envelope).
152
159
  const { doc: patchedDocument, errors } = applyPatches(definition, originalData.fields ?? {}, params.patches);
153
160
  if (errors.length > 0) {
@@ -180,40 +187,55 @@ export async function updateDocumentWithPatches(ctx, params) {
180
187
  // The document's own content-locale anchor governs which save writes the
181
188
  // path row — not the mutable global default. Falls back to the global
182
189
  // default for rows predating source_locale (not yet backfilled).
183
- const sourceLocale = originalData.source_locale ?? defaultLocale;
190
+ const sourceLocale = observedSourceLocale;
184
191
  const pathForCommand = resolvePathForUpdate({
185
192
  explicitPath,
186
- currentPath: originalData.path,
193
+ currentPath: observedPath,
187
194
  requestLocale,
188
195
  sourceLocale,
189
196
  documentId: params.documentId,
190
197
  logger: ctx.logger,
191
198
  });
192
199
  await applyRichTextEmbed(ctx, nextData);
193
- const result = await persistExistingDocumentVersion(ctx, {
200
+ const committed = await commitGuardedDocumentMutation(ctx, {
194
201
  documentId: params.documentId,
195
- action: 'update',
196
- documentData: nextData,
197
- path: pathForCommand,
198
- availableLocales: params.availableLocales,
199
- status: defaultStatus,
202
+ expectedRevision: params.expectedRevision,
203
+ previousVersionId,
200
204
  locale: requestLocale,
201
- previousVersionId: originalData.document_version_id,
205
+ }, async (locked) => {
206
+ const systemFields = await writeSystemFieldsInTransaction(ctx, params, locked);
207
+ const result = await persistExistingDocumentVersion(ctx, {
208
+ documentId: params.documentId,
209
+ action: 'update',
210
+ documentData: nextData,
211
+ path: pathForCommand,
212
+ status: defaultStatus,
213
+ locale: requestLocale,
214
+ previousVersionId,
215
+ });
216
+ await selfHealTreePlacement(ctx, params.documentId);
217
+ return { value: { result, systemFields }, changed: true };
202
218
  }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
219
+ const { result, systemFields } = committed.value;
220
+ const revision = committed.revision;
203
221
  const documentId = extractDocumentId(result.document) || params.documentId;
204
222
  const documentVersionId = extractVersionId(result.document);
205
- // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
206
- // save re-trees a stray (system step, best-effort, no-op when placed).
207
- await selfHealTreePlacement(ctx, documentId);
208
223
  // 7. afterUpdate hook.
209
- await invokeHook(hooks?.afterUpdate, {
224
+ await finishSystemFieldMutation(ctx, params, { ...systemFields, documentVersionId }, revision);
225
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
210
226
  data: nextData,
211
227
  originalData,
212
228
  collectionPath,
213
229
  documentId,
214
230
  documentVersionId,
215
231
  path: pathForCommand ?? originalData.path,
216
- });
217
- return { documentId, documentVersionId };
232
+ }));
233
+ return { documentId, documentVersionId, revision };
218
234
  });
219
235
  }
236
+ /** Combined patch/content and optional metadata save, with one guarded commit. */
237
+ export async function saveDocument(ctx, params) {
238
+ if (params.patches.length === 0)
239
+ return updateDocumentSystemFields(ctx, params);
240
+ return updateDocumentWithPatches(ctx, params);
241
+ }