@byline/core 4.19.0 → 5.1.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 (119) 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/@types/site-config.d.ts +3 -1
  7. package/dist/config/attach-hooks.test.node.js +2 -1
  8. package/dist/config/config-hooks.test.node.js +2 -1
  9. package/dist/core.js +13 -1
  10. package/dist/core.test.node.js +27 -2
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lib/errors.d.ts +15 -0
  14. package/dist/lib/errors.js +32 -0
  15. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  16. package/dist/lib/lock-conflict.test.node.js +31 -0
  17. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  18. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  19. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  20. package/dist/scheduler/scheduled-publication.js +27 -1
  21. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  22. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  23. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  24. package/dist/scheduler/scheduler-boot.test.node.js +54 -12
  25. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  26. package/dist/scheduler/ticker.test.node.js +3 -2
  27. package/dist/scheduler/validate-scheduler-config.d.ts +2 -0
  28. package/dist/scheduler/validate-scheduler-config.js +5 -0
  29. package/dist/scheduler/validate-scheduler-config.test.node.js +16 -0
  30. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  31. package/dist/services/collection-bootstrap.test.node.js +12 -3
  32. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  33. package/dist/services/discover-counter-groups.test.node.js +19 -3
  34. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  35. package/dist/services/document-lifecycle/audit.js +1 -0
  36. package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
  37. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  38. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  39. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  40. package/dist/services/document-lifecycle/create.d.ts +1 -0
  41. package/dist/services/document-lifecycle/create.js +4 -20
  42. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  43. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  44. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  45. package/dist/services/document-lifecycle/delete.js +24 -15
  46. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  47. package/dist/services/document-lifecycle/duplicate.js +48 -41
  48. package/dist/services/document-lifecycle/index.d.ts +3 -1
  49. package/dist/services/document-lifecycle/index.js +3 -1
  50. package/dist/services/document-lifecycle/persistence.js +18 -7
  51. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  52. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  53. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  54. package/dist/services/document-lifecycle/reorder.js +83 -0
  55. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  56. package/dist/services/document-lifecycle/restore.js +19 -25
  57. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  58. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  59. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  60. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  61. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  62. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  63. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  64. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  65. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  66. package/dist/services/document-lifecycle/stale-document.js +95 -0
  67. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  68. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  69. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  70. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  71. package/dist/services/document-lifecycle/status.d.ts +5 -21
  72. package/dist/services/document-lifecycle/status.js +90 -133
  73. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  74. package/dist/services/document-lifecycle/system-fields.js +118 -116
  75. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  76. package/dist/services/document-lifecycle/tree.js +65 -39
  77. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  78. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  79. package/dist/services/document-lifecycle/update.d.ts +14 -6
  80. package/dist/services/document-lifecycle/update.js +101 -80
  81. package/dist/services/document-lifecycle.test.node.js +401 -73
  82. package/dist/services/field-upload.d.ts +1 -0
  83. package/dist/services/field-upload.js +5 -1
  84. package/dist/services/field-upload.test.node.d.ts +0 -7
  85. package/dist/services/field-upload.test.node.js +38 -2
  86. package/dist/services/populate.test.node.d.ts +0 -7
  87. package/dist/services/populate.test.node.js +19 -3
  88. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  89. package/dist/services/richtext-populate.test.node.js +3 -2
  90. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  91. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  92. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  93. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  94. package/dist/services/singleton-lifecycle/internals.js +74 -77
  95. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  96. package/dist/services/singleton-lifecycle/restore.js +3 -1
  97. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  98. package/dist/services/singleton-lifecycle/update.js +158 -91
  99. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  100. package/dist/services/singleton-lifecycle.test.node.js +67 -23
  101. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  102. package/dist/storage/db-adapter.test-helper.js +117 -0
  103. package/dist/storage/document-revision-capability.d.ts +3 -0
  104. package/dist/storage/document-revision-capability.js +15 -0
  105. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision-capability.test.node.js +53 -0
  107. package/dist/storage/document-revision.d.ts +12 -0
  108. package/dist/storage/document-revision.js +37 -0
  109. package/dist/storage/document-revision.test.node.d.ts +1 -0
  110. package/dist/storage/document-revision.test.node.js +48 -0
  111. package/dist/storage/read-snapshot.d.ts +3 -0
  112. package/dist/storage/read-snapshot.js +55 -0
  113. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  114. package/dist/storage/revision-store.test-helper.js +16 -0
  115. package/dist/validation/password-sign-in.test.node.d.ts +8 -0
  116. package/dist/validation/password-sign-in.test.node.js +21 -0
  117. package/dist/validation/shared.d.ts +5 -11
  118. package/dist/validation/shared.js +7 -1
  119. package/package.json +8 -8
@@ -2,6 +2,7 @@ import type { RequestContext } from '@byline/auth';
2
2
  import type { CollectionDefinition } from '@byline/core';
3
3
  import type { DbErrorClassification } from '../lib/errors.js';
4
4
  import type { ISchedulerStore } from '../scheduler/types.js';
5
+ import type { DocumentRevisionStore } from './document-revision.js';
5
6
  /**
6
7
  * Read mode for document queries.
7
8
  *
@@ -202,7 +203,18 @@ export interface FieldSort {
202
203
  /** Sort direction. */
203
204
  direction: 'asc' | 'desc';
204
205
  }
206
+ /** Query-only executor facade, valid only during its snapshot callback. */
207
+ export interface ReadSnapshotQueries {
208
+ collections: ICollectionQueries;
209
+ documents: Omit<IDocumentQueries, 'getDocumentSystemFieldsForUpdate'>;
210
+ audit: IAuditQueries;
211
+ singletons: ISingletonQueries;
212
+ }
205
213
  export interface IDbAdapter {
214
+ /** Dedicated read-only repeatable-read transaction; never joins ambient writes. */
215
+ withReadSnapshot<T>(fn: (queries: ReadSnapshotQueries) => Promise<T>): Promise<T>;
216
+ /** Required revision schema and transaction-scoped guard capability. */
217
+ revisions: DocumentRevisionStore;
206
218
  commands: {
207
219
  collections: ICollectionCommands;
208
220
  documents: IDocumentCommands;
@@ -444,6 +456,11 @@ export interface ICounterCommands {
444
456
  nextScopedCounterValue(scopeName: string): Promise<number>;
445
457
  }
446
458
  export interface ICollectionCommands {
459
+ /** Acquire before document locks, inside an owned transaction. Shared locks
460
+ * admit independent writers; exclusive locks coordinate deletion/maintenance.
461
+ * Select the mode before locking documents; never upgrade a shared lock.
462
+ */
463
+ lockCollectionRegistration(collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
447
464
  /**
448
465
  * Insert a new collection row. `opts.version` and `opts.schemaHash` are
449
466
  * used by the startup bootstrap to anchor initial values; omitted by
@@ -541,7 +558,7 @@ export interface TreeDeleteMutationResult {
541
558
  /** Durable state of one document-grain scheduled-publication intent. */
542
559
  export type DocumentPublishScheduleState = 'armed' | 'needs_reconfirm';
543
560
  /** Why an armed schedule stopped being executable. */
544
- type DocumentPublishScheduleSuspendedReason = 'content_edited';
561
+ type DocumentPublishScheduleSuspendedReason = 'content_edited' | 'document_metadata_changed' | 'upgrade_invalidated';
545
562
  /**
546
563
  * Adapter-neutral representation of `byline_document_publish_schedules`.
547
564
  *
@@ -550,6 +567,8 @@ type DocumentPublishScheduleSuspendedReason = 'content_edited';
550
567
  * know whether the backing engine uses `timestamptz` or `datetime(6)`.
551
568
  */
552
569
  export interface DocumentPublishSchedule {
570
+ /** Revision authorized by the last arm/reconfirm; null for legacy suspended rows. */
571
+ authorizedRevision: number | null;
553
572
  documentId: string;
554
573
  collectionId: string;
555
574
  targetVersionId: string;
@@ -613,6 +632,8 @@ export interface DocumentPublishSchedulePage {
613
632
  * operations: core owns abilities, workflow validation, hooks, and audit.
614
633
  */
615
634
  export interface IDocumentPublishScheduleCommands {
635
+ /** Lock existing schedules in stable identity order after every affected document lock. */
636
+ lockDocuments(documentIds: readonly string[]): Promise<void>;
616
637
  /**
617
638
  * Create or reschedule one document's intent.
618
639
  *
@@ -635,6 +656,7 @@ export interface IDocumentPublishScheduleCommands {
635
656
  documentId: string;
636
657
  collectionId: string;
637
658
  expectedVersionId: string;
659
+ authorizedRevision: number;
638
660
  publishAt: Date;
639
661
  actorId: string | null;
640
662
  }): Promise<ScheduleDocumentPublishResult>;
@@ -649,6 +671,7 @@ export interface IDocumentPublishScheduleCommands {
649
671
  documentId: string;
650
672
  collectionId: string;
651
673
  expectedVersionId: string;
674
+ authorizedRevision: number;
652
675
  actorId: string | null;
653
676
  }): Promise<ConfirmDocumentPublishScheduleResult>;
654
677
  /**
@@ -670,6 +693,7 @@ export interface IDocumentPublishScheduleCommands {
670
693
  suspendForContentEdit(params: {
671
694
  documentId: string;
672
695
  collectionId: string;
696
+ reason?: 'content_edited' | 'document_metadata_changed';
673
697
  }): Promise<SuspendDocumentPublishScheduleResult>;
674
698
  /**
675
699
  * Atomically claim up to `batchSize` due rows, oldest `publishAt` first.
@@ -709,6 +733,7 @@ export interface IDocumentPublishScheduleCommands {
709
733
  * stale token without mutating the newer claimant's row.
710
734
  */
711
735
  suspendClaimForContentEdit(params: {
736
+ reason?: 'content_edited' | 'document_metadata_changed';
712
737
  documentId: string;
713
738
  executionToken: string;
714
739
  }): Promise<boolean>;
@@ -880,6 +905,7 @@ export interface IDocumentCommands {
880
905
  */
881
906
  restoreSoftDeletedDocument(params: {
882
907
  document_id: string;
908
+ expectedRevision: number;
883
909
  }): Promise<number>;
884
910
  /**
885
911
  * Remove one content locale's data from a document by writing a new
@@ -978,6 +1004,11 @@ export interface ICollectionQueries {
978
1004
  getCollectionById(id: string): Promise<any>;
979
1005
  }
980
1006
  export interface IDocumentQueries {
1007
+ /** Internal observation; expose only alongside source data from the same snapshot. */
1008
+ getDocumentRevision(params: {
1009
+ collection_id: string;
1010
+ document_id: string;
1011
+ }): Promise<number | null>;
981
1012
  /** Scheduled-publication reads; callers resolve collection abilities above storage. */
982
1013
  publishSchedules: IDocumentPublishScheduleQueries;
983
1014
  /**
@@ -0,0 +1,92 @@
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
+ /** The revision observed before preparing an existing-document mutation. */
9
+ export interface DocumentWritePrecondition {
10
+ expectedRevision: number;
11
+ }
12
+ /** An empty singleton slot must be asserted explicitly, never by omission. */
13
+ export type SingletonSavePrecondition = {
14
+ expectedState: 'empty';
15
+ expectedRevision?: never;
16
+ } | {
17
+ expectedRevision: number;
18
+ expectedState?: never;
19
+ };
20
+ /** Returned only for the document state committed by a mutation. */
21
+ export interface DocumentRevisionReceipt {
22
+ documentId: string;
23
+ revision: number;
24
+ }
25
+ /** Preserve operation-specific results, including committed side-effect failures. */
26
+ export type DocumentMutationResult<T extends object> = T & DocumentRevisionReceipt;
27
+ export interface StructuralMutationReceipt extends DocumentRevisionReceipt {
28
+ /** Hosts must authorize/filter these identities before returning them to clients. */
29
+ affectedDocuments: DocumentRevisionReceipt[];
30
+ scheduledPublicationsNeedReconfirmation: boolean;
31
+ }
32
+ export type DocumentStaleDetails = {
33
+ reason: 'revision_mismatch';
34
+ documentId: string;
35
+ expectedRevision: number;
36
+ currentRevision: number;
37
+ } | {
38
+ reason: 'version_parent_mismatch';
39
+ documentId: string;
40
+ previousVersionId: string;
41
+ currentVersionId: string | null;
42
+ } | {
43
+ reason: 'singleton_slot_changed';
44
+ singletonPath: string;
45
+ expectedState: 'empty';
46
+ currentState: 'document';
47
+ };
48
+ export type DocumentRevisionValidationDetails = {
49
+ reason: 'missing_document_revision';
50
+ } | {
51
+ reason: 'invalid_document_revision';
52
+ };
53
+ /** Adapter-internal inputs. Lifecycle services authorize these targets before locking. */
54
+ export interface DocumentRevisionTarget extends DocumentWritePrecondition {
55
+ documentId: string;
56
+ collectionId: string;
57
+ previousVersionId?: string;
58
+ locale?: string;
59
+ }
60
+ /** An adapter-issued observation valid only inside its originating transaction. */
61
+ export interface LockedDocumentRevision extends DocumentRevisionReceipt {
62
+ readonly collectionId: string;
63
+ readonly currentVersionId: string | null;
64
+ readonly status: string | null;
65
+ readonly sourceLocale: string;
66
+ readonly path: string | null;
67
+ readonly availableLocales: readonly string[];
68
+ }
69
+ /** Transaction-scoped structural state; used under an exclusive collection lock. */
70
+ export interface DocumentStructuralState {
71
+ documentId: string;
72
+ revision: number;
73
+ orderKey: string | null;
74
+ placed: boolean;
75
+ parentDocumentId: string | null;
76
+ treeOrderKey: string | null;
77
+ live: boolean;
78
+ }
79
+ /** Storage primitives; public SDK mutations compose them inside the service boundary. */
80
+ export interface DocumentRevisionStore {
81
+ readStructure(params: {
82
+ collectionId: string;
83
+ documentIds?: readonly string[];
84
+ parentDocumentId?: string;
85
+ }): Promise<DocumentStructuralState[]>;
86
+ assertCompatibleSchema(): Promise<void>;
87
+ isInTransaction(): boolean;
88
+ /** Lock in stable document identity order, then validate every observation before returning. */
89
+ lock(targets: readonly DocumentRevisionTarget[]): Promise<readonly LockedDocumentRevision[]>;
90
+ /** Advance an authentic locked observation exactly once; participates in rollback/savepoints. */
91
+ advance(locked: LockedDocumentRevision): Promise<DocumentRevisionReceipt>;
92
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -8,6 +8,7 @@
8
8
  export * from './admin-types.js';
9
9
  export * from './collection-types.js';
10
10
  export * from './db-types.js';
11
+ export * from './document-revision.js';
11
12
  export * from './field-data-types.js';
12
13
  export * from './field-types.js';
13
14
  export * from './populate-types.js';
@@ -8,6 +8,7 @@
8
8
  export * from './admin-types.js';
9
9
  export * from './collection-types.js';
10
10
  export * from './db-types.js';
11
+ export * from './document-revision.js';
11
12
  export * from './field-data-types.js';
12
13
  export * from './field-types.js';
13
14
  export * from './populate-types.js';
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { SessionProvider } from '@byline/auth';
8
+ import type { PasswordSignInProtection, SessionProvider } from '@byline/auth';
9
9
  import type { RecurringTaskDefinition } from '../scheduler/types.js';
10
10
  import type { SlugifierFn } from '../utils/slugify.js';
11
11
  import type { FilenameSlugifierFn } from '../utils/slugify-filename.js';
@@ -351,6 +351,8 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
351
351
  * ```
352
352
  */
353
353
  sessionProvider?: SessionProvider;
354
+ /** Required by the host password sign-in endpoint; shared limiter and trusted IP resolver. */
355
+ passwordSignIn?: PasswordSignInProtection;
354
356
  /**
355
357
  * Adapter-built bundle of admin repositories (users / roles /
356
358
  * permissions / refresh tokens). Typically constructed via the
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { resolveHooks, resolveUploadHooks } from '../@types/collection-types.js';
3
3
  import { initBylineCore } from '../core.js';
4
+ import { testAdapter } from '../storage/db-adapter.test-helper.js';
4
5
  import { commitHookAttachment, prepareHookAttachment } from './attach-hooks.js';
5
6
  function collection(path = 'documents') {
6
7
  return {
@@ -197,7 +198,7 @@ describe('server hook attachment', () => {
197
198
  }));
198
199
  const invalid = {
199
200
  collections: [definition],
200
- db: {},
201
+ db: testAdapter({}),
201
202
  hooks: { collections: { documents: replacement } },
202
203
  i18n: {
203
204
  admin: { defaultLocale: 'en', locales: ['en'] },
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import { defineAdmin, defineSingletonAdmin } from '../@types/admin-types.js';
3
3
  import { defineCollection, defineSingleton } from '../@types/collection-types.js';
4
+ import { testAdapter } from '../storage/db-adapter.test-helper.js';
4
5
  import { defineAdminConfig, defineServerConfig, getCollectionAdminConfig, getCollectionDefinition, getSingletonAdminConfig, } from './config.js';
5
6
  const SERVER_CONFIG = Symbol.for('__byline_server_config__');
6
7
  const ADMIN_CONFIG = Symbol.for('__byline_admin_config__');
@@ -53,7 +54,7 @@ describe('collection definition config preference', () => {
53
54
  defineAdminConfig({ collections: [client], i18n });
54
55
  defineServerConfig({
55
56
  collections: [server],
56
- db: {},
57
+ db: testAdapter({}),
57
58
  hooks: { collections: { docs: hooks } },
58
59
  i18n,
59
60
  });
package/dist/core.js CHANGED
@@ -18,6 +18,7 @@ import { validateTreeAuditCapability } from './services/document-lifecycle/audit
18
18
  import { validateTranslations } from './services/i18n-validator.js';
19
19
  import { validateRichTextFieldFlags } from './services/richtext-populate.js';
20
20
  import { validateSearchConfig } from './services/validate-search-config.js';
21
+ import { assertDocumentRevisionCapability } from './storage/document-revision-capability.js';
21
22
  /**
22
23
  * Initialize Byline CMS core services via the typed registry.
23
24
  *
@@ -82,7 +83,11 @@ export const initBylineCore = async (config, pinoLogger) => {
82
83
  },
83
84
  });
84
85
  }
85
- validateSchedulerConfig({ tasks: configuredTasks, adapter: composed.db });
86
+ validateSchedulerConfig({
87
+ tasks: configuredTasks,
88
+ adapter: composed.db,
89
+ passwordSignIn: resolvedConfig.passwordSignIn,
90
+ });
86
91
  // Freeze a snapshot of the validated task set: a new array of new, frozen
87
92
  // objects, then freeze the array itself. Without this, `core.recurringTasks`
88
93
  // would hold the caller's own array and definition objects, so a caller
@@ -118,6 +123,7 @@ export const initBylineCore = async (config, pinoLogger) => {
118
123
  // Reconcile collection definitions with the database: insert new rows,
119
124
  // bump schema versions when the fingerprint has drifted, and build the
120
125
  // in-memory record cache used by the lifecycle/upload/client paths.
126
+ await assertDocumentRevisionCapability(composed.db);
121
127
  const collectionRecords = await ensureCollections({
122
128
  definitions: composed.collections,
123
129
  db: composed.db,
@@ -161,6 +167,12 @@ export const initBylineCore = async (config, pinoLogger) => {
161
167
  // abilities. Plugins and subsystems add their own via
162
168
  // `core.registerAbility()` or `core.abilities.register()`.
163
169
  const abilities = new AbilityRegistry();
170
+ abilities.register({
171
+ key: 'system.documentMaintenance',
172
+ label: 'Maintain document fields while preserving status',
173
+ group: 'system',
174
+ source: 'core',
175
+ });
164
176
  for (const definition of composed.collections) {
165
177
  registerDocumentAbilities(abilities, definition);
166
178
  }
@@ -1,11 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { defineServerConfig, getServerConfig } from './config/config.js';
3
3
  import { initBylineCore } from './core.js';
4
+ import { testAdapter } from './storage/db-adapter.test-helper.js';
4
5
  function serverConfig(admin) {
5
6
  return {
6
7
  routes: { admin },
7
8
  collections: [],
8
- db: {},
9
+ db: testAdapter({}),
9
10
  i18n: {
10
11
  admin: { defaultLocale: 'en', locales: [] },
11
12
  content: { defaultLocale: 'en', locales: [] },
@@ -25,3 +26,27 @@ describe('initBylineCore configuration registration', () => {
25
26
  expect(getServerConfig().routes.admin).toBe('/stable/admin');
26
27
  });
27
28
  });
29
+ it('rejects revision-incompatible storage before any boot database writes', async () => {
30
+ const config = serverConfig('/revision-check/admin');
31
+ const write = vi.fn();
32
+ const schemaError = new Error('fence and upgrade the revision schema');
33
+ config.db = testAdapter({
34
+ withTransaction: vi.fn(),
35
+ withReadSnapshot: vi.fn(),
36
+ revisions: {
37
+ assertCompatibleSchema: vi.fn().mockRejectedValue(schemaError),
38
+ isInTransaction: () => false,
39
+ lock: vi.fn(),
40
+ readStructure: vi.fn(),
41
+ advance: vi.fn(),
42
+ },
43
+ commands: {
44
+ collections: { create: write, update: write, lockCollectionRegistration: write },
45
+ documents: { publishSchedules: { lockDocuments: write } },
46
+ counters: { ensureCounterGroup: write },
47
+ },
48
+ backfillSourceLocales: write,
49
+ });
50
+ await expect(initBylineCore(config, {})).rejects.toBe(schemaError);
51
+ expect(write).not.toHaveBeenCalled();
52
+ });
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
8
8
  export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
9
9
  export * from './defaults/default-values.js';
10
10
  export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
11
- export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
11
+ export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_DOCUMENT_STALE, ERR_LOCK_CONFLICT, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, getLockConflictDetails, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
12
12
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
13
13
  export { type BylineLogger, getLogger } from './lib/logger.js';
14
14
  export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
@@ -18,7 +18,9 @@ export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere,
18
18
  export { defineRecurringTask, MIN_INTERVAL_MS, MIN_LEASE_MS, } from './scheduler/define-recurring-task.js';
19
19
  export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
20
20
  export * from './services/index.js';
21
+ export { documentRevisionFromDatabase, isDocumentRevision, parseDocumentRevision, } from './storage/document-revision.js';
21
22
  export * from './storage/index.js';
23
+ export { runReadSnapshot } from './storage/read-snapshot.js';
22
24
  export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
23
25
  export { formatTextValue, looksLikeISODate, type SlugifierFn, type SlugifyContext, slugify, } from './utils/slugify.js';
24
26
  export { type FilenameSlugifierFn, type FilenameSlugifyContext, resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
20
20
  export { getBylineCore, initBylineCore } from './core.js';
21
21
  export * from './defaults/default-values.js';
22
22
  export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
23
- export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
23
+ export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_DOCUMENT_STALE, ERR_LOCK_CONFLICT, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, getLockConflictDetails, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
24
24
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
25
25
  export { getLogger } from './lib/logger.js';
26
26
  export { AsyncRegistry, Registry } from './lib/registry.js';
@@ -30,7 +30,9 @@ export { mergePredicates, parsePredicateFilters, parseSort, parseWhere, } from '
30
30
  export { defineRecurringTask, MIN_INTERVAL_MS, MIN_LEASE_MS, } from './scheduler/define-recurring-task.js';
31
31
  export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
32
32
  export * from './services/index.js';
33
+ export { documentRevisionFromDatabase, isDocumentRevision, parseDocumentRevision, } from './storage/document-revision.js';
33
34
  export * from './storage/index.js';
35
+ export { runReadSnapshot } from './storage/read-snapshot.js';
34
36
  export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
35
37
  export { formatTextValue, looksLikeISODate, slugify, } from './utils/slugify.js';
36
38
  export { resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
@@ -19,6 +19,7 @@
19
19
  * details: { documentId },
20
20
  * }).log(logger)
21
21
  */
22
+ import type { DocumentStaleDetails } from '../@types/document-revision.js';
22
23
  import type { BylineLogger, LogLevel, LogLevelWithSilent } from './logger.js';
23
24
  export type ErrorReport = {
24
25
  code: string;
@@ -83,12 +84,14 @@ export declare const ErrorCodes: {
83
84
  readonly INVALID_TRANSITION: 'ERR_INVALID_TRANSITION';
84
85
  readonly PATCH_FAILED: 'ERR_PATCH_FAILED';
85
86
  readonly DATABASE: 'ERR_DATABASE';
87
+ readonly LOCK_CONFLICT: 'ERR_LOCK_CONFLICT';
86
88
  readonly STORAGE: 'ERR_STORAGE';
87
89
  readonly READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED';
88
90
  readonly READ_RECURSION: 'ERR_READ_RECURSION';
89
91
  readonly PATH_CONFLICT: 'ERR_PATH_CONFLICT';
90
92
  readonly AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED';
91
93
  readonly DOCUMENT_HOOK_COMMITTED: 'ERR_DOCUMENT_HOOK_COMMITTED';
94
+ readonly DOCUMENT_STALE: 'ERR_DOCUMENT_STALE';
92
95
  readonly TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED';
93
96
  };
94
97
  /** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
@@ -100,10 +103,21 @@ export declare const DOCUMENT_HOOK_COMMITTED_MARKER = "[ERR_DOCUMENT_HOOK_COMMIT
100
103
  export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
101
104
  export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
102
105
  export declare const ERR_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
106
+ /** Editorial stale state has its own code; other conflicts must not trigger reload. */
107
+ export declare const ERR_DOCUMENT_STALE: (options: Omit<BylineErrorOptions, 'details'> & {
108
+ details: DocumentStaleDetails;
109
+ }) => BylineError;
103
110
  export declare const ERR_VALIDATION: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
104
111
  export declare const ERR_INVALID_TRANSITION: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
105
112
  export declare const ERR_PATCH_FAILED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
106
113
  export declare const ERR_DATABASE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
114
+ export declare const ERR_LOCK_CONFLICT: (options: BylineErrorOptions) => BylineError;
115
+ /** Safe, small contract for live errors, reports, and message-only Error transports. */
116
+ export declare function getLockConflictDetails(error: unknown): {
117
+ reason: 'lock_conflict';
118
+ rolledBack: true;
119
+ retryable: true;
120
+ } | null;
107
121
  export declare const ERR_STORAGE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
108
122
  export declare const ERR_READ_BUDGET_EXCEEDED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
109
123
  export declare const ERR_READ_RECURSION: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -152,6 +166,7 @@ export declare const ERR_TREE_HOOK_COMMITTED: (opts: BylineErrorOptions, errorCo
152
166
  export declare const DbErrorCodes: {
153
167
  readonly UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION';
154
168
  readonly FOREIGN_KEY_VIOLATION: 'DB_FOREIGN_KEY_VIOLATION';
169
+ readonly LOCK_CONFLICT: 'DB_LOCK_CONFLICT';
155
170
  readonly UNKNOWN: 'DB_UNKNOWN';
156
171
  };
157
172
  export type DbErrorCode = (typeof DbErrorCodes)[keyof typeof DbErrorCodes];
@@ -117,12 +117,14 @@ export const ErrorCodes = {
117
117
  INVALID_TRANSITION: 'ERR_INVALID_TRANSITION',
118
118
  PATCH_FAILED: 'ERR_PATCH_FAILED',
119
119
  DATABASE: 'ERR_DATABASE',
120
+ LOCK_CONFLICT: 'ERR_LOCK_CONFLICT',
120
121
  STORAGE: 'ERR_STORAGE',
121
122
  READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED',
122
123
  READ_RECURSION: 'ERR_READ_RECURSION',
123
124
  PATH_CONFLICT: 'ERR_PATH_CONFLICT',
124
125
  AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED',
125
126
  DOCUMENT_HOOK_COMMITTED: 'ERR_DOCUMENT_HOOK_COMMITTED',
127
+ DOCUMENT_STALE: 'ERR_DOCUMENT_STALE',
126
128
  TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED',
127
129
  };
128
130
  /** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
@@ -137,10 +139,39 @@ export const DOCUMENT_HOOK_COMMITTED_MARKER = '[ERR_DOCUMENT_HOOK_COMMITTED]';
137
139
  export const ERR_UNHANDLED = createErrorType(ErrorCodes.UNHANDLED);
138
140
  export const ERR_NOT_FOUND = createErrorType(ErrorCodes.NOT_FOUND, 'warn');
139
141
  export const ERR_CONFLICT = createErrorType(ErrorCodes.CONFLICT, 'warn');
142
+ /** Editorial stale state has its own code; other conflicts must not trigger reload. */
143
+ export const ERR_DOCUMENT_STALE = (options) => new BylineError(ErrorCodes.DOCUMENT_STALE, { logLevel: 'warn', ...options });
140
144
  export const ERR_VALIDATION = createErrorType(ErrorCodes.VALIDATION, 'warn');
141
145
  export const ERR_INVALID_TRANSITION = createErrorType(ErrorCodes.INVALID_TRANSITION, 'warn');
142
146
  export const ERR_PATCH_FAILED = createErrorType(ErrorCodes.PATCH_FAILED);
143
147
  export const ERR_DATABASE = createErrorType(ErrorCodes.DATABASE);
148
+ /** Only emitted after the owned transaction has confirmed complete rollback. */
149
+ const LOCK_CONFLICT_MARKER = '[ERR_LOCK_CONFLICT:ROLLED_BACK]';
150
+ export const ERR_LOCK_CONFLICT = (options) => new BylineError(ErrorCodes.LOCK_CONFLICT, {
151
+ ...options,
152
+ logLevel: 'warn',
153
+ message: `${LOCK_CONFLICT_MARKER} This change could not be saved because another operation was using the document. Reload before trying again.`,
154
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
155
+ });
156
+ /** Safe, small contract for live errors, reports, and message-only Error transports. */
157
+ export function getLockConflictDetails(error) {
158
+ try {
159
+ if (typeof error !== 'object' || error === null)
160
+ return null;
161
+ const details = Reflect.get(error, 'details');
162
+ const typed = Reflect.get(error, 'code') === ErrorCodes.LOCK_CONFLICT &&
163
+ details?.reason === 'lock_conflict' &&
164
+ details?.rolledBack === true &&
165
+ details?.retryable === true;
166
+ const message = Reflect.get(error, 'message');
167
+ if (!typed && !(typeof message === 'string' && message.startsWith(LOCK_CONFLICT_MARKER)))
168
+ return null;
169
+ return { reason: 'lock_conflict', rolledBack: true, retryable: true };
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
144
175
  export const ERR_STORAGE = createErrorType(ErrorCodes.STORAGE);
145
176
  export const ERR_READ_BUDGET_EXCEEDED = createErrorType(ErrorCodes.READ_BUDGET_EXCEEDED);
146
177
  export const ERR_READ_RECURSION = createErrorType(ErrorCodes.READ_RECURSION);
@@ -192,5 +223,6 @@ export const ERR_TREE_HOOK_COMMITTED = createErrorType(ErrorCodes.TREE_HOOK_COMM
192
223
  export const DbErrorCodes = {
193
224
  UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION',
194
225
  FOREIGN_KEY_VIOLATION: 'DB_FOREIGN_KEY_VIOLATION',
226
+ LOCK_CONFLICT: 'DB_LOCK_CONFLICT',
195
227
  UNKNOWN: 'DB_UNKNOWN',
196
228
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ERR_LOCK_CONFLICT, getLockConflictDetails } from './errors.js';
3
+ describe('confirmed lock conflict transport', () => {
4
+ it('preserves the contract through reports and message-only Error reconstruction', () => {
5
+ const cause = Object.assign(new Error('secret driver SQL'), { code: '40P01' });
6
+ const error = ERR_LOCK_CONFLICT({ cause, message: cause.message });
7
+ expect(error.cause).toBe(cause);
8
+ for (const transported of [
9
+ error,
10
+ JSON.parse(JSON.stringify(error.report())),
11
+ new Error(error.message),
12
+ ]) {
13
+ expect(getLockConflictDetails(transported)).toEqual({
14
+ reason: 'lock_conflict',
15
+ rolledBack: true,
16
+ retryable: true,
17
+ });
18
+ }
19
+ expect(JSON.stringify(error.report())).not.toContain('secret driver SQL');
20
+ });
21
+ it('rejects stale state, driver failures, and incomplete rollback claims', () => {
22
+ for (const error of [
23
+ null,
24
+ { code: 'ERR_DOCUMENT_STALE' },
25
+ { code: '40P01' },
26
+ { code: 'ERR_LOCK_CONFLICT' },
27
+ { code: 'ERR_LOCK_CONFLICT', details: { rolledBack: false } },
28
+ ])
29
+ expect(getLockConflictDetails(error)).toBeNull();
30
+ });
31
+ });
@@ -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
@@ -384,13 +385,13 @@ describe('runDueTasks', () => {
384
385
  const injectedRun = vi.fn(async () => { });
385
386
  const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
386
387
  const core = {
387
- db: { scheduler: store },
388
+ db: testAdapter({ scheduler: store }),
388
389
  recurringTasks: [task('core-task', coreRun)],
389
390
  logger: silentLogger,
390
391
  };
391
392
  await runDueTasks(core, { owner: 'test', tasks: [task('injected-task', injectedRun)] });
392
393
  expect(coreRun).toHaveBeenCalledTimes(1);
393
394
  expect(injectedRun).not.toHaveBeenCalled();
394
- await expect(runDueTasks({ ...core, db: {} }, { owner: 'test' })).rejects.toThrow(/scheduler capability/i);
395
+ await expect(runDueTasks({ ...core, db: testAdapter() }, { owner: 'test' })).rejects.toThrow(/scheduler capability/i);
395
396
  });
396
397
  });
@@ -7,6 +7,20 @@
7
7
  */
8
8
  import type { BylineCore } from '../core.js';
9
9
  import type { BylineLogger } from '../lib/logger.js';
10
+ /** The sweep depends only on this server-side operational surface. */
11
+ type ScheduledPublicationCore = Pick<BylineCore, 'db' | 'collections' | 'collectionRecords' | 'storage' | 'logger'> & {
12
+ config: {
13
+ slugifier?: BylineCore['config']['slugifier'];
14
+ i18n: {
15
+ content: {
16
+ defaultLocale: string;
17
+ };
18
+ };
19
+ scheduledPublication?: {
20
+ enabled?: boolean;
21
+ };
22
+ };
23
+ };
10
24
  export interface ScheduledPublicationSweepOptions {
11
25
  /** Abort before claiming another batch. */
12
26
  signal?: AbortSignal;
@@ -28,4 +42,5 @@ export interface ScheduledPublicationSweepResult {
28
42
  * Database claims provide the execution fence, so independent external
29
43
  * orchestrators may invoke this operation concurrently.
30
44
  */
31
- export declare function runScheduledPublicationSweep(core: BylineCore, options?: ScheduledPublicationSweepOptions): Promise<ScheduledPublicationSweepResult>;
45
+ export declare function runScheduledPublicationSweep(core: ScheduledPublicationCore, options?: ScheduledPublicationSweepOptions): Promise<ScheduledPublicationSweepResult>;
46
+ export {};