@byline/db-mysql 4.14.1 → 4.16.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.
@@ -0,0 +1,406 @@
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 { randomUUID } from 'node:crypto';
9
+ import { sql } from 'drizzle-orm';
10
+ import { affectedRowCount, toDate } from './storage-utils.js';
11
+ const LAST_ERROR_MAX_LENGTH = 2_048;
12
+ function requiredDate(value, column) {
13
+ const date = toDate(value, column);
14
+ if (date === null)
15
+ throw new Error(`publish schedules: ${column} unexpectedly returned null`);
16
+ return date;
17
+ }
18
+ function toSchedule(row) {
19
+ return {
20
+ documentId: row.document_id,
21
+ collectionId: row.collection_id,
22
+ targetVersionId: row.target_version_id,
23
+ publishAt: requiredDate(row.publish_at, 'publish_at'),
24
+ state: row.state,
25
+ suspendedAt: toDate(row.suspended_at, 'suspended_at'),
26
+ suspendedReason: row.suspended_reason,
27
+ scheduledBy: row.scheduled_by,
28
+ lastAuthorizedBy: row.last_authorized_by,
29
+ lastAuthorizedAt: requiredDate(row.last_authorized_at, 'last_authorized_at'),
30
+ scheduledAt: requiredDate(row.scheduled_at, 'scheduled_at'),
31
+ updatedAt: requiredDate(row.updated_at, 'updated_at'),
32
+ executionToken: row.execution_token,
33
+ executionExpiresAt: toDate(row.execution_expires_at, 'execution_expires_at'),
34
+ lastAttemptAt: toDate(row.last_attempt_at, 'last_attempt_at'),
35
+ nextAttemptAt: requiredDate(row.next_attempt_at, 'next_attempt_at'),
36
+ attemptCount: Number(row.attempt_count),
37
+ lastError: row.last_error,
38
+ };
39
+ }
40
+ function resultRows(result) {
41
+ return result[0];
42
+ }
43
+ function firstRow(result) {
44
+ return resultRows(result)[0];
45
+ }
46
+ function sanitizedError(error) {
47
+ return error
48
+ .replace(/\r?\n[\s\S]*$/, '')
49
+ .trim()
50
+ .slice(0, LAST_ERROR_MAX_LENGTH);
51
+ }
52
+ /** MySQL scheduled-publication writes on the ambient document transaction. */
53
+ export class DocumentPublishScheduleCommands {
54
+ dbManager;
55
+ constructor(dbManager) {
56
+ this.dbManager = dbManager;
57
+ }
58
+ get db() {
59
+ return this.dbManager.get();
60
+ }
61
+ async lockCurrentVersion(params) {
62
+ const document = await this.db.execute(sql `
63
+ SELECT id
64
+ FROM byline_documents
65
+ WHERE id = ${params.documentId}
66
+ AND collection_id = ${params.collectionId}
67
+ FOR UPDATE
68
+ `);
69
+ if (resultRows(document).length === 0)
70
+ return null;
71
+ const current = await this.db.execute(sql `
72
+ SELECT id
73
+ FROM byline_document_versions
74
+ WHERE document_id = ${params.documentId}
75
+ AND collection_id = ${params.collectionId}
76
+ AND is_deleted = false
77
+ ORDER BY id DESC
78
+ LIMIT 1
79
+ `);
80
+ return firstRow(current)?.id ?? null;
81
+ }
82
+ async lockSchedule(documentId) {
83
+ const result = await this.db.execute(sql `
84
+ SELECT *
85
+ FROM byline_document_publish_schedules
86
+ WHERE document_id = ${documentId}
87
+ FOR UPDATE
88
+ `);
89
+ const row = firstRow(result);
90
+ return row === undefined ? null : toSchedule(row);
91
+ }
92
+ async readSchedule(documentId) {
93
+ const result = await this.db.execute(sql `
94
+ SELECT * FROM byline_document_publish_schedules WHERE document_id = ${documentId}
95
+ `);
96
+ const row = firstRow(result);
97
+ if (row === undefined)
98
+ throw new Error('publish schedules: schedule row disappeared');
99
+ return toSchedule(row);
100
+ }
101
+ async schedule(params) {
102
+ const currentVersionId = await this.lockCurrentVersion(params);
103
+ if (currentVersionId === null)
104
+ return { status: 'document_not_found' };
105
+ if (currentVersionId !== params.expectedVersionId)
106
+ return { status: 'version_mismatch' };
107
+ const previous = await this.lockSchedule(params.documentId);
108
+ const validation = await this.db.execute(sql `
109
+ SELECT
110
+ (${params.publishAt} > CURRENT_TIMESTAMP(6)) AS publish_at_is_future,
111
+ (${previous?.executionToken ?? null} IS NOT NULL
112
+ AND ${previous?.executionExpiresAt ?? null} > CURRENT_TIMESTAMP(6)) AS execution_is_live
113
+ `);
114
+ const flags = firstRow(validation);
115
+ if (!flags?.publish_at_is_future)
116
+ return { status: 'publish_at_not_future' };
117
+ if (flags.execution_is_live)
118
+ return { status: 'execution_in_progress' };
119
+ if (previous === null) {
120
+ await this.db.execute(sql `
121
+ INSERT INTO byline_document_publish_schedules (
122
+ document_id, collection_id, target_version_id, publish_at,
123
+ state, suspended_at, suspended_reason,
124
+ scheduled_by, last_authorized_by, last_authorized_at,
125
+ execution_token, execution_expires_at, last_attempt_at,
126
+ next_attempt_at, attempt_count, last_error, updated_at
127
+ ) VALUES (
128
+ ${params.documentId},
129
+ ${params.collectionId},
130
+ ${params.expectedVersionId},
131
+ ${params.publishAt},
132
+ 'armed', NULL, NULL,
133
+ ${params.actorId}, ${params.actorId}, CURRENT_TIMESTAMP(6),
134
+ NULL, NULL, NULL,
135
+ ${params.publishAt}, 0, NULL, CURRENT_TIMESTAMP(6)
136
+ )
137
+ `);
138
+ }
139
+ else {
140
+ await this.db.execute(sql `
141
+ UPDATE byline_document_publish_schedules SET
142
+ target_version_id = ${params.expectedVersionId},
143
+ publish_at = ${params.publishAt},
144
+ state = 'armed',
145
+ suspended_at = NULL,
146
+ suspended_reason = NULL,
147
+ last_authorized_by = ${params.actorId},
148
+ last_authorized_at = CURRENT_TIMESTAMP(6),
149
+ execution_token = NULL,
150
+ execution_expires_at = NULL,
151
+ last_attempt_at = NULL,
152
+ next_attempt_at = ${params.publishAt},
153
+ attempt_count = 0,
154
+ last_error = NULL,
155
+ updated_at = CURRENT_TIMESTAMP(6)
156
+ WHERE document_id = ${params.documentId}
157
+ `);
158
+ }
159
+ return { status: 'scheduled', schedule: await this.readSchedule(params.documentId), previous };
160
+ }
161
+ async confirm(params) {
162
+ const currentVersionId = await this.lockCurrentVersion(params);
163
+ if (currentVersionId === null || currentVersionId !== params.expectedVersionId) {
164
+ return { status: 'version_mismatch' };
165
+ }
166
+ const previous = await this.lockSchedule(params.documentId);
167
+ if (previous === null || previous.collectionId !== params.collectionId) {
168
+ return { status: 'schedule_not_found' };
169
+ }
170
+ if (previous.state !== 'needs_reconfirm')
171
+ return { status: 'not_suspended' };
172
+ await this.db.execute(sql `
173
+ UPDATE byline_document_publish_schedules SET
174
+ target_version_id = ${params.expectedVersionId},
175
+ state = 'armed',
176
+ suspended_at = NULL,
177
+ suspended_reason = NULL,
178
+ last_authorized_by = ${params.actorId},
179
+ last_authorized_at = CURRENT_TIMESTAMP(6),
180
+ execution_token = NULL,
181
+ execution_expires_at = NULL,
182
+ last_attempt_at = NULL,
183
+ next_attempt_at = publish_at,
184
+ attempt_count = 0,
185
+ last_error = NULL,
186
+ updated_at = CURRENT_TIMESTAMP(6)
187
+ WHERE document_id = ${params.documentId}
188
+ `);
189
+ return {
190
+ status: 'confirmed',
191
+ schedule: await this.readSchedule(params.documentId),
192
+ previousTargetVersionId: previous.targetVersionId,
193
+ };
194
+ }
195
+ async cancel(params) {
196
+ const locked = await this.lockSchedule(params.documentId);
197
+ if (locked === null || locked.collectionId !== params.collectionId)
198
+ return null;
199
+ const result = await this.db.execute(sql `
200
+ DELETE FROM byline_document_publish_schedules
201
+ WHERE document_id = ${params.documentId}
202
+ AND collection_id = ${params.collectionId}
203
+ `);
204
+ return affectedRowCount(result) === 1 ? locked : null;
205
+ }
206
+ async suspendForContentEdit(params) {
207
+ const locked = await this.lockSchedule(params.documentId);
208
+ if (locked === null || locked.collectionId !== params.collectionId) {
209
+ return { status: 'schedule_not_found' };
210
+ }
211
+ if (locked.state === 'needs_reconfirm')
212
+ return { status: 'already_suspended' };
213
+ await this.db.execute(sql `
214
+ UPDATE byline_document_publish_schedules SET
215
+ state = 'needs_reconfirm',
216
+ suspended_at = CURRENT_TIMESTAMP(6),
217
+ suspended_reason = 'content_edited',
218
+ execution_token = NULL,
219
+ execution_expires_at = NULL,
220
+ updated_at = CURRENT_TIMESTAMP(6)
221
+ WHERE document_id = ${params.documentId}
222
+ `);
223
+ return { status: 'suspended', schedule: await this.readSchedule(params.documentId) };
224
+ }
225
+ async claimDue(params) {
226
+ const batchSize = Math.max(0, Math.trunc(params.batchSize));
227
+ if (batchSize === 0)
228
+ return [];
229
+ return this.db.transaction(async (tx) => {
230
+ const selected = await tx.execute(sql `
231
+ SELECT document_id, execution_token
232
+ FROM byline_document_publish_schedules
233
+ WHERE state = 'armed'
234
+ AND publish_at <= CURRENT_TIMESTAMP(6)
235
+ AND next_attempt_at <= CURRENT_TIMESTAMP(6)
236
+ AND (execution_expires_at IS NULL OR execution_expires_at <= CURRENT_TIMESTAMP(6))
237
+ ORDER BY publish_at, document_id
238
+ LIMIT ${batchSize}
239
+ FOR UPDATE SKIP LOCKED
240
+ `);
241
+ const candidates = resultRows(selected);
242
+ const claimed = [];
243
+ for (const candidate of candidates) {
244
+ const token = randomUUID();
245
+ const update = await tx.execute(sql `
246
+ UPDATE byline_document_publish_schedules SET
247
+ execution_token = ${token},
248
+ execution_expires_at = TIMESTAMPADD(
249
+ MICROSECOND,
250
+ ${params.leaseMs} * 1000,
251
+ CURRENT_TIMESTAMP(6)
252
+ ),
253
+ last_attempt_at = CURRENT_TIMESTAMP(6),
254
+ attempt_count = attempt_count + 1,
255
+ updated_at = CURRENT_TIMESTAMP(6)
256
+ WHERE document_id = ${candidate.document_id}
257
+ `);
258
+ if (affectedRowCount(update) !== 1) {
259
+ throw new Error(`publish schedules: locked row '${candidate.document_id}' disappeared`);
260
+ }
261
+ const reread = await tx.execute(sql `
262
+ SELECT *
263
+ FROM byline_document_publish_schedules
264
+ WHERE document_id = ${candidate.document_id}
265
+ `);
266
+ const row = firstRow(reread);
267
+ if (row === undefined) {
268
+ throw new Error(`publish schedules: claimed row '${candidate.document_id}' disappeared`);
269
+ }
270
+ const schedule = toSchedule(row);
271
+ if (schedule.executionToken === null ||
272
+ schedule.executionExpiresAt === null ||
273
+ schedule.lastAttemptAt === null) {
274
+ throw new Error('publish schedules: claimed row returned without its execution fence');
275
+ }
276
+ claimed.push({
277
+ ...schedule,
278
+ executionToken: schedule.executionToken,
279
+ executionExpiresAt: schedule.executionExpiresAt,
280
+ lastAttemptAt: schedule.lastAttemptAt,
281
+ databaseNow: schedule.lastAttemptAt,
282
+ recoveredExpiredClaim: candidate.execution_token !== null,
283
+ });
284
+ }
285
+ return claimed;
286
+ }, { isolationLevel: 'read committed' });
287
+ }
288
+ async lockClaim(params) {
289
+ const result = await this.db.execute(sql `
290
+ SELECT *
291
+ FROM byline_document_publish_schedules
292
+ WHERE document_id = ${params.documentId}
293
+ AND execution_token = ${params.executionToken}
294
+ FOR UPDATE
295
+ `);
296
+ const row = firstRow(result);
297
+ return row === undefined ? null : toSchedule(row);
298
+ }
299
+ async deleteClaim(params) {
300
+ const result = await this.db.execute(sql `
301
+ DELETE FROM byline_document_publish_schedules
302
+ WHERE document_id = ${params.documentId}
303
+ AND execution_token = ${params.executionToken}
304
+ `);
305
+ return affectedRowCount(result) === 1;
306
+ }
307
+ async suspendClaimForContentEdit(params) {
308
+ const result = await this.db.execute(sql `
309
+ UPDATE byline_document_publish_schedules SET
310
+ state = 'needs_reconfirm',
311
+ suspended_at = CURRENT_TIMESTAMP(6),
312
+ suspended_reason = 'content_edited',
313
+ execution_token = NULL,
314
+ execution_expires_at = NULL,
315
+ last_attempt_at = NULL,
316
+ next_attempt_at = publish_at,
317
+ attempt_count = 0,
318
+ last_error = NULL,
319
+ updated_at = CURRENT_TIMESTAMP(6)
320
+ WHERE document_id = ${params.documentId}
321
+ AND execution_token = ${params.executionToken}
322
+ `);
323
+ return affectedRowCount(result) === 1;
324
+ }
325
+ /**
326
+ * `attempt_count` is read but not assigned, so MySQL's left-to-right SET
327
+ * evaluation cannot shift the backoff sequence. If it is ever assigned in
328
+ * this statement, keep `next_attempt_at` before that assignment.
329
+ */
330
+ async releaseClaim(params) {
331
+ const result = await this.db.execute(sql `
332
+ UPDATE byline_document_publish_schedules SET
333
+ execution_token = NULL,
334
+ execution_expires_at = NULL,
335
+ last_error = ${sanitizedError(params.error)},
336
+ next_attempt_at = TIMESTAMPADD(
337
+ MINUTE,
338
+ CASE attempt_count
339
+ WHEN 1 THEN 1
340
+ WHEN 2 THEN 2
341
+ WHEN 3 THEN 4
342
+ WHEN 4 THEN 8
343
+ ELSE 15
344
+ END,
345
+ CURRENT_TIMESTAMP(6)
346
+ ),
347
+ updated_at = CURRENT_TIMESTAMP(6)
348
+ WHERE document_id = ${params.documentId}
349
+ AND execution_token = ${params.executionToken}
350
+ `);
351
+ return affectedRowCount(result) === 1;
352
+ }
353
+ }
354
+ /** MySQL scheduled-publication reads. */
355
+ export class DocumentPublishScheduleQueries {
356
+ db;
357
+ constructor(db) {
358
+ this.db = db;
359
+ }
360
+ async get(params) {
361
+ const result = await this.db.execute(sql `
362
+ SELECT *
363
+ FROM byline_document_publish_schedules
364
+ WHERE document_id = ${params.documentId}
365
+ AND collection_id = ${params.collectionId}
366
+ `);
367
+ const row = firstRow(result);
368
+ return row === undefined ? null : toSchedule(row);
369
+ }
370
+ async list(params) {
371
+ if (params.collectionIds.length === 0)
372
+ return { schedules: [], total: 0 };
373
+ const collectionList = sql.join(params.collectionIds.map((id) => sql `${id}`), sql `, `);
374
+ const statePredicate = params.states === undefined
375
+ ? sql ``
376
+ : params.states.length === 0
377
+ ? sql `AND false`
378
+ : sql `AND state IN (${sql.join(params.states.map((state) => sql `${state}`), sql `, `)})`;
379
+ const actorPredicate = params.lastAuthorizedBy === undefined
380
+ ? sql ``
381
+ : sql `AND last_authorized_by = ${params.lastAuthorizedBy}`;
382
+ const offset = Math.max(0, (params.page - 1) * params.pageSize);
383
+ const pageSize = Math.max(0, Math.trunc(params.pageSize));
384
+ const count = await this.db.execute(sql `
385
+ SELECT count(*) AS total
386
+ FROM byline_document_publish_schedules
387
+ WHERE collection_id IN (${collectionList})
388
+ ${statePredicate}
389
+ ${actorPredicate}
390
+ `);
391
+ const total = Number(firstRow(count)?.total ?? 0);
392
+ if (pageSize === 0 || total === 0)
393
+ return { schedules: [], total };
394
+ const rows = await this.db.execute(sql `
395
+ SELECT *
396
+ FROM byline_document_publish_schedules
397
+ WHERE collection_id IN (${collectionList})
398
+ ${statePredicate}
399
+ ${actorPredicate}
400
+ ORDER BY publish_at, document_id
401
+ LIMIT ${pageSize}
402
+ OFFSET ${offset}
403
+ `);
404
+ return { schedules: resultRows(rows).map(toSchedule), total };
405
+ }
406
+ }
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { CollectionDefinition, ICollectionCommands, IDocumentCommands, TreeDeleteMutationResult, TreeMutationResult } from '@byline/core';
9
+ import { DocumentPublishScheduleCommands } from './publish-schedules.js';
9
10
  import type { DBManager } from '../../lib/db-manager.js';
10
11
  /**
11
12
  * CollectionCommands
@@ -78,6 +79,7 @@ export declare class CollectionCommands implements ICollectionCommands {
78
79
  export declare class DocumentCommands implements IDocumentCommands {
79
80
  private dbManager;
80
81
  private defaultContentLocale;
82
+ readonly publishSchedules: DocumentPublishScheduleCommands;
81
83
  constructor(dbManager: DBManager, defaultContentLocale: string);
82
84
  /**
83
85
  * The executor for this call — the ambient transaction when a
@@ -10,6 +10,7 @@ import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
11
  import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
12
12
  import { classifyError } from './classify-error.js';
13
+ import { DocumentPublishScheduleCommands } from './publish-schedules.js';
13
14
  import { prepareFieldInsertBuckets } from './storage-insert.js';
14
15
  import { affectedRowCount, getFirstOrThrow } from './storage-utils.js';
15
16
  /**
@@ -135,9 +136,11 @@ export class CollectionCommands {
135
136
  export class DocumentCommands {
136
137
  dbManager;
137
138
  defaultContentLocale;
139
+ publishSchedules;
138
140
  constructor(dbManager, defaultContentLocale) {
139
141
  this.dbManager = dbManager;
140
142
  this.defaultContentLocale = defaultContentLocale;
143
+ this.publishSchedules = new DocumentPublishScheduleCommands(dbManager);
141
144
  }
142
145
  /**
143
146
  * The executor for this call — the ambient transaction when a
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { CollectionDefinition, DocumentFilter, FieldFilterOperator, FieldSort, FlattenedStore, ICollectionQueries, IDocumentQueries, MissingLocalePolicy, ReadMode } from '@byline/core';
26
26
  import type { MySql2Database } from 'drizzle-orm/mysql2';
27
+ import { DocumentPublishScheduleQueries } from './publish-schedules.js';
27
28
  import type * as schema from '../../database/schema/index.js';
28
29
  import type { DBManager } from '../../lib/db-manager.js';
29
30
  type DatabaseConnection = MySql2Database<typeof schema>;
@@ -74,6 +75,7 @@ export declare class CollectionQueries implements ICollectionQueries {
74
75
  * DocumentQueries
75
76
  */
76
77
  export declare class DocumentQueries implements IDocumentQueries {
78
+ readonly publishSchedules: DocumentPublishScheduleQueries;
77
79
  private db;
78
80
  private transactionDb;
79
81
  private collections;
@@ -25,6 +25,7 @@
25
25
  import { ERR_DATABASE, ERR_NOT_FOUND, extractFlattenedFieldValue, getLogger, orderByContentLocale, resolveIdentityField, resolveStoreTypes, restoreFieldSetData, } from '@byline/core';
26
26
  import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
27
27
  import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
28
+ import { DocumentPublishScheduleQueries } from './publish-schedules.js';
28
29
  import { normalizeRow } from './normalize-row.js';
29
30
  import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
30
31
  import { toDate } from './storage-utils.js';
@@ -61,12 +62,14 @@ export class CollectionQueries {
61
62
  * DocumentQueries
62
63
  */
63
64
  export class DocumentQueries {
65
+ publishSchedules;
64
66
  db;
65
67
  transactionDb;
66
68
  collections;
67
69
  defaultContentLocale;
68
70
  collectionPathCache = new Map();
69
71
  constructor(db, collections, defaultContentLocale, transactionDb) {
72
+ this.publishSchedules = new DocumentPublishScheduleQueries(db);
70
73
  this.db = db;
71
74
  this.transactionDb = transactionDb;
72
75
  this.collections = collections;
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.14.1",
5
+ "version": "4.16.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.23.4",
52
52
  "uuid": "^14.0.2",
53
- "@byline/admin": "4.14.1",
54
- "@byline/core": "4.14.1"
53
+ "@byline/admin": "4.16.0",
54
+ "@byline/core": "4.16.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@biomejs/biome": "2.5.9",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.23.12",
66
66
  "typescript": "^7.0.2",
67
67
  "vitest": "^4.1.11",
68
- "@byline/client": "4.14.1",
69
- "@byline/auth": "4.14.1",
70
- "@byline/db-conformance": "0.0.10"
68
+ "@byline/client": "4.16.0",
69
+ "@byline/db-conformance": "0.0.10",
70
+ "@byline/auth": "4.16.0"
71
71
  },
72
72
  "publishConfig": {
73
73
  "access": "public",