@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
@@ -0,0 +1,83 @@
1
+ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
2
+ import { ERR_CONFLICT, ERR_VALIDATION } from '../../lib/errors.js';
3
+ import { generateKeyBetween, generateNKeysBetween } from '../../lib/fractional-index.js';
4
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
5
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
6
+ /** Flat ordering and any sibling-key repairs share one guarded transaction. */
7
+ export async function reorderDocument(ctx, params) {
8
+ assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
9
+ await readDocumentForMutation(ctx, params);
10
+ if (ctx.definition.orderable !== true || ctx.definition.tree === true)
11
+ throw ERR_VALIDATION({ message: 'Flat reorder requires an orderable, non-tree collection' });
12
+ const audit = requireAuditCapability(ctx.db);
13
+ const committed = await commitGuardedDocumentMutation(ctx, params, async () => {
14
+ const rows = await ctx.db.withReadSnapshot((queries) => queries.documents.getCanonicalDocumentOrder({ collection_id: ctx.collectionId }));
15
+ const keys = new Map(rows.map((row) => [row.id, row.order_key]));
16
+ const keyed = rows.filter((row) => row.order_key !== null);
17
+ const corrupt = keyed.some((row, index) => {
18
+ if (index === 0)
19
+ return false;
20
+ const previous = keyed[index - 1];
21
+ return previous !== undefined && row.order_key <= previous.order_key;
22
+ });
23
+ if (corrupt) {
24
+ const replacements = generateNKeysBetween(null, null, rows.length);
25
+ rows.forEach((row, index) => {
26
+ keys.set(row.id, replacements[index]);
27
+ });
28
+ }
29
+ else {
30
+ const missing = rows.filter((row) => row.order_key === null);
31
+ const replacements = generateNKeysBetween(keyed.at(-1)?.order_key ?? null, null, missing.length);
32
+ missing.forEach((row, index) => {
33
+ keys.set(row.id, replacements[index]);
34
+ });
35
+ }
36
+ const siblings = rows.filter((row) => row.id !== params.documentId);
37
+ let leftId = params.beforeDocumentId ?? null;
38
+ const rightId = params.afterDocumentId ?? null;
39
+ if (leftId === null && rightId === null)
40
+ leftId = siblings.at(-1)?.id ?? null;
41
+ const leftIndex = leftId === null ? -1 : siblings.findIndex((row) => row.id === leftId);
42
+ const rightIndex = rightId === null ? siblings.length : siblings.findIndex((row) => row.id === rightId);
43
+ if ((leftId !== null && leftIndex < 0) ||
44
+ (rightId !== null && rightIndex < 0) ||
45
+ rightIndex !== leftIndex + 1)
46
+ throw ERR_CONFLICT({
47
+ message: 'The supplied ordering neighbors have changed. Reload before reordering.',
48
+ });
49
+ const left = leftId === null ? null : (keys.get(leftId) ?? null);
50
+ const right = rightId === null ? null : (keys.get(rightId) ?? null);
51
+ const current = keys.get(params.documentId);
52
+ const orderKey = current && (left === null || left < current) && (right === null || current < right)
53
+ ? current
54
+ : generateKeyBetween(left, right);
55
+ keys.set(params.documentId, orderKey);
56
+ let changed = false;
57
+ for (const row of rows) {
58
+ const next = keys.get(row.id);
59
+ if (next === undefined || next === null || next === row.order_key)
60
+ continue;
61
+ await ctx.db.commands.documents.setOrderKey({ document_id: row.id, order_key: next });
62
+ await audit.append({
63
+ documentId: row.id,
64
+ collectionId: ctx.collectionId,
65
+ ...auditActor(ctx),
66
+ action: AUDIT_ACTIONS.orderChanged,
67
+ field: 'order_key',
68
+ before: row.order_key,
69
+ after: next,
70
+ });
71
+ if (row.id === params.documentId)
72
+ changed = true;
73
+ }
74
+ return { value: orderKey, changed };
75
+ }, { structuralScope: 'collection' });
76
+ return {
77
+ documentId: params.documentId,
78
+ orderKey: committed.value,
79
+ revision: committed.revision,
80
+ affectedDocuments: committed.affectedDocuments,
81
+ scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
82
+ };
83
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { DocumentLifecycleContext } from './context.js';
9
9
  export interface RestoreVersionResult {
10
+ revision: number;
10
11
  documentId: string;
11
12
  documentVersionId: string;
12
13
  sourceVersionId: string;
@@ -53,5 +54,6 @@ export interface RestoreVersionResult {
53
54
  */
54
55
  export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, params: {
55
56
  documentId: string;
57
+ expectedRevision: number;
56
58
  sourceVersionId: string;
57
59
  }): Promise<RestoreVersionResult>;
@@ -13,6 +13,7 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
13
13
  import { runCommittedDocumentHook } from './committed-hook.js';
14
14
  import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
15
15
  import { persistExistingDocumentVersion } from './persistence.js';
16
+ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
16
17
  /**
17
18
  * Restore a historical document version as the new current version.
18
19
  *
@@ -54,9 +55,12 @@ import { persistExistingDocumentVersion } from './persistence.js';
54
55
  * 8. `hooks.afterUpdate({ ..., restore })`
55
56
  */
56
57
  export async function restoreDocumentVersion(ctx, params) {
58
+ params = { ...params };
57
59
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
58
60
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
59
61
  assertActorCanPerform(ctx.requestContext, definition, 'update');
62
+ const latest = await readDocumentForMutation(ctx, { ...params, locale: 'all' });
63
+ const previousVersionId = latest.document_version_id;
60
64
  const hooks = await resolveHooks(definition);
61
65
  // 1. Read source version (full multi-locale tree).
62
66
  const source = await db.queries.documents.getDocumentByVersion({
@@ -82,17 +86,7 @@ export async function restoreDocumentVersion(ctx, params) {
82
86
  }
83
87
  // 3. Current version metadata — used both for the already-current
84
88
  // guard and for the sticky path resolution.
85
- const currentMeta = await db.queries.documents.getCurrentVersionMetadata({
86
- collection_id: collectionId,
87
- document_id: params.documentId,
88
- });
89
- if (currentMeta == null) {
90
- throw ERR_NOT_FOUND({
91
- message: 'document not found',
92
- details: { documentId: params.documentId },
93
- }).log(ctx.logger);
94
- }
95
- if (currentMeta.document_version_id === params.sourceVersionId) {
89
+ if (previousVersionId === params.sourceVersionId) {
96
90
  throw ERR_INVALID_TRANSITION({
97
91
  message: 'source version is already the current version of this document',
98
92
  details: {
@@ -103,12 +97,6 @@ export async function restoreDocumentVersion(ctx, params) {
103
97
  }
104
98
  // 4. originalData for hooks: full reconstruction of the current
105
99
  // version (locale-scoped, matching updateDocument's semantics).
106
- const latest = await db.queries.documents.getDocumentById({
107
- collection_id: collectionId,
108
- document_id: params.documentId,
109
- locale: defaultLocale,
110
- reconstruct: true,
111
- });
112
100
  const originalData = latest ?? {};
113
101
  const sourceFields = source.fields ?? {};
114
102
  const restoreContext = { sourceVersionId: params.sourceVersionId };
@@ -131,19 +119,24 @@ export async function restoreDocumentVersion(ctx, params) {
131
119
  //
132
120
  // No `path` is passed: restore does not change the document's path
133
121
  // (the existing byline_document_paths row stays as-is — sticky).
134
- const result = await persistExistingDocumentVersion(ctx, {
135
- documentId: params.documentId,
136
- action: 'restore',
137
- documentData: sourceFields,
138
- status: getDefaultStatus(definition),
139
- locale: 'all',
140
- previousVersionId: currentMeta.document_version_id,
122
+ const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: 'all' }, async () => {
123
+ const result = await persistExistingDocumentVersion(ctx, {
124
+ documentId: params.documentId,
125
+ action: 'restore',
126
+ documentData: sourceFields,
127
+ status: getDefaultStatus(definition),
128
+ locale: 'all',
129
+ previousVersionId: previousVersionId,
130
+ });
131
+ return { value: result, changed: true };
141
132
  });
133
+ const result = committed.value;
134
+ const revision = committed.revision;
142
135
  const documentId = extractDocumentId(result.document) || params.documentId;
143
136
  const documentVersionId = extractVersionId(result.document);
144
137
  // 7. afterUpdate. Restore is path-sticky: the canonical path comes
145
138
  // from the current version's envelope (originalData), not the source.
146
- await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId }, () => invokeHook(hooks?.afterUpdate, {
139
+ await runCommittedDocumentHook(ctx, { phase: 'afterUpdate', documentId, documentVersionId, revision }, () => invokeHook(hooks?.afterUpdate, {
147
140
  data: sourceFields,
148
141
  originalData,
149
142
  collectionPath,
@@ -156,6 +149,7 @@ export async function restoreDocumentVersion(ctx, params) {
156
149
  documentId,
157
150
  documentVersionId,
158
151
  sourceVersionId: params.sourceVersionId,
152
+ revision,
159
153
  };
160
154
  });
161
155
  }
@@ -1,8 +1 @@
1
- /**
2
- * This Source Code is subject to the terms of the Mozilla Public
3
- * License, v. 2.0. If a copy of the MPL was not distributed with this
4
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
- *
6
- * Copyright (c) Infonomic Company Limited
7
- */
8
1
  export {};
@@ -1,3 +1,4 @@
1
+ import { testAdapter } from '../../storage/db-adapter.test-helper.js';
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -8,7 +9,7 @@
8
9
  import { describe, expect, it } from 'vitest';
9
10
  import { ErrorCodes } from '../../lib/errors.js';
10
11
  import { rethrowPathConflict } from './internals.js';
11
- const adapterWith = (c) => ({ classifyError: c === undefined ? undefined : () => c });
12
+ const adapterWith = (c) => testAdapter({ classifyError: c === undefined ? undefined : () => c });
12
13
  describe('rethrowPathConflict', () => {
13
14
  it.each([
14
15
  [
@@ -0,0 +1,34 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { DocumentRevisionTarget, LockedDocumentRevision } from '../../@types/index.js';
9
+ import type { DocumentLifecycleContext } from './context.js';
10
+ /** Public lifecycle services own their commit boundary, including post-commit hooks. */
11
+ export declare function assertLifecycleTransactionOwnership(ctx: DocumentLifecycleContext): void;
12
+ /** Authorized preflight: reject known staleness before hooks or external preparation. */
13
+ export declare function readDocumentForMutation(ctx: DocumentLifecycleContext, params: {
14
+ documentId: string;
15
+ expectedRevision: number;
16
+ locale?: string;
17
+ lenient?: boolean;
18
+ }): Promise<Record<string, any>>;
19
+ /** Internal final boundary. The callback contains storage work only, never user hooks. */
20
+ export declare function commitGuardedDocumentMutation<T>(ctx: DocumentLifecycleContext, target: Omit<DocumentRevisionTarget, 'collectionId'>, write: (locked: LockedDocumentRevision) => Promise<{
21
+ value: T;
22
+ changed: boolean;
23
+ }>, options?: {
24
+ collectionLock?: 'exclusive';
25
+ structuralScope?: 'collection';
26
+ }): Promise<{
27
+ value: T;
28
+ revision: number;
29
+ affectedDocuments: {
30
+ documentId: string;
31
+ revision: number;
32
+ }[];
33
+ scheduledPublicationsNeedReconfirmation: boolean;
34
+ }>;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { documentAbilityKey } from '../../auth/register-collection-abilities.js';
9
+ import { ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
10
+ import { documentRevisionFromDatabase, parseDocumentRevision, } from '../../storage/document-revision.js';
11
+ import { requireAuditCapability } from './audit.js';
12
+ import { suspendPublishScheduleForEdit } from './publish-schedule-consistency.js';
13
+ /** Public lifecycle services own their commit boundary, including post-commit hooks. */
14
+ export function assertLifecycleTransactionOwnership(ctx) {
15
+ if (ctx.db.revisions.isInTransaction()) {
16
+ throw ERR_VALIDATION({
17
+ message: 'Public document lifecycle operations cannot run inside an externally owned transaction',
18
+ details: { reason: 'external_lifecycle_transaction' },
19
+ });
20
+ }
21
+ }
22
+ /** Authorized preflight: reject known staleness before hooks or external preparation. */
23
+ export async function readDocumentForMutation(ctx, params) {
24
+ assertLifecycleTransactionOwnership(ctx);
25
+ const expectedRevision = parseDocumentRevision(params.expectedRevision);
26
+ return ctx.db.withReadSnapshot(async (queries) => {
27
+ const source = await queries.documents.getDocumentById({
28
+ collection_id: ctx.collectionId,
29
+ document_id: params.documentId,
30
+ locale: params.locale ?? ctx.defaultLocale,
31
+ reconstruct: true,
32
+ readMode: 'any',
33
+ lenient: params.lenient,
34
+ requestContext: ctx.requestContext,
35
+ });
36
+ if (source == null)
37
+ throw ERR_NOT_FOUND({
38
+ message: 'document not found',
39
+ details: { documentId: params.documentId },
40
+ });
41
+ const currentRevision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision({
42
+ collection_id: ctx.collectionId,
43
+ document_id: params.documentId,
44
+ }));
45
+ if (currentRevision !== expectedRevision)
46
+ throw ERR_DOCUMENT_STALE({
47
+ message: 'This document has changed. Reload it before making changes.',
48
+ details: {
49
+ reason: 'revision_mismatch',
50
+ documentId: params.documentId,
51
+ expectedRevision,
52
+ currentRevision,
53
+ },
54
+ });
55
+ return source;
56
+ });
57
+ }
58
+ /** Internal final boundary. The callback contains storage work only, never user hooks. */
59
+ export async function commitGuardedDocumentMutation(ctx, target, write, options = {}) {
60
+ assertLifecycleTransactionOwnership(ctx);
61
+ target = { ...target, expectedRevision: parseDocumentRevision(target.expectedRevision) };
62
+ const audit = requireAuditCapability(ctx.db);
63
+ return audit.withTransaction(async () => {
64
+ // Acquire the collection lock before any document lock, including FK locks
65
+ // from version/path/audit inserts. Ordinary writers share it; exclusive
66
+ // operations select their mode up front, never upgrading after locking a document.
67
+ const mode = options.structuralScope === 'collection' ||
68
+ options.collectionLock === 'exclusive' ||
69
+ ctx.definition.singleton === true ||
70
+ ctx.definition.tree === true
71
+ ? 'exclusive'
72
+ : 'shared';
73
+ await ctx.db.commands.collections.lockCollectionRegistration(ctx.collectionId, mode);
74
+ if (ctx.definition.singleton === true) {
75
+ const mapped = await ctx.db.queries.singletons.getMappedDocumentId(ctx.collectionId);
76
+ if (mapped !== target.documentId)
77
+ throw ERR_NOT_FOUND({ message: 'Singleton target is unavailable; reload before saving' });
78
+ }
79
+ const structural = ctx.definition.tree === true || options.structuralScope === 'collection';
80
+ const before = structural
81
+ ? await ctx.db.revisions.readStructure({
82
+ collectionId: ctx.collectionId,
83
+ ...(options.structuralScope === 'collection'
84
+ ? {}
85
+ : { documentIds: [target.documentId], parentDocumentId: target.documentId }),
86
+ })
87
+ : [];
88
+ const targets = before
89
+ .filter((row) => row.live && row.documentId !== target.documentId)
90
+ .map((row) => ({
91
+ documentId: row.documentId,
92
+ collectionId: ctx.collectionId,
93
+ expectedRevision: row.revision,
94
+ }));
95
+ const lockedDocuments = await ctx.db.revisions.lock([
96
+ ...targets,
97
+ { ...target, collectionId: ctx.collectionId },
98
+ ]);
99
+ const locked = lockedDocuments.find((row) => row.documentId === target.documentId);
100
+ if (!locked)
101
+ throw new Error('Revision guard returned no locked observation');
102
+ if (structural)
103
+ await ctx.db.commands.documents.publishSchedules.lockDocuments(lockedDocuments.map((row) => row.documentId));
104
+ const outcome = await write(locked);
105
+ const after = structural
106
+ ? await ctx.db.revisions.readStructure({
107
+ collectionId: ctx.collectionId,
108
+ documentIds: before.map((row) => row.documentId),
109
+ })
110
+ : [];
111
+ const structuralChanges = new Set();
112
+ for (const previous of before) {
113
+ const current = after.find((row) => row.documentId === previous.documentId);
114
+ if (current &&
115
+ (current.orderKey !== previous.orderKey ||
116
+ current.placed !== previous.placed ||
117
+ current.parentDocumentId !== previous.parentDocumentId ||
118
+ current.treeOrderKey !== previous.treeOrderKey))
119
+ structuralChanges.add(previous.documentId);
120
+ }
121
+ // Legacy/internal writes can leave edges attached to unavailable documents.
122
+ // Never commit a derived structural write that was not revision-locked.
123
+ if ([...structuralChanges].some((id) => !lockedDocuments.some((row) => row.documentId === id)))
124
+ throw ERR_NOT_FOUND({
125
+ message: 'A structural target is unavailable; reload before changing this tree.',
126
+ });
127
+ let suspended = false;
128
+ const affectedDocuments = [];
129
+ let revision = locked.revision;
130
+ for (const document of lockedDocuments) {
131
+ const changedStructure = structuralChanges.has(document.documentId);
132
+ if (changedStructure)
133
+ suspended =
134
+ (await suspendPublishScheduleForEdit(ctx, audit, document.documentId, 'document_metadata_changed')) || suspended;
135
+ if ((document.documentId === target.documentId && outcome.changed) || changedStructure) {
136
+ const receipt = await ctx.db.revisions.advance(document);
137
+ affectedDocuments.push(receipt);
138
+ if (document.documentId === target.documentId)
139
+ revision = receipt.revision;
140
+ }
141
+ }
142
+ const actor = ctx.requestContext?.actor;
143
+ const canSeeSchedules = actor?.hasAbility(documentAbilityKey(ctx.definition, 'publish')) === true &&
144
+ actor.hasAbility(documentAbilityKey(ctx.definition, 'changeStatus'));
145
+ return {
146
+ value: outcome.value,
147
+ revision,
148
+ affectedDocuments: actor?.hasAbility(documentAbilityKey(ctx.definition, 'read'))
149
+ ? affectedDocuments
150
+ : affectedDocuments.filter((row) => row.documentId === target.documentId),
151
+ scheduledPublicationsNeedReconfirmation: canSeeSchedules && suspended,
152
+ };
153
+ });
154
+ }
@@ -0,0 +1,2 @@
1
+ /** Compare reconstructed field data without treating omitted optional keys as edits. */
2
+ export declare function sameDocumentData(left: unknown, right: unknown): boolean;
@@ -0,0 +1,17 @@
1
+ /** Compare reconstructed field data without treating omitted optional keys as edits. */
2
+ export function sameDocumentData(left, right) {
3
+ if (Object.is(left, right))
4
+ return true;
5
+ if (left instanceof Date && right instanceof Date)
6
+ return left.getTime() === right.getTime();
7
+ if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object')
8
+ return false;
9
+ if (Array.isArray(left) || Array.isArray(right))
10
+ return (Array.isArray(left) &&
11
+ Array.isArray(right) &&
12
+ left.length === right.length &&
13
+ left.every((value, index) => sameDocumentData(value, right[index])));
14
+ const a = left, b = right;
15
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
16
+ return [...keys].every((key) => sameDocumentData(a[key], b[key]));
17
+ }
@@ -15,6 +15,7 @@ export type ClaimedScheduledPublicationResult = {
15
15
  status: 'claim_lost';
16
16
  } | {
17
17
  status: 'target_changed';
18
+ reason: 'content_edited' | 'document_metadata_changed';
18
19
  } | {
19
20
  status: 'terminal';
20
21
  reason: 'document_not_found' | 'already_published' | 'invalid_transition';
@@ -23,14 +24,24 @@ export declare function scheduleDocumentPublish(ctx: DocumentLifecycleContext, p
23
24
  documentId: string;
24
25
  publishAt: string;
25
26
  expectedVersionId: string;
26
- }): Promise<DocumentPublishSchedule>;
27
+ expectedRevision: number;
28
+ }): Promise<DocumentPublishSchedule & {
29
+ revision: number;
30
+ }>;
27
31
  export declare function confirmDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
28
32
  documentId: string;
29
33
  expectedVersionId: string;
30
- }): Promise<DocumentPublishSchedule>;
34
+ expectedRevision: number;
35
+ }): Promise<DocumentPublishSchedule & {
36
+ revision: number;
37
+ }>;
31
38
  export declare function cancelDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
32
39
  documentId: string;
33
- }): Promise<DocumentPublishSchedule | null>;
40
+ expectedRevision: number;
41
+ }): Promise<{
42
+ schedule: DocumentPublishSchedule | null;
43
+ revision: number;
44
+ }>;
34
45
  export declare function getDocumentScheduledPublish(ctx: DocumentLifecycleContext, params: {
35
46
  documentId: string;
36
47
  }): Promise<DocumentPublishSchedule | null>;
@@ -39,7 +50,12 @@ export declare function listDocumentPublishSchedules(core: BylineCore, requestCo
39
50
  lastAuthorizedBy?: string;
40
51
  page: number;
41
52
  pageSize: number;
42
- }): Promise<DocumentPublishSchedulePage>;
53
+ }): Promise<Omit<DocumentPublishSchedulePage, 'schedules'> & {
54
+ schedules: (DocumentPublishSchedule & {
55
+ revision: number;
56
+ documentPath: string | null;
57
+ })[];
58
+ }>;
43
59
  /**
44
60
  * Execute one token-fenced publication through the normal status lifecycle.
45
61
  * Internal to the server-only sweep: authorization was captured when the row