@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
@@ -7,185 +7,142 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { ERR_INVALID_TRANSITION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
13
13
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
14
+ import { runCommittedDocumentHook } from './committed-hook.js';
14
15
  import { invokeHook } from './internals.js';
15
16
  import { appendPublishScheduleCancellationAudit, cancelPublishScheduleInTransaction, } from './publish-schedule-consistency.js';
17
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
16
18
  import { commitDocumentStatusTransition } from './status-transition.js';
17
- /**
18
- * Change a document's workflow status.
19
- *
20
- * Flow:
21
- * 1. Fetch current document metadata
22
- * 2. Validate transition via `validateStatusTransition()`
23
- * 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
24
- * 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
25
- * 5. Auto-archive: if transitioning to `'published'`, archive other published versions
26
- * 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
27
- */
19
+ function validateWorkflow(ctx) {
20
+ if (getWorkflow(ctx.definition).statuses.length <= 1)
21
+ throw ERR_INVALID_TRANSITION({
22
+ message: `collection '${ctx.collectionPath}' has a single-status workflow; status transitions are not supported`,
23
+ details: { collectionPath: ctx.collectionPath },
24
+ });
25
+ }
26
+ function validateTransition(ctx, previousStatus, nextStatus) {
27
+ validateWorkflow(ctx);
28
+ const result = validateStatusTransition(getWorkflow(ctx.definition), previousStatus, nextStatus);
29
+ if (!result.valid)
30
+ throw ERR_INVALID_TRANSITION({
31
+ message: result.reason ?? 'Invalid status transition',
32
+ details: { currentStatus: previousStatus, nextStatus },
33
+ });
34
+ }
28
35
  export async function changeDocumentStatus(ctx, params) {
29
36
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'changeDocumentStatus' }, async () => {
37
+ params = { ...params };
30
38
  const { db, definition, collectionId, collectionPath } = ctx;
31
- // Every transition requires the general changeStatus ability.
32
- // Transitions that target the `published` status additionally
33
- // require the narrower `publish` ability — so installations can
34
- // grant "move things through the workflow" without also granting
35
- // "flip the final publish switch".
36
39
  assertActorCanPerform(ctx.requestContext, definition, 'changeStatus');
37
- if (params.nextStatus === 'published') {
40
+ if (params.nextStatus === 'published')
38
41
  assertActorCanPerform(ctx.requestContext, definition, 'publish');
39
- }
40
- // Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups)
41
- // have no transitions to perform. Reject early with a clear message
42
- // rather than relying on the generic ±1-step validator.
43
- const workflow = getWorkflow(definition);
44
- if (workflow.statuses.length <= 1) {
45
- throw ERR_INVALID_TRANSITION({
46
- message: `collection '${collectionPath}' has a single-status workflow; status transitions are not supported`,
47
- details: { collectionPath, nextStatus: params.nextStatus },
48
- }).log(ctx.logger);
49
- }
50
- const hooks = await resolveHooks(definition);
51
- // 1. Fetch current version metadata. No field reconstruction needed —
52
- // status transitions only touch the document_versions.status column.
53
- const latest = await db.queries.documents.getCurrentVersionMetadata({
54
- collection_id: collectionId,
55
- document_id: params.documentId,
56
- });
57
- if (latest == null) {
58
- throw ERR_NOT_FOUND({
59
- message: 'document not found',
60
- details: { documentId: params.documentId },
61
- }).log(ctx.logger);
62
- }
63
- const currentStatus = latest.status ?? 'draft';
42
+ const latest = await readDocumentForMutation(ctx, { ...params, lenient: true });
43
+ const previousStatus = latest.status ?? 'draft';
64
44
  const documentVersionId = latest.document_version_id;
65
- // 2. Validate transition.
66
- const result = validateStatusTransition(workflow, currentStatus, params.nextStatus);
67
- if (!result.valid) {
68
- throw ERR_INVALID_TRANSITION({
69
- message: result.reason ??
70
- `invalid status transition from '${currentStatus}' to '${params.nextStatus}'`,
71
- details: { currentStatus, nextStatus: params.nextStatus },
72
- }).log(ctx.logger);
73
- }
74
- // Resolve the document's canonical path so the hooks can act on the
75
- // specific document/URL (CDN purge, cache-key drop). Narrow lookup —
76
- // getCurrentVersionMetadata deliberately omits the path subquery.
77
- const path = (await ctx.db.queries.documents.getCurrentPath({
78
- collection_id: collectionId,
79
- document_id: params.documentId,
80
- })) ?? '';
45
+ validateTransition(ctx, previousStatus, params.nextStatus);
81
46
  const hookCtx = {
82
47
  documentId: params.documentId,
83
48
  documentVersionId,
84
49
  collectionPath,
85
- path,
86
- previousStatus: currentStatus,
50
+ path: latest.path ?? '',
51
+ previousStatus,
87
52
  nextStatus: params.nextStatus,
88
53
  };
89
- // 3. beforeStatusChange hook.
54
+ const hooks = await resolveHooks(definition);
90
55
  await invokeHook(hooks?.beforeStatusChange, hookCtx);
91
- // 4–5. Mutate status in-place + auto-archive, atomically with the audit
92
- // record. Status mutates the version row rather than minting a new
93
- // version, so the version stream never captures *who* changed it —
94
- // the audit log is its only accountability home (docs/07-auth-and-security/02-auditability.md).
95
- const transitionAudit = requireAuditCapability(db);
96
- let cancelledSchedule = null;
97
- await commitDocumentStatusTransition({
98
- db,
99
- documentId: params.documentId,
100
- documentVersionId,
101
- collectionId,
102
- previousStatus: currentStatus,
103
- nextStatus: params.nextStatus,
104
- actor: auditActor(ctx),
105
- contributions: {
106
- beforeStatusWrite: async () => {
107
- cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
108
- },
109
- afterAuditAppend: () => appendPublishScheduleCancellationAudit({
56
+ const audit = requireAuditCapability(db);
57
+ const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId: documentVersionId }, async (locked) => {
58
+ const status = locked.status ?? 'draft';
59
+ validateTransition(ctx, status, params.nextStatus);
60
+ const schedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
61
+ if (status !== params.nextStatus)
62
+ await commitDocumentStatusTransition({
63
+ db,
64
+ documentId: params.documentId,
65
+ documentVersionId: locked.currentVersionId,
66
+ collectionId,
67
+ previousStatus: status,
68
+ nextStatus: params.nextStatus,
69
+ actor: auditActor(ctx),
70
+ contributions: {
71
+ afterAuditAppend: () => appendPublishScheduleCancellationAudit({
72
+ ctx,
73
+ audit,
74
+ schedule,
75
+ reason: 'status_changed',
76
+ }),
77
+ },
78
+ });
79
+ else
80
+ await appendPublishScheduleCancellationAudit({
110
81
  ctx,
111
- audit: transitionAudit,
112
- schedule: cancelledSchedule,
82
+ audit,
83
+ schedule,
113
84
  reason: 'status_changed',
114
- }),
115
- },
85
+ });
86
+ return {
87
+ value: { previousStatus: status, newStatus: params.nextStatus },
88
+ changed: status !== params.nextStatus || schedule !== null,
89
+ };
116
90
  });
117
- // 6. afterStatusChange hook.
118
- await invokeHook(hooks?.afterStatusChange, hookCtx);
119
- return { previousStatus: currentStatus, newStatus: params.nextStatus };
91
+ await runCommittedDocumentHook(ctx, {
92
+ phase: 'afterStatusChange',
93
+ documentId: params.documentId,
94
+ documentVersionId,
95
+ revision: committed.revision,
96
+ }, () => invokeHook(hooks?.afterStatusChange, hookCtx));
97
+ return { ...committed.value, documentId: params.documentId, revision: committed.revision };
120
98
  });
121
99
  }
122
- /**
123
- * Unpublish a document by archiving its published version(s).
124
- *
125
- * Flow:
126
- * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
127
- * 2. Archive published versions and append the status audit atomically
128
- * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
129
- */
130
100
  export async function unpublishDocument(ctx, params) {
131
101
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'unpublishDocument' }, async () => {
132
- const { db, collectionId, collectionPath, definition } = ctx;
133
- // Unpublish is a workflow transition out of `published` — reuse the
134
- // changeStatus gate rather than a separate ability.
102
+ params = { ...params };
103
+ const { db, definition, collectionId, collectionPath } = ctx;
135
104
  assertActorCanPerform(ctx.requestContext, definition, 'changeStatus');
136
- // Single-status workflows have nothing to unpublish to.
137
- const workflow = getWorkflow(definition);
138
- if (workflow.statuses.length <= 1) {
139
- throw ERR_INVALID_TRANSITION({
140
- message: `collection '${collectionPath}' has a single-status workflow; unpublish is not supported`,
141
- details: { collectionPath },
142
- }).log(ctx.logger);
143
- }
105
+ const latest = await readDocumentForMutation(ctx, { ...params, lenient: true });
106
+ validateWorkflow(ctx);
107
+ const documentVersionId = latest.document_version_id;
108
+ const hookCtx = { documentId: params.documentId, collectionPath, path: latest.path ?? '' };
144
109
  const hooks = await resolveHooks(definition);
145
- // Resolve the document's canonical path so the hooks can target the
146
- // specific document/URL (CDN purge, cache-key drop).
147
- const path = (await db.queries.documents.getCurrentPath({
148
- collection_id: collectionId,
149
- document_id: params.documentId,
150
- })) ?? '';
151
- await invokeHook(hooks?.beforeUnpublish, {
152
- documentId: params.documentId,
153
- collectionPath,
154
- path,
155
- });
110
+ await invokeHook(hooks?.beforeUnpublish, hookCtx);
156
111
  const audit = requireAuditCapability(db);
157
112
  const actor = auditActor(ctx);
158
- const archivedCount = await audit.withTransaction(async () => {
159
- const cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
160
- const count = await db.commands.documents.archivePublishedVersions({
113
+ const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId: documentVersionId }, async () => {
114
+ const schedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
115
+ const archivedCount = await db.commands.documents.archivePublishedVersions({
161
116
  document_id: params.documentId,
162
117
  });
163
- if (count > 0) {
118
+ if (archivedCount > 0)
164
119
  await audit.append({
165
120
  documentId: params.documentId,
166
121
  collectionId,
167
- actorId: actor.actorId,
168
- actorRealm: actor.actorRealm,
122
+ ...actor,
169
123
  action: AUDIT_ACTIONS.statusChanged,
170
124
  field: 'status',
171
125
  before: 'published',
172
126
  after: 'archived',
173
127
  });
174
- }
175
128
  await appendPublishScheduleCancellationAudit({
176
129
  ctx,
177
130
  audit,
178
- schedule: cancelledSchedule,
131
+ schedule,
179
132
  reason: 'unpublished',
180
133
  });
181
- return count;
134
+ return { value: archivedCount, changed: archivedCount > 0 || schedule !== null };
182
135
  });
183
- await invokeHook(hooks?.afterUnpublish, {
136
+ await runCommittedDocumentHook(ctx, {
137
+ phase: 'afterUnpublish',
184
138
  documentId: params.documentId,
185
- collectionPath,
186
- path,
187
- archivedCount,
188
- });
189
- return { archivedCount };
139
+ documentVersionId,
140
+ revision: committed.revision,
141
+ }, () => invokeHook(hooks?.afterUnpublish, { ...hookCtx, archivedCount: committed.value }));
142
+ return {
143
+ archivedCount: committed.value,
144
+ documentId: params.documentId,
145
+ revision: committed.revision,
146
+ };
190
147
  });
191
148
  }
@@ -5,9 +5,11 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import type { LockedDocumentRevision } from '../../@types/index.js';
8
9
  import type { DocumentLifecycleContext } from './context.js';
9
10
  export interface UpdateDocumentSystemFieldsResult {
10
11
  documentId: string;
12
+ revision: number;
11
13
  /** The path actually written, or `undefined` when no path write occurred. */
12
14
  path?: string;
13
15
  /** Whether either system field actually changed. */
@@ -58,6 +60,7 @@ export interface UpdateDocumentSystemFieldsResult {
58
60
  */
59
61
  export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext, params: {
60
62
  documentId: string;
63
+ expectedRevision: number;
61
64
  locale?: string;
62
65
  /**
63
66
  * Explicit path override from the path widget. `null` / empty / omitted
@@ -75,3 +78,21 @@ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext
75
78
  /** Re-run `afterSystemFieldsChange` when requested values are already current. */
76
79
  reconcile?: boolean;
77
80
  }): Promise<UpdateDocumentSystemFieldsResult>;
81
+ export type SystemFieldMutationParams = Parameters<typeof updateDocumentSystemFields>[1];
82
+ /** Internal storage phase; caller owns the document guard and revision advancement. */
83
+ export declare function writeSystemFieldsInTransaction(ctx: DocumentLifecycleContext, params: SystemFieldMutationParams, locked: LockedDocumentRevision): Promise<{
84
+ requested: {
85
+ path: boolean;
86
+ availableLocales: boolean;
87
+ };
88
+ pathForCommand: string | undefined;
89
+ pathChanged: boolean;
90
+ availableLocalesChanged: boolean;
91
+ previousPath: string | undefined;
92
+ currentPath: string | undefined;
93
+ previousAvailableLocales: string[];
94
+ currentAvailableLocales: string[];
95
+ }>;
96
+ export declare function finishSystemFieldMutation(ctx: DocumentLifecycleContext, params: SystemFieldMutationParams, outcome: Awaited<ReturnType<typeof writeSystemFieldsInTransaction>> & {
97
+ documentVersionId: string;
98
+ }, revision: number): Promise<UpdateDocumentSystemFieldsResult>;
@@ -7,10 +7,11 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_AUDIT_UNSUPPORTED, ERR_NOT_FOUND } from '../../lib/errors.js';
11
- import { withLogContext } from '../../lib/logger.js';
12
10
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
11
+ import { runCommittedDocumentHook } from './committed-hook.js';
13
12
  import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './internals.js';
13
+ import { suspendPublishScheduleForEdit } from './publish-schedule-consistency.js';
14
+ import { commitGuardedDocumentMutation } from './revision-guard.js';
14
15
  /**
15
16
  * Write a document's system-managed, document-grain fields — `path` and the
16
17
  * editorial `availableLocales` set — **without** minting a new version or
@@ -49,127 +50,128 @@ import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './interna
49
50
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
50
51
  */
51
52
  export async function updateDocumentSystemFields(ctx, params) {
52
- return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
53
- const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
54
- assertActorCanPerform(ctx.requestContext, definition, 'update');
55
- const requestLocale = params.locale ?? defaultLocale;
56
- const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
57
- const requested = {
58
- path: explicitPath !== null,
59
- availableLocales: params.availableLocales !== undefined,
60
- };
61
- const requestedLocales = params.availableLocales === undefined ? undefined : [...new Set(params.availableLocales)];
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);
70
- const actor = auditActor(ctx);
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) {
100
- await db.commands.documents
101
- .updateDocumentPath({
102
- documentId: params.documentId,
103
- collectionId,
104
- locale: sourceLocale,
105
- path: pathForCommand,
106
- })
107
- .catch((err) => rethrowPathConflict(db, err, pathForCommand, sourceLocale, 'update'));
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
- });
118
- }
119
- if (availableLocalesChanged) {
120
- await db.commands.documents.setDocumentAvailableLocales({
121
- documentId: params.documentId,
122
- collectionId,
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,
134
- });
135
- }
136
- return {
137
- pathForCommand,
138
- pathChanged,
139
- availableLocalesChanged,
140
- previousPath: currentPath,
141
- currentPath: pathChanged ? pathForCommand : currentPath,
142
- previousAvailableLocales: [...currentLocales],
143
- currentAvailableLocales: [...nextLocales],
144
- };
53
+ params = { ...params, availableLocales: params.availableLocales?.slice() };
54
+ assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
55
+ // Locale controls path semantics, not version ancestry: metadata-only writes
56
+ // do not create a version and carry only a document revision precondition.
57
+ const committed = await commitGuardedDocumentMutation(ctx, {
58
+ documentId: params.documentId,
59
+ expectedRevision: params.expectedRevision,
60
+ }, async (locked) => {
61
+ const value = await writeSystemFieldsInTransaction(ctx, params, locked);
62
+ const changed = value.pathChanged || value.availableLocalesChanged;
63
+ if (changed)
64
+ await suspendPublishScheduleForEdit(ctx, requireAuditCapability(ctx.db), params.documentId, 'document_metadata_changed');
65
+ return { value: { ...value, documentVersionId: locked.currentVersionId ?? '' }, changed };
66
+ });
67
+ return finishSystemFieldMutation(ctx, params, committed.value, committed.revision);
68
+ }
69
+ /** Internal storage phase; caller owns the document guard and revision advancement. */
70
+ export async function writeSystemFieldsInTransaction(ctx, params, locked) {
71
+ const { db, collectionId, defaultLocale } = ctx;
72
+ const audit = requireAuditCapability(db);
73
+ const actor = auditActor(ctx);
74
+ const requestLocale = params.locale ?? defaultLocale;
75
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
76
+ const requested = {
77
+ path: explicitPath !== null,
78
+ availableLocales: params.availableLocales !== undefined,
79
+ };
80
+ const requestedLocales = params.availableLocales === undefined ? undefined : [...new Set(params.availableLocales)];
81
+ const sourceLocale = locked.sourceLocale;
82
+ const currentPath = locked.path ?? undefined;
83
+ const currentLocales = [...locked.availableLocales];
84
+ const nextLocales = requestedLocales ?? currentLocales;
85
+ const pathForCommand = resolvePathForUpdate({
86
+ explicitPath,
87
+ currentPath,
88
+ requestLocale,
89
+ sourceLocale,
90
+ documentId: params.documentId,
91
+ logger: ctx.logger,
92
+ });
93
+ const pathChanged = pathForCommand !== undefined && pathForCommand !== currentPath;
94
+ const availableLocalesChanged = requestedLocales !== undefined && !sameLocaleSet(currentLocales, nextLocales);
95
+ if (pathChanged) {
96
+ await db.commands.documents
97
+ .updateDocumentPath({
98
+ documentId: params.documentId,
99
+ collectionId,
100
+ locale: sourceLocale,
101
+ path: pathForCommand,
102
+ })
103
+ .catch((error) => rethrowPathConflict(db, error, pathForCommand, sourceLocale, 'update'));
104
+ await audit.append({
105
+ documentId: params.documentId,
106
+ collectionId,
107
+ actorId: actor.actorId,
108
+ actorRealm: actor.actorRealm,
109
+ action: AUDIT_ACTIONS.pathChanged,
110
+ field: 'path',
111
+ before: currentPath ?? null,
112
+ after: pathForCommand,
145
113
  });
146
- const changed = outcome.pathChanged || outcome.availableLocalesChanged;
147
- const reconciliation = !changed && params.reconcile === true;
148
- if (changed || reconciliation) {
149
- const hooks = await resolveHooks(definition);
114
+ }
115
+ if (availableLocalesChanged) {
116
+ await db.commands.documents.setDocumentAvailableLocales({
117
+ documentId: params.documentId,
118
+ collectionId,
119
+ availableLocales: nextLocales,
120
+ });
121
+ await audit.append({
122
+ documentId: params.documentId,
123
+ collectionId,
124
+ actorId: actor.actorId,
125
+ actorRealm: actor.actorRealm,
126
+ action: AUDIT_ACTIONS.localesChanged,
127
+ field: 'availableLocales',
128
+ before: currentLocales,
129
+ after: nextLocales,
130
+ });
131
+ }
132
+ return {
133
+ requested,
134
+ pathForCommand,
135
+ pathChanged,
136
+ availableLocalesChanged,
137
+ previousPath: currentPath,
138
+ currentPath: pathChanged ? pathForCommand : currentPath,
139
+ previousAvailableLocales: currentLocales,
140
+ currentAvailableLocales: nextLocales,
141
+ };
142
+ }
143
+ export async function finishSystemFieldMutation(ctx, params, outcome, revision) {
144
+ const changed = outcome.pathChanged || outcome.availableLocalesChanged;
145
+ const reconciliation = !changed && params.reconcile === true;
146
+ if (changed || reconciliation) {
147
+ await runCommittedDocumentHook(ctx, {
148
+ phase: 'afterSystemFieldsChange',
149
+ documentId: params.documentId,
150
+ documentVersionId: outcome.documentVersionId,
151
+ revision,
152
+ }, async () => {
153
+ const hooks = await resolveHooks(ctx.definition);
150
154
  await invokeHook(hooks?.afterSystemFieldsChange, {
151
155
  documentId: params.documentId,
152
- collectionPath,
153
- requested,
154
- changed: {
155
- path: outcome.pathChanged,
156
- availableLocales: outcome.availableLocalesChanged,
157
- },
156
+ collectionPath: ctx.collectionPath,
157
+ requested: outcome.requested,
158
+ changed: { path: outcome.pathChanged, availableLocales: outcome.availableLocalesChanged },
158
159
  reconciliation,
159
160
  previousPath: outcome.previousPath,
160
161
  currentPath: outcome.currentPath,
161
162
  previousAvailableLocales: outcome.previousAvailableLocales,
162
163
  currentAvailableLocales: outcome.currentAvailableLocales,
163
164
  });
164
- }
165
- return {
166
- documentId: params.documentId,
167
- path: outcome.pathChanged ? outcome.pathForCommand : undefined,
168
- changed,
169
- reconciliation,
170
- pathChanged: outcome.pathChanged,
171
- availableLocalesChanged: outcome.availableLocalesChanged,
172
- availableLocalesWritten: outcome.availableLocalesChanged,
173
- };
174
- });
165
+ });
166
+ }
167
+ return {
168
+ documentId: params.documentId,
169
+ revision,
170
+ path: outcome.pathChanged ? outcome.pathForCommand : undefined,
171
+ changed,
172
+ reconciliation,
173
+ pathChanged: outcome.pathChanged,
174
+ availableLocalesChanged: outcome.availableLocalesChanged,
175
+ availableLocalesWritten: outcome.availableLocalesChanged,
176
+ };
175
177
  }
@@ -6,9 +6,10 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { type TreeAuditCapability } from './audit.js';
9
- import type { TreeDeleteMutationResult } from '../../@types/index.js';
9
+ import type { StructuralMutationReceipt, TreeDeleteMutationResult } from '../../@types/index.js';
10
10
  import type { DocumentLifecycleContext } from './context.js';
11
11
  interface PlaceParams {
12
+ expectedRevision: number;
12
13
  documentId: string;
13
14
  parentDocumentId: string | null;
14
15
  beforeDocumentId?: string | null;
@@ -20,16 +21,17 @@ interface PlaceParams {
20
21
  /** Place, re-parent, or reorder a node; explicit no-op retries may reconcile hooks. */
21
22
  export declare function placeTreeNode(ctx: DocumentLifecycleContext, params: PlaceParams): Promise<{
22
23
  orderKey: string;
23
- }>;
24
- /** Best-effort create/self-heal primitive that never moves an already-placed node. */
24
+ } & StructuralMutationReceipt>;
25
+ /** Internal placement, only inside the initiating create/save transaction. */
25
26
  export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
26
- /** Best-effort post-version repair; locked `ifUnplaced` closes the check/write race. */
27
+ /** Placement failure aborts the initiating save, including its version and audit. */
27
28
  export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
28
29
  /** Remove a node to the unplaced state; an already-unplaced node is a true no-op. */
29
30
  export declare function removeFromTree(ctx: DocumentLifecycleContext, params: {
30
31
  documentId: string;
32
+ expectedRevision: number;
31
33
  reconcile?: boolean;
32
- }): Promise<void>;
34
+ }): Promise<StructuralMutationReceipt>;
33
35
  /**
34
36
  * Reconcile delete-time edges and append parent plus child-specific audit rows.
35
37
  * The caller owns the transaction; delete uses this beside soft-delete/audit.
@@ -40,5 +42,6 @@ export declare function firePromoteTreeChange(ctx: DocumentLifecycleContext, doc
40
42
  /** Standalone audited reconciliation used by internal tooling and tests. */
41
43
  export declare function promoteChildrenAndRemove(ctx: DocumentLifecycleContext, params: {
42
44
  documentId: string;
43
- }): Promise<void>;
45
+ expectedRevision: number;
46
+ }): Promise<StructuralMutationReceipt>;
44
47
  export {};