@byline/db-mysql 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 (39) hide show
  1. package/dist/database/schema/auth.d.ts +202 -0
  2. package/dist/database/schema/auth.js +29 -0
  3. package/dist/database/schema/index.d.ts +35 -1
  4. package/dist/database/schema/index.js +7 -2
  5. package/dist/database/schema/schema-pins.test.node.js +18 -2
  6. package/dist/index.js +4 -0
  7. package/dist/lib/db-manager.d.ts +10 -0
  8. package/dist/lib/db-manager.js +73 -16
  9. package/dist/lib/db-manager.test.node.d.ts +1 -0
  10. package/dist/lib/db-manager.test.node.js +82 -0
  11. package/dist/lib/revision-schema.d.ts +2 -0
  12. package/dist/lib/revision-schema.js +63 -0
  13. package/dist/modules/admin/admin-store.js +30 -0
  14. package/dist/modules/admin/admin-users-repository.js +37 -6
  15. package/dist/modules/admin/login-sessions-repository.d.ts +11 -0
  16. package/dist/modules/admin/login-sessions-repository.js +38 -0
  17. package/dist/modules/admin/refresh-tokens-repository.js +18 -24
  18. package/dist/modules/admin/retry-deadlock.d.ts +2 -0
  19. package/dist/modules/admin/retry-deadlock.js +22 -0
  20. package/dist/modules/admin/sign-in-rate-limit-store.d.ts +11 -0
  21. package/dist/modules/admin/sign-in-rate-limit-store.js +37 -0
  22. package/dist/modules/storage/classify-error.js +3 -0
  23. package/dist/modules/storage/classify-error.test.node.js +8 -0
  24. package/dist/modules/storage/collection-registration.d.ts +9 -0
  25. package/dist/modules/storage/collection-registration.js +32 -0
  26. package/dist/modules/storage/document-revisions.d.ts +20 -0
  27. package/dist/modules/storage/document-revisions.js +197 -0
  28. package/dist/modules/storage/publish-schedules.d.ts +5 -0
  29. package/dist/modules/storage/publish-schedules.js +23 -3
  30. package/dist/modules/storage/read-snapshot.d.ts +3 -0
  31. package/dist/modules/storage/read-snapshot.js +12 -0
  32. package/dist/modules/storage/singletons.d.ts +1 -1
  33. package/dist/modules/storage/singletons.js +3 -20
  34. package/dist/modules/storage/storage-commands.d.ts +2 -0
  35. package/dist/modules/storage/storage-commands.js +31 -3
  36. package/dist/modules/storage/storage-queries.d.ts +4 -0
  37. package/dist/modules/storage/storage-queries.js +9 -0
  38. package/dist/modules/storage/tests/storage-live-path-queries.test.js +9 -2
  39. package/package.json +10 -10
@@ -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 { assertDocumentVersionParent, 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,
@@ -805,18 +810,30 @@ export class DocumentCommands {
805
810
  * reconstructed by this storage primitive.
806
811
  */
807
812
  async restoreSoftDeletedDocument(params) {
813
+ params = { ...params, expectedRevision: parseDocumentRevision(params.expectedRevision) };
808
814
  const restoredAt = new Date();
809
815
  return this.db.transaction(async (tx) => {
810
816
  const collectionId = await this.lockDocumentCollection(tx, params.document_id);
811
817
  if (collectionId == null)
812
818
  return 0;
813
819
  const [document] = await tx
814
- .select({ id: documents.id })
820
+ .select({ id: documents.id, revision: documents.revision })
815
821
  .from(documents)
816
822
  .where(eq(documents.id, params.document_id))
817
823
  .for('update');
818
824
  if (document == null)
819
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
+ });
820
837
  const versionLiveness = await this.readDocumentVersionLiveness(tx, params.document_id);
821
838
  if (versionLiveness.total === 0 || versionLiveness.live > 0)
822
839
  return 0;
@@ -827,6 +844,17 @@ export class DocumentCommands {
827
844
  updated_at: restoredAt,
828
845
  })
829
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)));
830
858
  const result = await tx
831
859
  .update(documentVersions)
832
860
  .set({
@@ -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.19.0",
5
+ "version": "5.1.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -48,24 +48,24 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "drizzle-orm": "^0.45.2",
51
- "mysql2": "^3.24.2",
51
+ "mysql2": "^3.24.4",
52
52
  "uuid": "^14.0.2",
53
- "@byline/admin": "4.19.0",
54
- "@byline/core": "4.19.0"
53
+ "@byline/admin": "5.1.0",
54
+ "@byline/core": "5.1.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@biomejs/biome": "2.5.10",
58
- "@types/node": "^26.4.0",
57
+ "@biomejs/biome": "2.5.12",
58
+ "@types/node": "^26.5.0",
59
59
  "chokidar": "^5.0.0",
60
60
  "chokidar-cli": "^3.0.0",
61
61
  "dotenv": "^17.4.2",
62
62
  "drizzle-kit": "^0.31.10",
63
63
  "npm-run-all": "^4.1.5",
64
- "tsc-alias": "^1.9.2",
64
+ "tsc-alias": "^1.9.4",
65
65
  "typescript": "^7.0.2",
66
- "vitest": "^4.1.11",
67
- "@byline/auth": "4.19.0",
68
- "@byline/client": "4.19.0",
66
+ "vitest": "^5.0.0",
67
+ "@byline/auth": "5.1.0",
68
+ "@byline/client": "5.1.0",
69
69
  "@byline/db-conformance": "0.0.10"
70
70
  },
71
71
  "publishConfig": {