@byline/core 4.19.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 (111) hide show
  1. package/dist/@types/db-types.d.ts +32 -1
  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 +15 -0
  13. package/dist/lib/errors.js +32 -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 +2 -1
  33. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +4 -20
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  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 +48 -41
  44. package/dist/services/document-lifecycle/index.d.ts +3 -1
  45. package/dist/services/document-lifecycle/index.js +3 -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 +19 -25
  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 +101 -80
  77. package/dist/services/document-lifecycle.test.node.js +401 -73
  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 +74 -77
  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 +67 -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/read-snapshot.d.ts +3 -0
  108. package/dist/storage/read-snapshot.js +55 -0
  109. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  110. package/dist/storage/revision-store.test-helper.js +16 -0
  111. 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,44 +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
17
  import { runCommittedDocumentHook } from './committed-hook.js';
18
18
  import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
19
19
  import { persistExistingDocumentVersion } from './persistence.js';
20
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
21
+ import { finishSystemFieldMutation, updateDocumentSystemFields, writeSystemFieldsInTransaction, } from './system-fields.js';
20
22
  import { selfHealTreePlacement } from './tree.js';
21
- /**
22
- * Update a document via full replacement (PUT semantics).
23
- *
24
- * Unlike the previous implementation, this now fetches the current version
25
- * from storage to provide a real `originalData` to hooks.
26
- *
27
- * Flow:
28
- * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
29
- * 2. Normalize date and numeric fields
30
- * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`, then normalize numerics again
31
- * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
32
- * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
33
- */
34
- 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() };
35
44
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
36
- const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
45
+ const { db, definition, collectionPath, defaultLocale } = ctx;
37
46
  assertActorCanPerform(ctx.requestContext, definition, 'update');
38
- const hooks = await resolveHooks(definition);
39
47
  const data = params.data;
40
48
  // Fetch the real original so hooks get accurate originalData (fixes the
41
49
  // PUT handler bug where originalData === data).
42
- const latest = await db.queries.documents.getDocumentById({
43
- collection_id: collectionId,
44
- document_id: params.documentId,
45
- locale: params.locale ?? defaultLocale,
46
- reconstruct: true,
47
- });
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;
48
57
  const originalData = latest ?? {};
49
58
  normaliseDateFields(data);
50
59
  normalizeNumericFields(definition.fields, data);
@@ -68,32 +77,47 @@ export async function updateDocument(ctx, params) {
68
77
  // The document's own content-locale anchor governs which save writes the
69
78
  // path row — not the mutable global default. Falls back to the global
70
79
  // default for rows predating source_locale (not yet backfilled).
71
- const sourceLocale = originalData.source_locale ?? defaultLocale;
80
+ const sourceLocale = observedSourceLocale;
72
81
  const pathForCommand = resolvePathForUpdate({
73
82
  explicitPath,
74
- currentPath: originalData.path,
83
+ currentPath: observedPath,
75
84
  requestLocale,
76
85
  sourceLocale,
77
86
  documentId: params.documentId,
78
87
  logger: ctx.logger,
79
88
  });
80
89
  await applyRichTextEmbed(ctx, data);
81
- const result = await persistExistingDocumentVersion(ctx, {
90
+ const committed = await commitGuardedDocumentMutation(ctx, {
82
91
  documentId: params.documentId,
83
- action: 'update',
84
- documentData: data,
85
- path: pathForCommand,
86
- availableLocales: params.availableLocales,
87
- status: defaultStatus,
92
+ expectedRevision: params.expectedRevision,
93
+ previousVersionId,
88
94
  locale: requestLocale,
89
- previousVersionId: originalData.document_version_id,
90
- }).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;
91
117
  const documentId = extractDocumentId(result.document) || params.documentId;
92
118
  const documentVersionId = extractVersionId(result.document);
93
- // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
94
- // save re-trees a stray (system step, best-effort, no-op when placed).
95
- await selfHealTreePlacement(ctx, documentId);
96
- await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
119
+ await finishSystemFieldMutation(ctx, params, { ...systemFields, documentVersionId }, revision);
120
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
97
121
  data,
98
122
  originalData,
99
123
  collectionPath,
@@ -101,7 +125,7 @@ export async function updateDocument(ctx, params) {
101
125
  documentVersionId,
102
126
  path: pathForCommand ?? originalData.path,
103
127
  }));
104
- return { documentId, documentVersionId };
128
+ return { documentId, documentVersionId, revision };
105
129
  });
106
130
  }
107
131
  /**
@@ -109,46 +133,28 @@ export async function updateDocument(ctx, params) {
109
133
  *
110
134
  * Flow:
111
135
  * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
112
- * 2. Optimistic concurrency check on `documentVersionId`
136
+ * 2. Validate the observed document revision before preparation
113
137
  * 3. `applyPatches(definition, originalData, patches)` → `nextData`
114
138
  * 4. Normalize date and numeric fields
115
139
  * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`, then normalize numerics again
116
140
  * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
117
141
  * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
118
142
  *
119
- * @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.
120
144
  * @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
121
145
  */
122
146
  export async function updateDocumentWithPatches(ctx, params) {
147
+ params = { ...params, availableLocales: params.availableLocales?.slice() };
123
148
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
124
- const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
149
+ const { db, definition, collectionPath, defaultLocale } = ctx;
125
150
  assertActorCanPerform(ctx.requestContext, definition, 'update');
126
- const hooks = await resolveHooks(definition);
127
151
  // 1. Fetch current document.
128
- const latest = await db.queries.documents.getDocumentById({
129
- collection_id: collectionId,
130
- document_id: params.documentId,
131
- locale: params.locale ?? defaultLocale,
132
- reconstruct: true,
133
- });
134
- if (latest == null) {
135
- throw ERR_NOT_FOUND({
136
- message: 'document not found',
137
- details: { documentId: params.documentId },
138
- }).log(ctx.logger);
139
- }
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;
140
157
  const originalData = latest;
141
- // 2. Optimistic concurrency check.
142
- if (params.documentVersionId &&
143
- params.documentVersionId !== originalData.document_version_id) {
144
- throw ERR_CONFLICT({
145
- message: 'document has been modified since you loaded it',
146
- details: {
147
- currentVersionId: originalData.document_version_id,
148
- yourVersionId: params.documentVersionId,
149
- },
150
- }).log(ctx.logger);
151
- }
152
158
  // 3. Apply patches (patches operate on flat field data, not the full envelope).
153
159
  const { doc: patchedDocument, errors } = applyPatches(definition, originalData.fields ?? {}, params.patches);
154
160
  if (errors.length > 0) {
@@ -181,33 +187,42 @@ export async function updateDocumentWithPatches(ctx, params) {
181
187
  // The document's own content-locale anchor governs which save writes the
182
188
  // path row — not the mutable global default. Falls back to the global
183
189
  // default for rows predating source_locale (not yet backfilled).
184
- const sourceLocale = originalData.source_locale ?? defaultLocale;
190
+ const sourceLocale = observedSourceLocale;
185
191
  const pathForCommand = resolvePathForUpdate({
186
192
  explicitPath,
187
- currentPath: originalData.path,
193
+ currentPath: observedPath,
188
194
  requestLocale,
189
195
  sourceLocale,
190
196
  documentId: params.documentId,
191
197
  logger: ctx.logger,
192
198
  });
193
199
  await applyRichTextEmbed(ctx, nextData);
194
- const result = await persistExistingDocumentVersion(ctx, {
200
+ const committed = await commitGuardedDocumentMutation(ctx, {
195
201
  documentId: params.documentId,
196
- action: 'update',
197
- documentData: nextData,
198
- path: pathForCommand,
199
- availableLocales: params.availableLocales,
200
- status: defaultStatus,
202
+ expectedRevision: params.expectedRevision,
203
+ previousVersionId,
201
204
  locale: requestLocale,
202
- 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 };
203
218
  }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
219
+ const { result, systemFields } = committed.value;
220
+ const revision = committed.revision;
204
221
  const documentId = extractDocumentId(result.document) || params.documentId;
205
222
  const documentVersionId = extractVersionId(result.document);
206
- // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
207
- // save re-trees a stray (system step, best-effort, no-op when placed).
208
- await selfHealTreePlacement(ctx, documentId);
209
223
  // 7. afterUpdate hook.
210
- await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
224
+ await finishSystemFieldMutation(ctx, params, { ...systemFields, documentVersionId }, revision);
225
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
211
226
  data: nextData,
212
227
  originalData,
213
228
  collectionPath,
@@ -215,6 +230,12 @@ export async function updateDocumentWithPatches(ctx, params) {
215
230
  documentVersionId,
216
231
  path: pathForCommand ?? originalData.path,
217
232
  }));
218
- return { documentId, documentVersionId };
233
+ return { documentId, documentVersionId, revision };
219
234
  });
220
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
+ }