@byline/db-mysql 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.
@@ -271,6 +271,23 @@ export declare const documents: import("drizzle-orm/mysql-core").MySqlTableWithC
271
271
  identity: undefined;
272
272
  generated: undefined;
273
273
  }, {}, {}>;
274
+ revision: import("drizzle-orm/mysql-core").MySqlColumn<{
275
+ name: "revision";
276
+ tableName: "byline_documents";
277
+ dataType: "number";
278
+ columnType: "MySqlBigInt53";
279
+ data: number;
280
+ driverParam: string | number;
281
+ notNull: true;
282
+ hasDefault: false;
283
+ isPrimaryKey: false;
284
+ isAutoincrement: false;
285
+ hasRuntimeDefault: false;
286
+ enumValues: undefined;
287
+ baseColumn: never;
288
+ identity: undefined;
289
+ generated: undefined;
290
+ }, {}, {}>;
274
291
  };
275
292
  dialect: 'mysql';
276
293
  }>;
@@ -581,6 +598,23 @@ export declare const documentPublishSchedules: import("drizzle-orm/mysql-core").
581
598
  identity: undefined;
582
599
  generated: undefined;
583
600
  }, {}, {}>;
601
+ authorized_revision: import("drizzle-orm/mysql-core").MySqlColumn<{
602
+ name: "authorized_revision";
603
+ tableName: "byline_document_publish_schedules";
604
+ dataType: "number";
605
+ columnType: "MySqlBigInt53";
606
+ data: number;
607
+ driverParam: string | number;
608
+ notNull: false;
609
+ hasDefault: false;
610
+ isPrimaryKey: false;
611
+ isAutoincrement: false;
612
+ hasRuntimeDefault: false;
613
+ enumValues: undefined;
614
+ baseColumn: never;
615
+ identity: undefined;
616
+ generated: undefined;
617
+ }, {}, {}>;
584
618
  publish_at: import("drizzle-orm/mysql-core").MySqlColumn<{
585
619
  name: "publish_at";
586
620
  tableName: "byline_document_publish_schedules";
@@ -65,6 +65,8 @@ export const documents = mysqlTable('byline_documents', {
65
65
  // `backfillSourceLocales()` (boot-auto via initBylineCore).
66
66
  //
67
67
  source_locale: varchar('source_locale', { length: 10 }).notNull(),
68
+ // Editorial concurrency counter; content versions remain independently immutable.
69
+ revision: bigint('revision', { mode: 'number' }).notNull(),
68
70
  ...timestamps,
69
71
  }, (table) => [
70
72
  foreignKey({
@@ -72,6 +74,7 @@ export const documents = mysqlTable('byline_documents', {
72
74
  columns: [table.collection_id],
73
75
  foreignColumns: [collections.id],
74
76
  }).onDelete('cascade'),
77
+ check('check_documents_revision', sql `${table.revision} BETWEEN 1 AND 9007199254740991`),
75
78
  index('idx_documents_collection').on(table.collection_id),
76
79
  index('idx_documents_collection_order').on(table.collection_id, table.order_key),
77
80
  unique('uq_documents_collection_id_id').on(table.collection_id, table.id),
@@ -137,6 +140,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
137
140
  document_id: uuidChar('document_id').primaryKey(),
138
141
  collection_id: uuidChar('collection_id').notNull(),
139
142
  target_version_id: uuidChar('target_version_id').notNull(),
143
+ authorized_revision: bigint('authorized_revision', { mode: 'number' }),
140
144
  publish_at: datetime('publish_at', { fsp: 6 }).notNull(),
141
145
  state: varchar('state', { length: 32 }).notNull().default('armed'),
142
146
  suspended_at: datetime('suspended_at', { fsp: 6 }),
@@ -157,6 +161,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
157
161
  // stack or document content.
158
162
  last_error: text('last_error'),
159
163
  }, (table) => [
164
+ check('check_publish_schedules_authorized_revision', sql `${table.authorized_revision} IS NULL OR ${table.authorized_revision} BETWEEN 1 AND 9007199254740991`),
160
165
  foreignKey({
161
166
  name: 'fk_publish_schedules_document',
162
167
  columns: [table.document_id],
@@ -173,7 +178,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
173
178
  foreignColumns: [documentVersions.id],
174
179
  }).onDelete('cascade'),
175
180
  check('check_publish_schedules_state', sql `${table.state} IN ('armed', 'needs_reconfirm')`),
176
- check('check_publish_schedules_suspended_reason', sql `${table.suspended_reason} IS NULL OR ${table.suspended_reason} = 'content_edited'`),
181
+ check('check_publish_schedules_suspended_reason', sql `${table.suspended_reason} IS NULL OR ${table.suspended_reason} IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')`),
177
182
  // MySQL has no partial indexes, so state is the leading discriminator.
178
183
  index('idx_document_publish_schedules_due').on(table.state, table.next_attempt_at, table.publish_at),
179
184
  index('idx_document_publish_schedules_execution_expiry').on(table.execution_expires_at),
@@ -448,6 +448,7 @@ describe('schema pins — scheduled publication', () => {
448
448
  });
449
449
  it('pins the bounded state and suspension-reason constraints', () => {
450
450
  expect(cfg.checks.map((constraint) => constraint.name).sort()).toEqual([
451
+ 'check_publish_schedules_authorized_revision',
451
452
  'check_publish_schedules_state',
452
453
  'check_publish_schedules_suspended_reason',
453
454
  ]);
@@ -485,3 +486,16 @@ describe('schema pins — singleton documents', () => {
485
486
  expect(ownershipForeignKey?.onDelete).toBe('cascade');
486
487
  });
487
488
  });
489
+ describe('schema pins — document revisions', () => {
490
+ it('requires an explicit safe-range revision on every new document', () => {
491
+ expect(coreSchema.documents.revision.getSQLType()).toBe('bigint');
492
+ expect(coreSchema.documents.revision.notNull).toBe(true);
493
+ expect(coreSchema.documents.revision.hasDefault).toBe(false);
494
+ expect(getTableConfig(coreSchema.documents).checks.map((constraint) => constraint.name)).toContain('check_documents_revision');
495
+ });
496
+ it('allows legacy schedules to have no prior document authorization', () => {
497
+ expect(coreSchema.documentPublishSchedules.authorized_revision.getSQLType()).toBe('bigint');
498
+ expect(coreSchema.documentPublishSchedules.authorized_revision.notNull).toBe(false);
499
+ expect(coreSchema.documentPublishSchedules.authorized_revision.hasDefault).toBe(false);
500
+ });
501
+ });
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { createAuditQueries } from './modules/audit/audit-queries.js';
15
15
  import { createCounterCommands } from './modules/counters/counters-commands.js';
16
16
  import { createSchedulerStore } from './modules/scheduler/scheduler-store.js';
17
17
  import { classifyError } from './modules/storage/classify-error.js';
18
+ import { DocumentRevisions } from './modules/storage/document-revisions.js';
19
+ import { createReadSnapshot } from './modules/storage/read-snapshot.js';
18
20
  import { SingletonCommands, SingletonQueries } from './modules/storage/singletons.js';
19
21
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
20
22
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
@@ -151,6 +153,8 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
151
153
  singletons: singletonQueries,
152
154
  },
153
155
  withTransaction: (fn) => txManager.withTransaction(fn),
156
+ withReadSnapshot: createReadSnapshot(db, collections, defaultContentLocale),
157
+ revisions: new DocumentRevisions(dbManager),
154
158
  scheduler: schedulerStore,
155
159
  classifyError,
156
160
  drizzle: db,
@@ -27,14 +27,24 @@ export interface DBManager {
27
27
  get(): DBExecutor;
28
28
  /** Whether the current async context owns an ambient transaction. */
29
29
  isInTransaction(): boolean;
30
+ getTransactionScope(): object | undefined;
31
+ getTransactionToken(): object | undefined;
32
+ isTransactionTokenActive(token: object): boolean;
33
+ runInTransaction<T>(executor: DBExecutor, fn: () => Promise<T>): Promise<T>;
30
34
  }
31
35
  export declare class DBManagerImpl implements DBManager {
36
+ private readonly transactionALS;
37
+ private readonly activeTransactions;
32
38
  private readonly dbPool;
33
39
  constructor(deps: {
34
40
  dbPool: DBExecutor;
35
41
  });
36
42
  get(): DBExecutor;
37
43
  isInTransaction(): boolean;
44
+ getTransactionScope(): object | undefined;
45
+ getTransactionToken(): object | undefined;
46
+ isTransactionTokenActive(token: object): boolean;
47
+ runInTransaction<T>(executor: DBExecutor, fn: () => Promise<T>): Promise<T>;
38
48
  }
39
49
  export interface TXManager {
40
50
  /**
@@ -17,17 +17,42 @@
17
17
  * `withTransaction` capability on `IDbAdapter`, never the implementation.
18
18
  */
19
19
  import { AsyncLocalStorage } from 'node:async_hooks';
20
- const transactionALS = new AsyncLocalStorage();
20
+ import { DbErrorCodes, ERR_LOCK_CONFLICT } from '@byline/core';
21
+ import { classifyError } from '../modules/storage/classify-error.js';
21
22
  export class DBManagerImpl {
23
+ transactionALS = new AsyncLocalStorage();
24
+ activeTransactions = new WeakSet();
22
25
  dbPool;
23
26
  constructor(deps) {
24
27
  this.dbPool = deps.dbPool;
25
28
  }
26
29
  get() {
27
- return transactionALS.getStore() ?? this.dbPool;
30
+ return this.transactionALS.getStore()?.executor ?? this.dbPool;
28
31
  }
29
32
  isInTransaction() {
30
- return transactionALS.getStore() != null;
33
+ return this.getTransactionToken() != null;
34
+ }
35
+ getTransactionScope() {
36
+ return this.getTransactionToken() != null ? this.transactionALS.getStore()?.scope : undefined;
37
+ }
38
+ getTransactionToken() {
39
+ const frame = this.transactionALS.getStore();
40
+ return frame != null && this.activeTransactions.has(frame) ? frame : undefined;
41
+ }
42
+ isTransactionTokenActive(token) {
43
+ return this.activeTransactions.has(token);
44
+ }
45
+ async runInTransaction(executor, fn) {
46
+ // Share lock ordering with savepoints. Observations issued inside a savepoint
47
+ // expire when it ends: rollback can release the row locks it acquired.
48
+ const frame = { executor, scope: this.getTransactionScope() ?? {} };
49
+ this.activeTransactions.add(frame);
50
+ try {
51
+ return await this.transactionALS.run(frame, fn);
52
+ }
53
+ finally {
54
+ this.activeTransactions.delete(frame);
55
+ }
31
56
  }
32
57
  }
33
58
  export class TXManagerImpl {
@@ -35,18 +60,50 @@ export class TXManagerImpl {
35
60
  constructor(deps) {
36
61
  this.db = deps.db;
37
62
  }
38
- withTransaction(fn) {
39
- return this.db.get().transaction((tx) =>
40
- // `tx` is Drizzle's MySqlTransaction; it carries the full
41
- // query-builder surface every command uses. The cast bridges the
42
- // structural gap to MySql2Database the same way the pg adapter
43
- // bridges to NodePgDatabase. See docs/03-architecture/03-transactions.md.
44
- transactionALS.run(tx, fn), {
45
- // Spec decision: pin the isolation level to match the pg adapter's
46
- // assumptions (Postgres's default is READ COMMITTED; MySQL/InnoDB
47
- // defaults to REPEATABLE READ). Canonical adapters must behave
48
- // identically for the shared db-conformance suite.
49
- isolationLevel: 'read committed',
50
- });
63
+ async withTransaction(fn) {
64
+ const ownsTransaction = !this.db.isInTransaction();
65
+ let callbackError;
66
+ let callbackFailed = false;
67
+ const guarded = async () => {
68
+ try {
69
+ return await fn();
70
+ }
71
+ catch (error) {
72
+ callbackFailed = true;
73
+ callbackError = error;
74
+ throw error;
75
+ }
76
+ };
77
+ try {
78
+ return await this.db.get().transaction((tx) =>
79
+ // `tx` is Drizzle's MySqlTransaction; it carries the full
80
+ // query-builder surface every command uses. The cast bridges the
81
+ // structural gap to MySql2Database the same way the pg adapter
82
+ // bridges to NodePgDatabase. See docs/03-architecture/03-transactions.md.
83
+ this.db.runInTransaction(tx, guarded), {
84
+ // Spec decision: pin the isolation level to match the pg adapter's
85
+ // assumptions (Postgres's default is READ COMMITTED; MySQL/InnoDB
86
+ // defaults to REPEATABLE READ). Canonical adapters must behave
87
+ // identically for the shared db-conformance suite.
88
+ isolationLevel: 'read committed',
89
+ });
90
+ }
91
+ catch (error) {
92
+ // Drizzle rethrows the callback error only after ROLLBACK succeeds. A
93
+ // rollback failure replaces it; a COMMIT failure has no callback error.
94
+ // Never infer safe retry from either of those uncertain outcomes, nor
95
+ // from a savepoint rollback inside an externally owned transaction.
96
+ if (ownsTransaction &&
97
+ callbackFailed &&
98
+ error === callbackError &&
99
+ classifyError(error).code === DbErrorCodes.LOCK_CONFLICT) {
100
+ throw ERR_LOCK_CONFLICT({
101
+ message: 'This change could not be saved because another operation was using the document. Reload before trying again.',
102
+ cause: error,
103
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
104
+ });
105
+ }
106
+ throw error;
107
+ }
51
108
  }
52
109
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
3
+ describe('adapter transaction ownership', () => {
4
+ it('does not leak one adapter transaction into another adapter pool', async () => {
5
+ const poolA = {};
6
+ const poolB = {};
7
+ const tx = {};
8
+ const a = new DBManagerImpl({ dbPool: poolA });
9
+ const b = new DBManagerImpl({ dbPool: poolB });
10
+ await a.runInTransaction(tx, async () => {
11
+ expect(a.get()).toBe(tx);
12
+ expect(a.isInTransaction()).toBe(true);
13
+ expect(b.get()).toBe(poolB);
14
+ expect(b.isInTransaction()).toBe(false);
15
+ expect(b.getTransactionScope()).toBeUndefined();
16
+ });
17
+ expect(a.get()).toBe(poolA);
18
+ expect(a.getTransactionScope()).toBeUndefined();
19
+ });
20
+ it('preserves root scope through savepoints and restores the parent executor', async () => {
21
+ const pool = {};
22
+ const outer = {};
23
+ const inner = {};
24
+ const manager = new DBManagerImpl({ dbPool: pool });
25
+ await manager.runInTransaction(outer, async () => {
26
+ const scope = manager.getTransactionScope();
27
+ await expect(manager.runInTransaction(inner, async () => {
28
+ expect(manager.get()).toBe(inner);
29
+ expect(manager.getTransactionScope()).toBe(scope);
30
+ throw new Error('rollback');
31
+ })).rejects.toThrow('rollback');
32
+ expect(manager.get()).toBe(outer);
33
+ expect(manager.getTransactionScope()).toBe(scope);
34
+ });
35
+ });
36
+ });
37
+ describe('owned transaction lock failures', () => {
38
+ const conflict = () => Object.assign(new Error('driver SQL detail'), { errno: 1213 });
39
+ for (const outcome of ['rollback', 'rollback failure', 'commit failure', 'nested']) {
40
+ it(`only marks confirmed whole rollback retryable: ${outcome}`, async () => {
41
+ const original = conflict();
42
+ const replacement = new Error('connection lost');
43
+ let rollbackCompleted = false;
44
+ const executor = {
45
+ transaction: async (fn) => {
46
+ let result;
47
+ try {
48
+ result = await fn(executor);
49
+ }
50
+ catch (error) {
51
+ if (outcome === 'rollback failure')
52
+ throw replacement;
53
+ rollbackCompleted = true;
54
+ throw error;
55
+ }
56
+ if (outcome === 'commit failure')
57
+ throw original;
58
+ return result;
59
+ },
60
+ };
61
+ const db = new DBManagerImpl({ dbPool: executor });
62
+ const manager = new TXManagerImpl({ db });
63
+ const operation = () => manager.withTransaction(async () => {
64
+ if (outcome !== 'commit failure')
65
+ throw original;
66
+ return 'ok';
67
+ });
68
+ const pending = outcome === 'nested' ? db.runInTransaction(executor, operation) : operation();
69
+ if (outcome === 'rollback') {
70
+ await expect(pending).rejects.toMatchObject({
71
+ code: 'ERR_LOCK_CONFLICT',
72
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
73
+ cause: original,
74
+ });
75
+ expect(rollbackCompleted).toBe(true);
76
+ }
77
+ else {
78
+ await expect(pending).rejects.toBe(outcome === 'rollback failure' ? replacement : original);
79
+ }
80
+ });
81
+ }
82
+ });
@@ -0,0 +1,2 @@
1
+ import type { DBExecutor } from './db-manager.js';
2
+ export declare function assertRevisionSchema(db: DBExecutor): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { ERR_DATABASE } from '@byline/core';
2
+ import { sql } from 'drizzle-orm';
3
+ // Catalogue renderers add parentheses, casts, charset introducers, and escaped quotes.
4
+ // Compare complete normalized expressions, not just constraint names or bound substrings.
5
+ function normalize(clause) {
6
+ return clause
7
+ .toLowerCase()
8
+ .replace(/::(?:character varying|bigint|integer|text)(?:\[\])?/g, '')
9
+ .replace(/_utf8mb[34]/g, '')
10
+ .replace(/^check/, '')
11
+ .replace(/[\s()[\]`'"\\]/g, '');
12
+ }
13
+ export async function assertRevisionSchema(db) {
14
+ const upgrade = 'Byline document revision schema is incompatible. Fence all writers/workers, upgrade both packages and schema using 0005_document-revisions.sql, and rerun startup validation before reopening traffic.';
15
+ try {
16
+ const columnResult = await db.execute(sql `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name,
17
+ CASE WHEN DATA_TYPE = 'bigint' AND COLUMN_TYPE NOT LIKE '%unsigned%' AND COLUMN_DEFAULT IS NULL
18
+ AND IS_NULLABLE = CASE WHEN COLUMN_NAME = 'revision' THEN 'NO' ELSE 'YES' END
19
+ AND EXTRA = '' THEN 1 ELSE 0 END AS valid
20
+ FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND ((table_name = 'byline_documents' AND column_name = 'revision') OR (table_name = 'byline_document_publish_schedules' AND column_name = 'authorized_revision'))`);
21
+ const checkResult = await db.execute(sql `SELECT t.CONSTRAINT_NAME AS name, c.CHECK_CLAUSE AS clause, (t.ENFORCED = 'YES') AS valid
22
+ FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c
23
+ ON c.CONSTRAINT_SCHEMA = t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME = t.CONSTRAINT_NAME
24
+ WHERE t.CONSTRAINT_SCHEMA = DATABASE() AND t.TABLE_NAME IN ('byline_documents', 'byline_document_publish_schedules') AND t.CONSTRAINT_TYPE = 'CHECK'`);
25
+ const columns = columnResult[0];
26
+ const checks = checkResult[0];
27
+ if (columns.length !== 2 || columns.some((column) => Number(column.valid) !== 1))
28
+ throw new Error('Required BIGINT columns must have the correct nullability and no default.');
29
+ const required = [
30
+ [
31
+ 'check_documents_revision',
32
+ [
33
+ 'revision BETWEEN 1 AND 9007199254740991',
34
+ 'revision >= 1 AND revision <= 9007199254740991',
35
+ ],
36
+ ],
37
+ [
38
+ 'check_publish_schedules_authorized_revision',
39
+ [
40
+ 'authorized_revision IS NULL OR authorized_revision BETWEEN 1 AND 9007199254740991',
41
+ 'authorized_revision IS NULL OR authorized_revision >= 1 AND authorized_revision <= 9007199254740991',
42
+ ],
43
+ ],
44
+ [
45
+ 'check_publish_schedules_suspended_reason',
46
+ [
47
+ "suspended_reason IS NULL OR suspended_reason IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')",
48
+ "suspended_reason IS NULL OR suspended_reason = ANY (ARRAY['content_edited', 'document_metadata_changed', 'upgrade_invalidated'])",
49
+ ],
50
+ ],
51
+ ];
52
+ for (const [name, expressions] of required) {
53
+ const check = checks.find((row) => row.name === name);
54
+ if (check == null ||
55
+ Number(check.valid) !== 1 ||
56
+ !expressions.some((expression) => normalize(expression) === normalize(check.clause)))
57
+ throw new Error(`Missing or incompatible enforced check: ${name}`);
58
+ }
59
+ }
60
+ catch (cause) {
61
+ throw ERR_DATABASE({ message: upgrade, cause });
62
+ }
63
+ }
@@ -50,6 +50,9 @@ export function classifyError(err) {
50
50
  const match = typeof e.message === 'string' ? e.message.match(/CONSTRAINT [`']([^`']+)[`']/) : null;
51
51
  return { code: DbErrorCodes.FOREIGN_KEY_VIOLATION, constraint: match?.[1] };
52
52
  }
53
+ if (e.errno === 1213 || e.errno === 1205) {
54
+ return { code: DbErrorCodes.LOCK_CONFLICT };
55
+ }
53
56
  e = e.cause;
54
57
  }
55
58
  return { code: DbErrorCodes.UNKNOWN };
@@ -99,3 +99,11 @@ runClassifyErrorContract([
99
99
  unrelatedError: { code: 'ER_DATA_TOO_LONG', errno: 1406, sqlState: '22001' },
100
100
  },
101
101
  ]);
102
+ describe('lock conflict classification', () => {
103
+ it.each([1213, 1205])('classifies driver lock failure %s through a cause', (value) => {
104
+ expect(classifyError({ cause: { errno: value } })).toEqual({ code: 'DB_LOCK_CONFLICT' });
105
+ });
106
+ it('does not classify connection loss as safe contention', () => {
107
+ expect(classifyError({ code: 'ECONNRESET', errno: 2013 })).toEqual({ code: 'DB_UNKNOWN' });
108
+ });
109
+ });
@@ -0,0 +1,9 @@
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 { DBManager } from '../../lib/db-manager.js';
9
+ export declare function lockCollectionRegistration(dbManager: DBManager, collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
@@ -0,0 +1,32 @@
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 { ERR_DATABASE, ERR_NOT_FOUND, ERR_VALIDATION } from '@byline/core';
9
+ import { sql } from 'drizzle-orm';
10
+ export async function lockCollectionRegistration(dbManager, collectionId, mode) {
11
+ if (mode !== 'shared' && mode !== 'exclusive') {
12
+ throw ERR_VALIDATION({ message: 'Invalid collection registration lock mode' });
13
+ }
14
+ if (!dbManager.isInTransaction()) {
15
+ throw ERR_DATABASE({
16
+ message: 'collection registration locks require an active transaction',
17
+ details: { collectionId },
18
+ });
19
+ }
20
+ const locked = await dbManager.get().execute(sql `
21
+ SELECT id FROM byline_collections
22
+ WHERE id = ${collectionId}
23
+ ${mode === 'exclusive' ? sql `FOR UPDATE` : sql `FOR SHARE`}
24
+ `);
25
+ const rows = locked[0];
26
+ if (rows.length === 0) {
27
+ throw ERR_NOT_FOUND({
28
+ message: 'collection registration not found',
29
+ details: { collectionId },
30
+ });
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ import { type DocumentRevisionReceipt, type DocumentRevisionStore, type DocumentRevisionTarget, type DocumentStructuralState, type LockedDocumentRevision } from '@byline/core';
2
+ import type { DBManager } from '../../lib/db-manager.js';
3
+ export declare class DocumentRevisions implements DocumentRevisionStore {
4
+ private readonly manager;
5
+ private readonly issued;
6
+ private readonly lockOrder;
7
+ private readonly acquiring;
8
+ constructor(manager: DBManager);
9
+ readStructure(params: {
10
+ collectionId: string;
11
+ documentIds?: readonly string[];
12
+ parentDocumentId?: string;
13
+ }): Promise<DocumentStructuralState[]>;
14
+ isInTransaction(): boolean;
15
+ assertCompatibleSchema(): Promise<void>;
16
+ private scope;
17
+ lock(targets: readonly DocumentRevisionTarget[]): Promise<readonly LockedDocumentRevision[]>;
18
+ private lockSequentially;
19
+ advance(locked: LockedDocumentRevision): Promise<DocumentRevisionReceipt>;
20
+ }
@@ -0,0 +1,197 @@
1
+ import { assertDocumentVersionParent, documentRevisionFromDatabase, ERR_DATABASE, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION, parseDocumentRevision, } from '@byline/core';
2
+ import { and, desc, eq, sql } from 'drizzle-orm';
3
+ import { documentAvailableLocales, documentPaths, documents, documentVersions, } from '../../database/schema/index.js';
4
+ import { assertRevisionSchema } from '../../lib/revision-schema.js';
5
+ export class DocumentRevisions {
6
+ manager;
7
+ issued = new WeakMap();
8
+ lockOrder = new WeakMap();
9
+ acquiring = new WeakSet();
10
+ constructor(manager) {
11
+ this.manager = manager;
12
+ }
13
+ async readStructure(params) {
14
+ this.scope();
15
+ const ids = params.documentIds ?? [];
16
+ const filtered = params.documentIds !== undefined || params.parentDocumentId !== undefined;
17
+ const selection = ids.length
18
+ ? sql `d.id IN (${sql.join(ids.map((id) => sql `${id}`), sql `, `)})`
19
+ : sql `FALSE`;
20
+ const result = await this.manager.get().execute(sql `
21
+ SELECT d.id, d.revision, d.order_key,
22
+ r.child_document_id, r.parent_document_id, r.order_key AS tree_order_key,
23
+ EXISTS (SELECT 1 FROM byline_document_versions v WHERE v.document_id = d.id AND v.is_deleted = false) AS live
24
+ FROM byline_documents d
25
+ LEFT JOIN byline_document_relationships r ON r.child_document_id = d.id
26
+ WHERE d.collection_id = ${params.collectionId}
27
+ AND (${!filtered} OR ${selection} OR r.parent_document_id = ${params.parentDocumentId ?? null})
28
+ ORDER BY d.id
29
+ `);
30
+ const rows = result[0];
31
+ return rows.map((row) => ({
32
+ documentId: row.id,
33
+ revision: documentRevisionFromDatabase(row.revision),
34
+ orderKey: row.order_key,
35
+ placed: row.child_document_id !== null,
36
+ parentDocumentId: row.parent_document_id,
37
+ treeOrderKey: row.tree_order_key,
38
+ live: Boolean(row.live),
39
+ }));
40
+ }
41
+ isInTransaction() {
42
+ return this.manager.isInTransaction();
43
+ }
44
+ assertCompatibleSchema() {
45
+ return assertRevisionSchema(this.manager.get());
46
+ }
47
+ scope() {
48
+ const scope = this.manager.getTransactionScope();
49
+ if (scope == null)
50
+ throw ERR_DATABASE({
51
+ message: 'Document revision primitives require an active adapter transaction.',
52
+ });
53
+ return scope;
54
+ }
55
+ async lock(targets) {
56
+ const scope = this.scope();
57
+ if (this.acquiring.has(scope))
58
+ throw ERR_DATABASE({
59
+ message: 'Concurrent document lock requests inside one transaction are not allowed.',
60
+ });
61
+ this.acquiring.add(scope);
62
+ try {
63
+ return await this.lockSequentially(targets, scope);
64
+ }
65
+ finally {
66
+ this.acquiring.delete(scope);
67
+ }
68
+ }
69
+ async lockSequentially(targets, scope) {
70
+ const token = this.manager.getTransactionToken();
71
+ // Validate all caller values before acquiring even the first lock.
72
+ const ordered = targets
73
+ .map((target) => {
74
+ if (![target.documentId, target.collectionId].every((id) => typeof id === 'string' &&
75
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))) {
76
+ throw ERR_VALIDATION({
77
+ message: 'Revision targets require valid document and collection UUIDs.',
78
+ });
79
+ }
80
+ return {
81
+ ...target,
82
+ documentId: target.documentId.toLowerCase(),
83
+ collectionId: target.collectionId.toLowerCase(),
84
+ expectedRevision: parseDocumentRevision(target.expectedRevision),
85
+ };
86
+ })
87
+ .sort((a, b) => (a.documentId < b.documentId ? -1 : a.documentId > b.documentId ? 1 : 0));
88
+ if (new Set(ordered.map((target) => target.documentId)).size !== ordered.length) {
89
+ throw ERR_VALIDATION({ message: 'Duplicate document revision lock targets are not allowed.' });
90
+ }
91
+ const last = this.lockOrder.get(scope);
92
+ if (last != null && ordered.some((target) => target.documentId <= last)) {
93
+ throw ERR_DATABASE({
94
+ message: 'Document revision locks must be acquired once in increasing document identity order, including across savepoints.',
95
+ });
96
+ }
97
+ const executor = this.manager.get();
98
+ const locked = [];
99
+ for (const target of ordered) {
100
+ const [document] = await executor
101
+ .select({
102
+ revision: sql `${documents.revision}`,
103
+ sourceLocale: documents.source_locale,
104
+ })
105
+ .from(documents)
106
+ .where(and(eq(documents.id, target.documentId), eq(documents.collection_id, target.collectionId)))
107
+ .for('update');
108
+ this.lockOrder.set(scope, target.documentId);
109
+ if (document == null)
110
+ throw ERR_NOT_FOUND({ message: 'Document is unavailable.' });
111
+ const [current] = await executor
112
+ .select({ id: documentVersions.id, status: documentVersions.status })
113
+ .from(documentVersions)
114
+ .where(and(eq(documentVersions.document_id, target.documentId), eq(documentVersions.is_deleted, false)))
115
+ .orderBy(desc(documentVersions.id))
116
+ .limit(1);
117
+ if (current == null) {
118
+ const [anyVersion] = await executor
119
+ .select({ id: documentVersions.id })
120
+ .from(documentVersions)
121
+ .where(eq(documentVersions.document_id, target.documentId))
122
+ .limit(1);
123
+ if (anyVersion != null)
124
+ throw ERR_NOT_FOUND({ message: 'Document is unavailable.' });
125
+ }
126
+ const revision = documentRevisionFromDatabase(document.revision);
127
+ if (revision !== target.expectedRevision)
128
+ throw ERR_DOCUMENT_STALE({
129
+ message: 'This document has changed. Reload it before making further changes.',
130
+ details: {
131
+ reason: 'revision_mismatch',
132
+ documentId: target.documentId,
133
+ expectedRevision: target.expectedRevision,
134
+ currentRevision: revision,
135
+ },
136
+ });
137
+ assertDocumentVersionParent({
138
+ documentId: target.documentId,
139
+ locale: target.locale,
140
+ previousVersionId: target.previousVersionId,
141
+ currentVersionId: current?.id ?? null,
142
+ });
143
+ const [path] = await executor
144
+ .select({ path: documentPaths.path })
145
+ .from(documentPaths)
146
+ .where(and(eq(documentPaths.document_id, target.documentId), eq(documentPaths.locale, document.sourceLocale)))
147
+ .limit(1);
148
+ const locales = await executor
149
+ .select({ locale: documentAvailableLocales.locale })
150
+ .from(documentAvailableLocales)
151
+ .where(eq(documentAvailableLocales.document_id, target.documentId));
152
+ const observation = Object.freeze({
153
+ documentId: target.documentId,
154
+ collectionId: target.collectionId,
155
+ revision,
156
+ currentVersionId: current?.id ?? null,
157
+ status: current?.status ?? null,
158
+ sourceLocale: document.sourceLocale,
159
+ path: path?.path ?? null,
160
+ availableLocales: Object.freeze(locales.map((row) => row.locale).sort()),
161
+ });
162
+ this.issued.set(observation, {
163
+ scope,
164
+ token,
165
+ documentId: target.documentId,
166
+ collectionId: target.collectionId,
167
+ revision,
168
+ });
169
+ locked.push(observation);
170
+ }
171
+ return Object.freeze(locked);
172
+ }
173
+ async advance(locked) {
174
+ const scope = this.scope();
175
+ const original = this.issued.get(locked);
176
+ if (original == null ||
177
+ original.scope !== scope ||
178
+ !this.manager.isTransactionTokenActive(original.token))
179
+ throw ERR_DATABASE({
180
+ message: 'Document revision observation does not belong to this active transaction.',
181
+ });
182
+ if (original.revision === Number.MAX_SAFE_INTEGER)
183
+ throw ERR_DATABASE({
184
+ message: 'Document revision exhausted its safe integer range; the mutation was not committed.',
185
+ });
186
+ const next = original.revision + 1;
187
+ const executor = this.manager.get();
188
+ const predicate = and(eq(documents.id, original.documentId), eq(documents.collection_id, original.collectionId), eq(documents.revision, original.revision));
189
+ const [changed] = await executor.update(documents).set({ revision: next }).where(predicate);
190
+ const count = changed.affectedRows;
191
+ if (count !== 1)
192
+ throw ERR_DATABASE({
193
+ message: 'Locked document revision was already advanced or changed; roll back the mutation.',
194
+ });
195
+ return { documentId: original.documentId, revision: next };
196
+ }
197
+ }
@@ -15,6 +15,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
15
15
  private dbManager;
16
16
  constructor(dbManager: DBManager);
17
17
  private get db();
18
+ lockDocuments(documentIds: readonly string[]): Promise<void>;
18
19
  private lockCurrentVersion;
19
20
  private lockSchedule;
20
21
  private readSchedule;
@@ -22,6 +23,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
22
23
  documentId: string;
23
24
  collectionId: string;
24
25
  expectedVersionId: string;
26
+ authorizedRevision: number;
25
27
  publishAt: Date;
26
28
  actorId: string | null;
27
29
  }): Promise<ScheduleDocumentPublishResult>;
@@ -29,6 +31,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
29
31
  documentId: string;
30
32
  collectionId: string;
31
33
  expectedVersionId: string;
34
+ authorizedRevision: number;
32
35
  actorId: string | null;
33
36
  }): Promise<ConfirmDocumentPublishScheduleResult>;
34
37
  cancel(params: {
@@ -38,6 +41,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
38
41
  suspendForContentEdit(params: {
39
42
  documentId: string;
40
43
  collectionId: string;
44
+ reason?: 'content_edited' | 'document_metadata_changed';
41
45
  }): Promise<SuspendDocumentPublishScheduleResult>;
42
46
  claimDue(params: {
43
47
  batchSize: number;
@@ -52,6 +56,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
52
56
  executionToken: string;
53
57
  }): Promise<boolean>;
54
58
  suspendClaimForContentEdit(params: {
59
+ reason?: 'content_edited' | 'document_metadata_changed';
55
60
  documentId: string;
56
61
  executionToken: string;
57
62
  }): Promise<boolean>;
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { documentRevisionFromDatabase, parseDocumentRevision } from '@byline/core';
9
10
  import { sql } from 'drizzle-orm';
10
11
  import { affectedRowCount, toDate } from './storage-utils.js';
11
12
  const LAST_ERROR_MAX_LENGTH = 2_048;
@@ -17,6 +18,9 @@ function requiredDate(value, column) {
17
18
  }
18
19
  function toSchedule(row) {
19
20
  return {
21
+ authorizedRevision: row.authorized_revision == null
22
+ ? null
23
+ : documentRevisionFromDatabase(row.authorized_revision),
20
24
  documentId: row.document_id,
21
25
  collectionId: row.collection_id,
22
26
  targetVersionId: row.target_version_id,
@@ -58,6 +62,13 @@ export class DocumentPublishScheduleCommands {
58
62
  get db() {
59
63
  return this.dbManager.get();
60
64
  }
65
+ async lockDocuments(documentIds) {
66
+ if (!this.dbManager.isInTransaction())
67
+ throw new Error('Schedule locks require an active transaction');
68
+ for (const documentId of [...new Set(documentIds)].sort()) {
69
+ await this.db.execute(sql `SELECT document_id FROM byline_document_publish_schedules WHERE document_id = ${documentId} FOR UPDATE`);
70
+ }
71
+ }
61
72
  async lockCurrentVersion(params) {
62
73
  const document = await this.db.execute(sql `
63
74
  SELECT id
@@ -99,6 +110,7 @@ export class DocumentPublishScheduleCommands {
99
110
  return toSchedule(row);
100
111
  }
101
112
  async schedule(params) {
113
+ params = { ...params, authorizedRevision: parseDocumentRevision(params.authorizedRevision) };
102
114
  const currentVersionId = await this.lockCurrentVersion(params);
103
115
  if (currentVersionId === null)
104
116
  return { status: 'document_not_found' };
@@ -119,7 +131,7 @@ export class DocumentPublishScheduleCommands {
119
131
  if (previous === null) {
120
132
  await this.db.execute(sql `
121
133
  INSERT INTO byline_document_publish_schedules (
122
- document_id, collection_id, target_version_id, publish_at,
134
+ document_id, collection_id, target_version_id, authorized_revision, publish_at,
123
135
  state, suspended_at, suspended_reason,
124
136
  scheduled_by, last_authorized_by, last_authorized_at,
125
137
  execution_token, execution_expires_at, last_attempt_at,
@@ -128,6 +140,7 @@ export class DocumentPublishScheduleCommands {
128
140
  ${params.documentId},
129
141
  ${params.collectionId},
130
142
  ${params.expectedVersionId},
143
+ ${params.authorizedRevision},
131
144
  ${params.publishAt},
132
145
  'armed', NULL, NULL,
133
146
  ${params.actorId}, ${params.actorId}, CURRENT_TIMESTAMP(6),
@@ -140,6 +153,7 @@ export class DocumentPublishScheduleCommands {
140
153
  await this.db.execute(sql `
141
154
  UPDATE byline_document_publish_schedules SET
142
155
  target_version_id = ${params.expectedVersionId},
156
+ authorized_revision = ${params.authorizedRevision},
143
157
  publish_at = ${params.publishAt},
144
158
  state = 'armed',
145
159
  suspended_at = NULL,
@@ -159,6 +173,7 @@ export class DocumentPublishScheduleCommands {
159
173
  return { status: 'scheduled', schedule: await this.readSchedule(params.documentId), previous };
160
174
  }
161
175
  async confirm(params) {
176
+ params = { ...params, authorizedRevision: parseDocumentRevision(params.authorizedRevision) };
162
177
  const currentVersionId = await this.lockCurrentVersion(params);
163
178
  if (currentVersionId === null || currentVersionId !== params.expectedVersionId) {
164
179
  return { status: 'version_mismatch' };
@@ -172,6 +187,7 @@ export class DocumentPublishScheduleCommands {
172
187
  await this.db.execute(sql `
173
188
  UPDATE byline_document_publish_schedules SET
174
189
  target_version_id = ${params.expectedVersionId},
190
+ authorized_revision = ${params.authorizedRevision},
175
191
  state = 'armed',
176
192
  suspended_at = NULL,
177
193
  suspended_reason = NULL,
@@ -214,7 +230,7 @@ export class DocumentPublishScheduleCommands {
214
230
  UPDATE byline_document_publish_schedules SET
215
231
  state = 'needs_reconfirm',
216
232
  suspended_at = CURRENT_TIMESTAMP(6),
217
- suspended_reason = 'content_edited',
233
+ suspended_reason = ${params.reason ?? 'content_edited'},
218
234
  execution_token = NULL,
219
235
  execution_expires_at = NULL,
220
236
  updated_at = CURRENT_TIMESTAMP(6)
@@ -291,6 +307,8 @@ export class DocumentPublishScheduleCommands {
291
307
  FROM byline_document_publish_schedules
292
308
  WHERE document_id = ${params.documentId}
293
309
  AND execution_token = ${params.executionToken}
310
+ AND state = 'armed'
311
+ AND execution_expires_at > CURRENT_TIMESTAMP(6)
294
312
  FOR UPDATE
295
313
  `);
296
314
  const row = firstRow(result);
@@ -301,6 +319,7 @@ export class DocumentPublishScheduleCommands {
301
319
  DELETE FROM byline_document_publish_schedules
302
320
  WHERE document_id = ${params.documentId}
303
321
  AND execution_token = ${params.executionToken}
322
+ AND state = 'armed' AND execution_expires_at > CURRENT_TIMESTAMP(6)
304
323
  `);
305
324
  return affectedRowCount(result) === 1;
306
325
  }
@@ -309,7 +328,7 @@ export class DocumentPublishScheduleCommands {
309
328
  UPDATE byline_document_publish_schedules SET
310
329
  state = 'needs_reconfirm',
311
330
  suspended_at = CURRENT_TIMESTAMP(6),
312
- suspended_reason = 'content_edited',
331
+ suspended_reason = ${params.reason ?? 'content_edited'},
313
332
  execution_token = NULL,
314
333
  execution_expires_at = NULL,
315
334
  last_attempt_at = NULL,
@@ -319,6 +338,7 @@ export class DocumentPublishScheduleCommands {
319
338
  updated_at = CURRENT_TIMESTAMP(6)
320
339
  WHERE document_id = ${params.documentId}
321
340
  AND execution_token = ${params.executionToken}
341
+ AND state = 'armed' AND execution_expires_at > CURRENT_TIMESTAMP(6)
322
342
  `);
323
343
  return affectedRowCount(result) === 1;
324
344
  }
@@ -0,0 +1,3 @@
1
+ import type { CollectionDefinition, IDbAdapter } from '@byline/core';
2
+ import type { DBExecutor } from '../../lib/db-manager.js';
3
+ export declare function createReadSnapshot(db: DBExecutor, collections: readonly CollectionDefinition[], defaultLocale: string): IDbAdapter['withReadSnapshot'];
@@ -0,0 +1,12 @@
1
+ import { runReadSnapshot } from '@byline/core';
2
+ import { DBManagerImpl } from '../../lib/db-manager.js';
3
+ import { createAuditQueries } from '../audit/audit-queries.js';
4
+ import { SingletonQueries } from './singletons.js';
5
+ import { createQueryBuilders } from './storage-queries.js';
6
+ export function createReadSnapshot(db, collections, defaultLocale) {
7
+ return (fn) => db.transaction(async (tx) => {
8
+ const manager = new DBManagerImpl({ dbPool: tx });
9
+ const queries = createQueryBuilders(tx, collections, defaultLocale, manager);
10
+ return runReadSnapshot({ ...queries, audit: createAuditQueries(tx), singletons: new SingletonQueries(manager) }, fn);
11
+ }, { isolationLevel: 'repeatable read', accessMode: 'read only' });
12
+ }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { type ISingletonCommands, type ISingletonQueries } from '@byline/core';
8
+ import type { ISingletonCommands, ISingletonQueries } from '@byline/core';
9
9
  import type { DBManager } from '../../lib/db-manager.js';
10
10
  export declare class SingletonCommands implements ISingletonCommands {
11
11
  private dbManager;
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { ERR_DATABASE, ERR_NOT_FOUND, } from '@byline/core';
9
- import { eq, sql } from 'drizzle-orm';
8
+ import { eq } from 'drizzle-orm';
10
9
  import { singletonDocuments } from '../../database/schema/index.js';
10
+ import { lockCollectionRegistration } from './collection-registration.js';
11
11
  export class SingletonCommands {
12
12
  dbManager;
13
13
  constructor(dbManager) {
@@ -21,24 +21,7 @@ export class SingletonCommands {
21
21
  return this.dbManager.get();
22
22
  }
23
23
  async lockSlot(collectionId) {
24
- if (!this.dbManager.isInTransaction()) {
25
- throw ERR_DATABASE({
26
- message: 'singleton slot locks require an active transaction',
27
- details: { collectionId },
28
- });
29
- }
30
- const locked = await this.db.execute(sql `
31
- SELECT id FROM byline_collections
32
- WHERE id = ${collectionId}
33
- FOR UPDATE
34
- `);
35
- const rows = locked[0];
36
- if (rows.length === 0) {
37
- throw ERR_NOT_FOUND({
38
- message: 'singleton slot registration not found',
39
- details: { collectionId },
40
- });
41
- }
24
+ await lockCollectionRegistration(this.dbManager, collectionId, 'exclusive');
42
25
  }
43
26
  async setMapping(collectionId, documentId) {
44
27
  await this.db
@@ -20,6 +20,7 @@ import type { DBManager } from '../../lib/db-manager.js';
20
20
  */
21
21
  export declare class CollectionCommands implements ICollectionCommands {
22
22
  private dbManager;
23
+ lockCollectionRegistration(collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
23
24
  constructor(dbManager: DBManager);
24
25
  /**
25
26
  * The executor for this call — the ambient transaction when a
@@ -384,6 +385,7 @@ export declare class DocumentCommands implements IDocumentCommands {
384
385
  */
385
386
  restoreSoftDeletedDocument(params: {
386
387
  document_id: string;
388
+ expectedRevision: number;
387
389
  }): Promise<number>;
388
390
  private readDocumentVersionLiveness;
389
391
  /**
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { DbErrorCodes, ERR_CONFLICT, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, isSingleton, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
8
+ import { assertDocumentVersionParent, DbErrorCodes, documentRevisionFromDatabase, ERR_CONFLICT, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, isSingleton, parseDocumentRevision, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
9
9
  import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
- import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
11
+ import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentPublishSchedules, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
12
12
  import { classifyError } from './classify-error.js';
13
+ import { lockCollectionRegistration } from './collection-registration.js';
13
14
  import { DocumentPublishScheduleCommands } from './publish-schedules.js';
14
15
  import { prepareFieldInsertBuckets } from './storage-insert.js';
15
16
  import { affectedRowCount, getFirstOrThrow } from './storage-utils.js';
@@ -34,6 +35,9 @@ const staleTreePlacementMessage = (message) => `${TREE_PLACEMENT_STALE_MARKER} t
34
35
  */
35
36
  export class CollectionCommands {
36
37
  dbManager;
38
+ async lockCollectionRegistration(collectionId, mode) {
39
+ await lockCollectionRegistration(this.dbManager, collectionId, mode);
40
+ }
37
41
  constructor(dbManager) {
38
42
  this.dbManager = dbManager;
39
43
  }
@@ -202,6 +206,7 @@ export class DocumentCommands {
202
206
  sourceLocale = this.defaultContentLocale;
203
207
  await tx.insert(documents).values({
204
208
  id: documentId,
209
+ revision: 1,
205
210
  collection_id: params.collectionId,
206
211
  order_key: params.orderKey ?? null,
207
212
  source_locale: sourceLocale,
@@ -221,6 +226,12 @@ export class DocumentCommands {
221
226
  details: { documentId },
222
227
  });
223
228
  }
229
+ assertDocumentVersionParent({
230
+ documentId,
231
+ locale: params.locale,
232
+ previousVersionId: params.previousVersionId,
233
+ currentVersionId: versionLiveness.currentVersionId,
234
+ });
224
235
  sourceLocale = existing.source_locale ?? this.defaultContentLocale;
225
236
  }
226
237
  // 2. Create the document version. The id is minted here (app-side
@@ -799,18 +810,30 @@ export class DocumentCommands {
799
810
  * reconstructed by this storage primitive.
800
811
  */
801
812
  async restoreSoftDeletedDocument(params) {
813
+ params = { ...params, expectedRevision: parseDocumentRevision(params.expectedRevision) };
802
814
  const restoredAt = new Date();
803
815
  return this.db.transaction(async (tx) => {
804
816
  const collectionId = await this.lockDocumentCollection(tx, params.document_id);
805
817
  if (collectionId == null)
806
818
  return 0;
807
819
  const [document] = await tx
808
- .select({ id: documents.id })
820
+ .select({ id: documents.id, revision: documents.revision })
809
821
  .from(documents)
810
822
  .where(eq(documents.id, params.document_id))
811
823
  .for('update');
812
824
  if (document == null)
813
825
  return 0;
826
+ const currentRevision = documentRevisionFromDatabase(document.revision);
827
+ if (currentRevision !== params.expectedRevision)
828
+ throw ERR_DOCUMENT_STALE({
829
+ message: 'This document has changed. Reload before restoring.',
830
+ details: {
831
+ reason: 'revision_mismatch',
832
+ documentId: params.document_id,
833
+ expectedRevision: params.expectedRevision,
834
+ currentRevision,
835
+ },
836
+ });
814
837
  const versionLiveness = await this.readDocumentVersionLiveness(tx, params.document_id);
815
838
  if (versionLiveness.total === 0 || versionLiveness.live > 0)
816
839
  return 0;
@@ -821,6 +844,17 @@ export class DocumentCommands {
821
844
  updated_at: restoredAt,
822
845
  })
823
846
  .where(eq(documentPaths.document_id, params.document_id));
847
+ // Internal restores cannot revive a schedule left by legacy or raw maintenance.
848
+ // Keep this on the same transaction executor, after the collection/document locks.
849
+ await tx
850
+ .delete(documentPublishSchedules)
851
+ .where(eq(documentPublishSchedules.document_id, params.document_id));
852
+ if (currentRevision === Number.MAX_SAFE_INTEGER)
853
+ throw ERR_VALIDATION({ message: 'Document revision exhausted' });
854
+ await tx
855
+ .update(documents)
856
+ .set({ revision: currentRevision + 1, updated_at: restoredAt })
857
+ .where(and(eq(documents.id, params.document_id), eq(documents.revision, currentRevision)));
824
858
  const result = await tx
825
859
  .update(documentVersions)
826
860
  .set({
@@ -833,10 +867,12 @@ export class DocumentCommands {
833
867
  }
834
868
  async readDocumentVersionLiveness(tx, documentId) {
835
869
  // Match the live views: only explicit `false` is live; legacy `NULL` is not.
870
+ // Their current version is likewise the greatest live UUIDv7 id.
836
871
  return tx
837
872
  .select({
838
873
  total: sql `CAST(COUNT(*) AS SIGNED)`,
839
874
  live: sql `CAST(COUNT(CASE WHEN ${documentVersions.is_deleted} = false THEN 1 END) AS SIGNED)`,
875
+ currentVersionId: sql `MAX(CASE WHEN ${documentVersions.is_deleted} = false THEN ${documentVersions.id} ELSE NULL END)`,
840
876
  })
841
877
  .from(documentVersions)
842
878
  .where(eq(documentVersions.document_id, documentId))
@@ -75,6 +75,10 @@ export declare class CollectionQueries implements ICollectionQueries {
75
75
  * DocumentQueries
76
76
  */
77
77
  export declare class DocumentQueries implements IDocumentQueries {
78
+ getDocumentRevision(params: {
79
+ collection_id: string;
80
+ document_id: string;
81
+ }): Promise<number | null>;
78
82
  readonly publishSchedules: DocumentPublishScheduleQueries;
79
83
  private db;
80
84
  private transactionDb;
@@ -62,6 +62,14 @@ export class CollectionQueries {
62
62
  * DocumentQueries
63
63
  */
64
64
  export class DocumentQueries {
65
+ async getDocumentRevision(params) {
66
+ const rows = await this.db
67
+ .select({ revision: documents.revision })
68
+ .from(documents)
69
+ .where(and(eq(documents.id, params.document_id), eq(documents.collection_id, params.collection_id)))
70
+ .limit(1);
71
+ return rows[0]?.revision ?? null;
72
+ }
65
73
  publishSchedules;
66
74
  db;
67
75
  transactionDb;
@@ -1722,6 +1730,7 @@ export class DocumentQueries {
1722
1730
  : sql `(d.order_key IS NULL) ASC, d.order_key ASC, d.created_at DESC`;
1723
1731
  }
1724
1732
  const columnMap = {
1733
+ document_id: 'd.document_id',
1725
1734
  created_at: 'd.created_at',
1726
1735
  updated_at: 'd.updated_at',
1727
1736
  };
@@ -144,7 +144,10 @@ describe('live-path query resolution (MySQL)', () => {
144
144
  previousVersionId: first.document.id,
145
145
  });
146
146
  await testDb.commandBuilders.documents.softDeleteDocument({ document_id: documentId });
147
- await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({ document_id: documentId })).resolves.toBe(2);
147
+ await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
148
+ expectedRevision: 1,
149
+ document_id: documentId,
150
+ })).resolves.toBe(2);
148
151
  const restoredPaths = await testDb.db
149
152
  .select({
150
153
  path: documentPaths.path,
@@ -180,7 +183,10 @@ describe('live-path query resolution (MySQL)', () => {
180
183
  .update(documentPaths)
181
184
  .set({ deleted_at: null })
182
185
  .where(eq(documentPaths.document_id, documentId));
183
- await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({ document_id: documentId })).resolves.toBe(0);
186
+ await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
187
+ expectedRevision: 2,
188
+ document_id: documentId,
189
+ })).resolves.toBe(0);
184
190
  const legacyPaths = await testDb.db
185
191
  .select({ deletedAt: documentPaths.deleted_at })
186
192
  .from(documentPaths)
@@ -196,6 +202,7 @@ describe('live-path query resolution (MySQL)', () => {
196
202
  const documentId = crypto.randomUUID();
197
203
  const path = `versionless-${timestamp}`;
198
204
  await testDb.db.insert(documents).values({
205
+ revision: 1,
199
206
  id: documentId,
200
207
  collection_id: collectionId,
201
208
  source_locale: 'en',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-mysql",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.18.0",
5
+ "version": "5.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -50,8 +50,8 @@
50
50
  "drizzle-orm": "^0.45.2",
51
51
  "mysql2": "^3.24.2",
52
52
  "uuid": "^14.0.2",
53
- "@byline/admin": "4.18.0",
54
- "@byline/core": "4.18.0"
53
+ "@byline/admin": "5.0.0",
54
+ "@byline/core": "5.0.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@biomejs/biome": "2.5.10",
@@ -64,9 +64,9 @@
64
64
  "tsc-alias": "^1.9.2",
65
65
  "typescript": "^7.0.2",
66
66
  "vitest": "^4.1.11",
67
- "@byline/client": "4.18.0",
68
- "@byline/db-conformance": "0.0.10",
69
- "@byline/auth": "4.18.0"
67
+ "@byline/client": "5.0.0",
68
+ "@byline/auth": "5.0.0",
69
+ "@byline/db-conformance": "0.0.10"
70
70
  },
71
71
  "publishConfig": {
72
72
  "access": "public",