@byline/db-mysql 4.14.0 → 4.15.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.
@@ -418,3 +418,48 @@ describe('schema pins — document-paths case-sensitive collation (project-owner
418
418
  expect(pathColumn?.getSQLType()).toBe('varchar(255) COLLATE utf8mb4_bin');
419
419
  });
420
420
  });
421
+ describe('schema pins — scheduled publication', () => {
422
+ const cfg = getTableConfig(coreSchema.documentPublishSchedules);
423
+ it('pins one row per document and exactly the three lifecycle ownership foreign keys', () => {
424
+ const documentId = cfg.columns.find((column) => column.name === 'document_id');
425
+ expect(documentId?.primary).toBe(true);
426
+ const references = cfg.foreignKeys.map((foreignKey) => ({
427
+ name: foreignKey.getName(),
428
+ local: foreignKey.reference().columns.map((column) => column.name),
429
+ onDelete: foreignKey.onDelete,
430
+ }));
431
+ expect(references).toEqual([
432
+ {
433
+ name: 'fk_publish_schedules_document',
434
+ local: ['document_id'],
435
+ onDelete: 'cascade',
436
+ },
437
+ {
438
+ name: 'fk_publish_schedules_collection',
439
+ local: ['collection_id'],
440
+ onDelete: 'cascade',
441
+ },
442
+ {
443
+ name: 'fk_publish_schedules_target_version',
444
+ local: ['target_version_id'],
445
+ onDelete: 'cascade',
446
+ },
447
+ ]);
448
+ });
449
+ it('pins the bounded state and suspension-reason constraints', () => {
450
+ expect(cfg.checks.map((constraint) => constraint.name).sort()).toEqual([
451
+ 'check_publish_schedules_state',
452
+ 'check_publish_schedules_suspended_reason',
453
+ ]);
454
+ });
455
+ it('pins state as the MySQL due-index discriminator and execution-expiry recovery', () => {
456
+ const due = cfg.indexes.find((candidate) => candidate.config.name === 'idx_document_publish_schedules_due');
457
+ expect(due?.config.columns.map((column) => ('name' in column ? column.name : null))).toEqual([
458
+ 'state',
459
+ 'next_attempt_at',
460
+ 'publish_at',
461
+ ]);
462
+ const expiry = cfg.indexes.find((candidate) => candidate.config.name === 'idx_document_publish_schedules_execution_expiry');
463
+ expect(expiry?.config.columns.map((column) => ('name' in column ? column.name : null))).toEqual(['execution_expires_at']);
464
+ });
465
+ });
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { DBManagerImpl, TXManagerImpl } from './lib/db-manager.js';
13
13
  import { createAuditCommands } from './modules/audit/audit-commands.js';
14
14
  import { createAuditQueries } from './modules/audit/audit-queries.js';
15
15
  import { createCounterCommands } from './modules/counters/counters-commands.js';
16
+ import { createSchedulerStore } from './modules/scheduler/scheduler-store.js';
16
17
  import { classifyError } from './modules/storage/classify-error.js';
17
18
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
18
19
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
@@ -85,6 +86,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
85
86
  const counterCommands = createCounterCommands(pool);
86
87
  const auditCommands = createAuditCommands(dbManager);
87
88
  const auditQueries = createAuditQueries(db);
89
+ const schedulerStore = createSchedulerStore(db);
88
90
  // Boot check: run lazily on the pool's first physical connection rather
89
91
  // than eagerly here, because `mysqlAdapter` is synchronous (mirroring
90
92
  // `pgAdapter`) and cannot await a round trip before returning. mysql2
@@ -144,6 +146,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
144
146
  audit: auditQueries,
145
147
  },
146
148
  withTransaction: (fn) => txManager.withTransaction(fn),
149
+ scheduler: schedulerStore,
147
150
  classifyError,
148
151
  drizzle: db,
149
152
  pool,
@@ -0,0 +1,13 @@
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 { ISchedulerStore } from '@byline/core';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ type DatabaseConnection = MySql2Database<typeof schema>;
12
+ export declare function createSchedulerStore(db: DatabaseConnection): ISchedulerStore;
13
+ export {};
@@ -0,0 +1,237 @@
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
+ /**
9
+ * MySQL implementation of `ISchedulerStore` (specs/2026-08-22-scheduler.md).
10
+ * Every due and lease comparison uses database UTC time, and every write after
11
+ * a claim is fenced by the claim's token.
12
+ *
13
+ * MySQL has no `UPDATE ... RETURNING`, so `claim()` locks the eligible row,
14
+ * updates it, and returns the pre-claim schedule from one READ COMMITTED
15
+ * transaction. The transaction is deliberately store-owned rather than ambient:
16
+ * scheduler claims must not inherit a caller's longer transaction boundary.
17
+ */
18
+ import { randomUUID } from 'node:crypto';
19
+ import { MAX_BACKOFF_MS } from '@byline/core/scheduler';
20
+ import { sql } from 'drizzle-orm';
21
+ import { affectedRowCount, toDate } from '../storage/storage-utils.js';
22
+ /** Doc comment on `ISchedulerStore.fail`: 2048 characters, never a stack trace. */
23
+ const LAST_ERROR_MAX_LENGTH = 2048;
24
+ /** Doc comment on `ISchedulerStore.health`: `lease_owner` capped at 255 characters. */
25
+ const LEASE_OWNER_MAX_LENGTH = 255;
26
+ const MAX_BACKOFF_SECONDS = MAX_BACKOFF_MS / 1000;
27
+ function requiredDate(value, context) {
28
+ const date = toDate(value, context);
29
+ if (date === null) {
30
+ throw new Error(`scheduler store: ${context} unexpectedly returned null`);
31
+ }
32
+ return date;
33
+ }
34
+ class SchedulerStore {
35
+ db;
36
+ constructor(db) {
37
+ this.db = db;
38
+ }
39
+ /**
40
+ * One conflict-tolerant upsert per definition. `interval_ms` always follows
41
+ * code, while an unleased `next_run_at` is only pulled earlier when the new
42
+ * interval would otherwise leave the task waiting too long.
43
+ */
44
+ async reconcile(tasks) {
45
+ for (const task of tasks) {
46
+ await this.db.execute(sql `
47
+ INSERT INTO byline_recurring_tasks (name, interval_ms, next_run_at, last_status)
48
+ VALUES (
49
+ ${task.name},
50
+ ${task.intervalMs},
51
+ TIMESTAMPADD(MICROSECOND, ${task.intervalMs} * 1000, CURRENT_TIMESTAMP(6)),
52
+ 'never_run'
53
+ )
54
+ ON DUPLICATE KEY UPDATE
55
+ next_run_at = CASE
56
+ WHEN lease_token IS NULL
57
+ AND next_run_at > TIMESTAMPADD(
58
+ MICROSECOND,
59
+ ${task.intervalMs} * 1000,
60
+ CURRENT_TIMESTAMP(6)
61
+ )
62
+ THEN TIMESTAMPADD(MICROSECOND, ${task.intervalMs} * 1000, CURRENT_TIMESTAMP(6))
63
+ ELSE next_run_at
64
+ END,
65
+ interval_ms = ${task.intervalMs},
66
+ updated_at = CURRENT_TIMESTAMP(6)
67
+ `);
68
+ }
69
+ }
70
+ /**
71
+ * Atomic claim through a locking read and fenced update on one physical
72
+ * connection. Under READ COMMITTED, contenders blocked on `FOR UPDATE`
73
+ * re-check the eligibility predicate after the winner commits and return
74
+ * null instead of observing the stale due row.
75
+ */
76
+ async claim(params) {
77
+ return this.db.transaction(async (tx) => {
78
+ const selected = (await tx.execute(sql `
79
+ SELECT
80
+ name,
81
+ next_run_at AS scheduled_for,
82
+ CURRENT_TIMESTAMP(6) AS database_now,
83
+ (lease_expires_at IS NOT NULL) AS recovered_expired_lease
84
+ FROM byline_recurring_tasks
85
+ WHERE name = ${params.name}
86
+ AND next_run_at <= CURRENT_TIMESTAMP(6)
87
+ AND (lease_expires_at IS NULL OR lease_expires_at <= CURRENT_TIMESTAMP(6))
88
+ FOR UPDATE
89
+ `));
90
+ const row = selected[0][0];
91
+ if (row === undefined)
92
+ return null;
93
+ const leaseToken = randomUUID();
94
+ const result = await tx.execute(sql `
95
+ UPDATE byline_recurring_tasks SET
96
+ lease_token = ${leaseToken},
97
+ lease_owner = ${params.owner.slice(0, LEASE_OWNER_MAX_LENGTH)},
98
+ lease_expires_at = TIMESTAMPADD(
99
+ MICROSECOND,
100
+ ${params.leaseMs} * 1000,
101
+ CURRENT_TIMESTAMP(6)
102
+ ),
103
+ last_started_at = CURRENT_TIMESTAMP(6),
104
+ last_status = 'running',
105
+ updated_at = CURRENT_TIMESTAMP(6)
106
+ WHERE name = ${params.name}
107
+ `);
108
+ if (affectedRowCount(result) !== 1) {
109
+ throw new Error(`scheduler store: locked claim row '${params.name}' disappeared`);
110
+ }
111
+ return {
112
+ name: row.name,
113
+ leaseToken,
114
+ scheduledFor: requiredDate(row.scheduled_for, 'scheduled_for'),
115
+ databaseNow: requiredDate(row.database_now, 'database_now'),
116
+ recoveredExpiredLease: Boolean(row.recovered_expired_lease),
117
+ };
118
+ }, { isolationLevel: 'read committed' });
119
+ }
120
+ /**
121
+ * Do not add an expiry predicate: expiry makes a row claimable but does not
122
+ * invalidate the current token until another claimant replaces it.
123
+ */
124
+ async renew(params) {
125
+ const result = await this.db.execute(sql `
126
+ UPDATE byline_recurring_tasks SET
127
+ lease_expires_at = TIMESTAMPADD(
128
+ MICROSECOND,
129
+ ${params.leaseMs} * 1000,
130
+ CURRENT_TIMESTAMP(6)
131
+ ),
132
+ updated_at = CURRENT_TIMESTAMP(6)
133
+ WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
134
+ `);
135
+ return affectedRowCount(result) === 1;
136
+ }
137
+ /**
138
+ * Complete a token-matched run and schedule from the persisted cadence.
139
+ * `interval_ms` is read but not assigned here, so assignment order is
140
+ * currently irrelevant. If a future change adds it to this SET list, keep
141
+ * that assignment after `next_run_at` so the persisted pre-update cadence is
142
+ * retained; see `fail()` for MySQL's left-to-right assignment rule.
143
+ */
144
+ async complete(params) {
145
+ const nextRunAt = params.workRemaining
146
+ ? sql `CURRENT_TIMESTAMP(6)`
147
+ : sql `TIMESTAMPADD(MICROSECOND, interval_ms * 1000, CURRENT_TIMESTAMP(6))`;
148
+ const result = await this.db.execute(sql `
149
+ UPDATE byline_recurring_tasks SET
150
+ last_succeeded_at = CURRENT_TIMESTAMP(6),
151
+ last_status = 'succeeded',
152
+ last_duration_ms = ${params.durationMs},
153
+ consecutive_failures = 0,
154
+ last_error = NULL,
155
+ lease_token = NULL,
156
+ lease_owner = NULL,
157
+ lease_expires_at = NULL,
158
+ next_run_at = ${nextRunAt},
159
+ updated_at = CURRENT_TIMESTAMP(6)
160
+ WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
161
+ `);
162
+ return affectedRowCount(result) === 1;
163
+ }
164
+ /**
165
+ * Record a token-matched failure with 1, 2, 4, 8, then 15 minute backoff.
166
+ * MySQL evaluates single-table UPDATE assignments from left to right, so
167
+ * `next_run_at` intentionally reads the old `consecutive_failures` before
168
+ * the following assignment increments it.
169
+ */
170
+ async fail(params) {
171
+ const result = await this.db.execute(sql `
172
+ UPDATE byline_recurring_tasks SET
173
+ last_failed_at = CURRENT_TIMESTAMP(6),
174
+ last_status = 'failed',
175
+ last_duration_ms = ${params.durationMs},
176
+ last_error = ${params.error.slice(0, LAST_ERROR_MAX_LENGTH)},
177
+ lease_token = NULL,
178
+ lease_owner = NULL,
179
+ lease_expires_at = NULL,
180
+ next_run_at = TIMESTAMPADD(
181
+ SECOND,
182
+ LEAST(
183
+ 60 * CAST(POWER(2, LEAST(consecutive_failures, 4)) AS UNSIGNED),
184
+ ${MAX_BACKOFF_SECONDS}
185
+ ),
186
+ CURRENT_TIMESTAMP(6)
187
+ ),
188
+ consecutive_failures = consecutive_failures + 1,
189
+ updated_at = CURRENT_TIMESTAMP(6)
190
+ WHERE name = ${params.name} AND lease_token = ${params.leaseToken}
191
+ `);
192
+ return affectedRowCount(result) === 1;
193
+ }
194
+ /** Return selected health rows, or every row when names is omitted. */
195
+ async health(names) {
196
+ if (names?.length === 0)
197
+ return [];
198
+ const whereClause = names === undefined
199
+ ? sql ``
200
+ : sql `WHERE name IN (${sql.join(names.map((name) => sql `${name}`), sql `, `)})`;
201
+ const result = (await this.db.execute(sql `
202
+ SELECT
203
+ name,
204
+ interval_ms,
205
+ next_run_at,
206
+ last_status,
207
+ last_started_at,
208
+ last_succeeded_at,
209
+ last_failed_at,
210
+ last_duration_ms,
211
+ consecutive_failures,
212
+ last_error,
213
+ (lease_expires_at IS NOT NULL AND lease_expires_at <= CURRENT_TIMESTAMP(6)) AS lease_expired,
214
+ CURRENT_TIMESTAMP(6) AS database_now
215
+ FROM byline_recurring_tasks
216
+ ${whereClause}
217
+ ORDER BY name
218
+ `));
219
+ return result[0].map((row) => ({
220
+ name: row.name,
221
+ intervalMs: Number(row.interval_ms),
222
+ nextRunAt: requiredDate(row.next_run_at, 'next_run_at'),
223
+ lastStatus: row.last_status,
224
+ lastStartedAt: toDate(row.last_started_at, 'last_started_at'),
225
+ lastSucceededAt: toDate(row.last_succeeded_at, 'last_succeeded_at'),
226
+ lastFailedAt: toDate(row.last_failed_at, 'last_failed_at'),
227
+ lastDurationMs: row.last_duration_ms === null ? null : Number(row.last_duration_ms),
228
+ consecutiveFailures: Number(row.consecutive_failures),
229
+ lastError: row.last_error,
230
+ leaseExpired: Boolean(row.lease_expired),
231
+ databaseNow: requiredDate(row.database_now, 'database_now'),
232
+ }));
233
+ }
234
+ }
235
+ export function createSchedulerStore(db) {
236
+ return new SchedulerStore(db);
237
+ }
@@ -0,0 +1,85 @@
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 { ClaimedDocumentPublishSchedule, ConfirmDocumentPublishScheduleResult, DocumentPublishSchedule, DocumentPublishSchedulePage, DocumentPublishScheduleState, IDocumentPublishScheduleCommands, IDocumentPublishScheduleQueries, ScheduleDocumentPublishResult, SuspendDocumentPublishScheduleResult } from '@byline/core';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ import type { DBManager } from '../../lib/db-manager.js';
12
+ type DatabaseConnection = MySql2Database<typeof schema>;
13
+ /** MySQL scheduled-publication writes on the ambient document transaction. */
14
+ export declare class DocumentPublishScheduleCommands implements IDocumentPublishScheduleCommands {
15
+ private dbManager;
16
+ constructor(dbManager: DBManager);
17
+ private get db();
18
+ private lockCurrentVersion;
19
+ private lockSchedule;
20
+ private readSchedule;
21
+ schedule(params: {
22
+ documentId: string;
23
+ collectionId: string;
24
+ expectedVersionId: string;
25
+ publishAt: Date;
26
+ actorId: string | null;
27
+ }): Promise<ScheduleDocumentPublishResult>;
28
+ confirm(params: {
29
+ documentId: string;
30
+ collectionId: string;
31
+ expectedVersionId: string;
32
+ actorId: string | null;
33
+ }): Promise<ConfirmDocumentPublishScheduleResult>;
34
+ cancel(params: {
35
+ documentId: string;
36
+ collectionId: string;
37
+ }): Promise<DocumentPublishSchedule | null>;
38
+ suspendForContentEdit(params: {
39
+ documentId: string;
40
+ collectionId: string;
41
+ }): Promise<SuspendDocumentPublishScheduleResult>;
42
+ claimDue(params: {
43
+ batchSize: number;
44
+ leaseMs: number;
45
+ }): Promise<ClaimedDocumentPublishSchedule[]>;
46
+ lockClaim(params: {
47
+ documentId: string;
48
+ executionToken: string;
49
+ }): Promise<DocumentPublishSchedule | null>;
50
+ deleteClaim(params: {
51
+ documentId: string;
52
+ executionToken: string;
53
+ }): Promise<boolean>;
54
+ suspendClaimForContentEdit(params: {
55
+ documentId: string;
56
+ executionToken: string;
57
+ }): Promise<boolean>;
58
+ /**
59
+ * `attempt_count` is read but not assigned, so MySQL's left-to-right SET
60
+ * evaluation cannot shift the backoff sequence. If it is ever assigned in
61
+ * this statement, keep `next_attempt_at` before that assignment.
62
+ */
63
+ releaseClaim(params: {
64
+ documentId: string;
65
+ executionToken: string;
66
+ error: string;
67
+ }): Promise<boolean>;
68
+ }
69
+ /** MySQL scheduled-publication reads. */
70
+ export declare class DocumentPublishScheduleQueries implements IDocumentPublishScheduleQueries {
71
+ private db;
72
+ constructor(db: DatabaseConnection);
73
+ get(params: {
74
+ documentId: string;
75
+ collectionId: string;
76
+ }): Promise<DocumentPublishSchedule | null>;
77
+ list(params: {
78
+ collectionIds: readonly string[];
79
+ states?: readonly DocumentPublishScheduleState[];
80
+ lastAuthorizedBy?: string;
81
+ page: number;
82
+ pageSize: number;
83
+ }): Promise<DocumentPublishSchedulePage>;
84
+ }
85
+ export {};