@iterativeflow/mysql 2.0.0-alpha.2

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.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @iterativeflow/mysql
2
+
3
+ A MySQL backend for [iterativeflow](https://github.com/ahmedrowaihi/iterativeflow) — the four ports
4
+ (Store / Queue / Timer / Wakeup) over one InnoDB database. State and dispatch share the database, so
5
+ the transactional outbox commits in one `BEGIN…COMMIT`, and `claim` uses `FOR UPDATE SKIP LOCKED` for
6
+ contention-free batch dispatch. Passes the same conformance suites as the Postgres, SQLite, DynamoDB,
7
+ and Redis backends. Requires MySQL 8+ (for `SKIP LOCKED`).
8
+
9
+ ```ts
10
+ import { createPool } from "mysql2/promise";
11
+ import { createMysqlBackend, applySchema, mysqlPool } from "@iterativeflow/mysql";
12
+ import { createEngine } from "@iterativeflow/core";
13
+
14
+ const sql = mysqlPool(createPool(process.env.MYSQL_URL));
15
+ await applySchema(sql);
16
+ const engine = createEngine(createMysqlBackend(sql), [
17
+ /* your flows */
18
+ ]);
19
+ ```
20
+
21
+ ## Notes
22
+
23
+ - **`mysql2` is a peer dependency.**
24
+ - **Wakeup is in-process** (like SQLite/DynamoDB/Redis).
25
+ - **No `RETURNING`**: first-writer-wins reads `affectedRows` (`INSERT IGNORE`), and `claim` does
26
+ `SELECT … FOR UPDATE SKIP LOCKED` then `UPDATE` — the semantics are identical to the Postgres
27
+ backend, just expressed without `RETURNING`.
28
+ - Timestamps are BIGINT epoch ms; JSON columns are LONGTEXT. Run `applySchema(sql, prefix?)` once.
@@ -0,0 +1,51 @@
1
+ import { Backend, IdGen } from "@iterativeflow/core/backend";
2
+ import { Pool } from "mysql2/promise";
3
+ //#region src/sql.d.ts
4
+ /** What a write reports — MySQL has no `RETURNING`, so first-writer-wins reads `affectedRows`. */
5
+ interface WriteResult {
6
+ affectedRows: number;
7
+ insertId: number;
8
+ }
9
+ /**
10
+ * The minimal SQL surface the backend needs: positional-`?` `query` for reads, `exec` for writes
11
+ * (returning `affectedRows`, since MySQL has no `RETURNING`), and a `tx` that runs a unit of work on
12
+ * one connection in a transaction. Abstracting it keeps the backend driver-agnostic and runs every
13
+ * outbox side-effect on the SAME connection. Tables are InnoDB, so `SELECT … FOR UPDATE SKIP LOCKED`
14
+ * gives contention-free batch claims.
15
+ */
16
+ interface Sql {
17
+ query<R = Record<string, unknown>>(text: string, params?: readonly unknown[]): Promise<R[]>;
18
+ exec(text: string, params?: readonly unknown[]): Promise<WriteResult>;
19
+ tx<T>(fn: (t: Sql) => Promise<T>): Promise<T>;
20
+ }
21
+ /** Adapt a `mysql2/promise` {@link Pool} to {@link Sql}. `tx` checks out one connection for the unit. */
22
+ declare const mysqlPool: (pool: Pool) => Sql;
23
+ //#endregion
24
+ //#region src/backend.d.ts
25
+ interface MysqlBackendOpts {
26
+ /** Table-name prefix, for running multiple engines in one database. Default none. */
27
+ prefix?: string;
28
+ /** Id generator for runs, signals, and lease tokens. Defaults to {@link newId} (RFC-4122 v4). */
29
+ id?: IdGen;
30
+ }
31
+ /**
32
+ * The MySQL {@link Backend}: the four ports over one {@link Sql} connection (an InnoDB database).
33
+ * Store/Queue/Timer share the database, so an outbox commits in one transaction; `claim` uses
34
+ * `FOR UPDATE SKIP LOCKED`. Wakeup is in-process. Run {@link applySchema} once before use.
35
+ */
36
+ declare const createMysqlBackend: (sql: Sql, opts?: MysqlBackendOpts) => Backend;
37
+ //#endregion
38
+ //#region src/schema.d.ts
39
+ /**
40
+ * DDL for the MySQL backend (InnoDB). `run` carries durable state; `step` is the exactly-once memo
41
+ * (PK `(run_id, cursor_key)`); `job` is the lease queue; `timer` the durable-deadline set. Timestamps
42
+ * are BIGINT epoch ms; JSON columns are LONGTEXT; `seq` is AUTO_INCREMENT (insertion order, since
43
+ * MySQL has no implicit rowid). Indexed string columns are `VARCHAR(191)` to stay under the utf8mb4
44
+ * index-key limit. The idempotency/signal-dedup UNIQUE KEYs need no partial `WHERE`: MySQL treats
45
+ * NULLs as distinct, so unkeyed rows never collide.
46
+ */
47
+ declare const ddl: (prefix?: string) => string[];
48
+ /** Apply the schema DDL (idempotent). Run once before use. */
49
+ declare const applySchema: (sql: Sql, prefix?: string) => Promise<void>;
50
+ //#endregion
51
+ export { type MysqlBackendOpts, type Sql, applySchema, createMysqlBackend, ddl, mysqlPool };
package/dist/index.mjs ADDED
@@ -0,0 +1,616 @@
1
+ import { NON_SUCCESS_TERMINAL_STATUSES, RECONCILABLE_STATUSES, TERMINAL_STATUSES, assertSqlIdentifier, createLocalWakeup, newId, orphanedRunsSql, queueDepthOf, statusList, zeroRunStats } from "@iterativeflow/core/backend";
2
+ //#region src/queue.ts
3
+ /** @internal */
4
+ const createMysqlQueue = (sql, t, id) => {
5
+ const ms = (d) => (d ?? /* @__PURE__ */ new Date()).getTime();
6
+ return {
7
+ async enqueue(runId, opts) {
8
+ await sql.exec(`INSERT INTO ${t.job} (run_id, run_at, priority, version) VALUES (?, ?, ?, 1)
9
+ ON DUPLICATE KEY UPDATE run_at = VALUES(run_at), priority = VALUES(priority), version = version + 1`, [
10
+ runId,
11
+ opts?.runAt ? opts.runAt.getTime() : 0,
12
+ opts?.priority ?? 0
13
+ ]);
14
+ },
15
+ async claim({ limit, leaseMs, now }) {
16
+ const at = ms(now);
17
+ const expiresAt = new Date(at + leaseMs);
18
+ return sql.tx(async (tx) => {
19
+ const due = await tx.query(`SELECT run_id, version FROM ${t.job}
20
+ WHERE run_at <= ? AND (lease_expires IS NULL OR lease_expires <= ?)
21
+ ORDER BY priority, run_at LIMIT ? FOR UPDATE SKIP LOCKED`, [
22
+ at,
23
+ at,
24
+ limit
25
+ ]);
26
+ const leases = [];
27
+ for (const row of due) {
28
+ const token = `${id()}:${row.run_id}`;
29
+ await tx.exec(`UPDATE ${t.job} SET lease_token = ?, lease_expires = ? WHERE run_id = ?`, [
30
+ token,
31
+ at + leaseMs,
32
+ row.run_id
33
+ ]);
34
+ leases.push({
35
+ runId: row.run_id,
36
+ token,
37
+ expiresAt,
38
+ version: Number(row.version)
39
+ });
40
+ }
41
+ return leases;
42
+ });
43
+ },
44
+ async heartbeat(lease, { leaseMs, now }) {
45
+ const at = ms(now);
46
+ const { affectedRows } = await sql.exec(`UPDATE ${t.job} SET lease_expires = ?
47
+ WHERE run_id = ? AND lease_token = ? AND lease_expires > ?`, [
48
+ at + leaseMs,
49
+ lease.runId,
50
+ lease.token,
51
+ at
52
+ ]);
53
+ if (affectedRows === 0) throw new Error(`heartbeat: lease for ${lease.runId} is no longer held`);
54
+ return {
55
+ ...lease,
56
+ expiresAt: new Date(at + leaseMs)
57
+ };
58
+ },
59
+ async ack(lease, opts) {
60
+ const params = [
61
+ lease.runId,
62
+ lease.token,
63
+ ms(opts?.now),
64
+ lease.version
65
+ ];
66
+ await sql.exec(`DELETE FROM ${t.job}
67
+ WHERE run_id = ? AND lease_token = ? AND lease_expires > ? AND version = ?`, params);
68
+ await sql.exec(`UPDATE ${t.job} SET lease_token = NULL, lease_expires = NULL, run_at = 0
69
+ WHERE run_id = ? AND lease_token = ? AND lease_expires > ? AND version <> ?`, params);
70
+ },
71
+ async depth(now) {
72
+ return queueDepthOf((await sql.query(`SELECT run_at, lease_expires FROM ${t.job}`)).map((r) => ({
73
+ runAt: Number(r.run_at),
74
+ leaseExpires: r.lease_expires == null ? void 0 : Number(r.lease_expires)
75
+ })), now.getTime());
76
+ }
77
+ };
78
+ };
79
+ //#endregion
80
+ //#region src/schema.ts
81
+ /** @internal */
82
+ const tables = (prefix) => {
83
+ assertSqlIdentifier(prefix);
84
+ return {
85
+ run: `\`${prefix}run\``,
86
+ step: `\`${prefix}step\``,
87
+ job: `\`${prefix}job\``,
88
+ timer: `\`${prefix}timer\``,
89
+ signal: `\`${prefix}signal\``,
90
+ cron: `\`${prefix}cron\``
91
+ };
92
+ };
93
+ /**
94
+ * DDL for the MySQL backend (InnoDB). `run` carries durable state; `step` is the exactly-once memo
95
+ * (PK `(run_id, cursor_key)`); `job` is the lease queue; `timer` the durable-deadline set. Timestamps
96
+ * are BIGINT epoch ms; JSON columns are LONGTEXT; `seq` is AUTO_INCREMENT (insertion order, since
97
+ * MySQL has no implicit rowid). Indexed string columns are `VARCHAR(191)` to stay under the utf8mb4
98
+ * index-key limit. The idempotency/signal-dedup UNIQUE KEYs need no partial `WHERE`: MySQL treats
99
+ * NULLs as distinct, so unkeyed rows never collide.
100
+ */
101
+ const ddl = (prefix = "") => {
102
+ const t = tables(prefix);
103
+ return [
104
+ `CREATE TABLE IF NOT EXISTS ${t.run} (
105
+ id VARCHAR(191) PRIMARY KEY,
106
+ seq BIGINT AUTO_INCREMENT UNIQUE,
107
+ name VARCHAR(191) NOT NULL,
108
+ version INT NOT NULL,
109
+ status VARCHAR(32) NOT NULL,
110
+ input LONGTEXT,
111
+ output LONGTEXT,
112
+ error LONGTEXT,
113
+ attempts INT NOT NULL DEFAULT 0,
114
+ idempotency_key VARCHAR(191),
115
+ tags LONGTEXT,
116
+ parent_run_id VARCHAR(191),
117
+ parent_cursor_key VARCHAR(191),
118
+ depth INT NOT NULL DEFAULT 0,
119
+ join_remaining INT NOT NULL DEFAULT 0,
120
+ created_at BIGINT NOT NULL,
121
+ UNIQUE KEY run_idem (name, version, idempotency_key),
122
+ KEY run_created (created_at),
123
+ KEY run_parent (parent_run_id),
124
+ KEY run_status (status)
125
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
126
+ `CREATE TABLE IF NOT EXISTS ${t.step} (
127
+ run_id VARCHAR(191) NOT NULL,
128
+ cursor_key VARCHAR(191) NOT NULL,
129
+ status VARCHAR(32) NOT NULL,
130
+ result LONGTEXT,
131
+ error LONGTEXT,
132
+ attempts INT NOT NULL,
133
+ shape VARCHAR(191),
134
+ PRIMARY KEY (run_id, cursor_key)
135
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
136
+ `CREATE TABLE IF NOT EXISTS ${t.job} (
137
+ run_id VARCHAR(191) PRIMARY KEY,
138
+ run_at BIGINT NOT NULL,
139
+ priority INT NOT NULL DEFAULT 0,
140
+ version BIGINT NOT NULL DEFAULT 0,
141
+ lease_token VARCHAR(191),
142
+ lease_expires BIGINT,
143
+ KEY job_claimable (priority, run_at)
144
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
145
+ `CREATE TABLE IF NOT EXISTS ${t.timer} (
146
+ run_id VARCHAR(191) PRIMARY KEY,
147
+ fire_at BIGINT NOT NULL,
148
+ KEY timer_due (fire_at)
149
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
150
+ `CREATE TABLE IF NOT EXISTS ${t.signal} (
151
+ id VARCHAR(191) PRIMARY KEY,
152
+ seq BIGINT AUTO_INCREMENT UNIQUE,
153
+ run_id VARCHAR(191) NOT NULL,
154
+ name VARCHAR(191) NOT NULL,
155
+ payload LONGTEXT,
156
+ idem_key VARCHAR(191),
157
+ KEY signal_inbox (run_id, seq),
158
+ UNIQUE KEY signal_idem (run_id, idem_key)
159
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
160
+ `CREATE TABLE IF NOT EXISTS ${t.cron} (
161
+ name VARCHAR(191) PRIMARY KEY,
162
+ schedule VARCHAR(255) NOT NULL,
163
+ flow_name VARCHAR(191) NOT NULL,
164
+ flow_version INT NOT NULL,
165
+ input LONGTEXT,
166
+ overlap VARCHAR(16) NOT NULL DEFAULT 'allow',
167
+ next_run_at BIGINT NOT NULL,
168
+ last_run_at BIGINT,
169
+ KEY cron_due (next_run_at)
170
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
171
+ ];
172
+ };
173
+ /** Apply the schema DDL (idempotent). Run once before use. */
174
+ const applySchema = async (sql, prefix = "") => {
175
+ for (const stmt of ddl(prefix)) await sql.query(stmt);
176
+ };
177
+ //#endregion
178
+ //#region src/codec.ts
179
+ /** @internal */
180
+ const j = (value) => value === void 0 ? null : JSON.stringify(value);
181
+ const p = (text) => text === null ? void 0 : JSON.parse(text);
182
+ const orUndef = (v) => v === null ? void 0 : v;
183
+ const at = (v) => new Date(Number(v));
184
+ const mapRun = (r) => ({
185
+ id: r.id,
186
+ name: r.name,
187
+ version: r.version,
188
+ status: r.status,
189
+ input: p(r.input),
190
+ attempts: r.attempts,
191
+ output: p(r.output),
192
+ error: p(r.error),
193
+ idempotencyKey: orUndef(r.idempotency_key),
194
+ tags: p(r.tags),
195
+ parentRunId: orUndef(r.parent_run_id),
196
+ parentCursorKey: orUndef(r.parent_cursor_key),
197
+ depth: r.depth,
198
+ createdAt: at(r.created_at)
199
+ });
200
+ const mapStep = (r) => ({
201
+ status: r.status,
202
+ result: p(r.result),
203
+ error: p(r.error),
204
+ attempts: r.attempts,
205
+ shape: orUndef(r.shape ?? null)
206
+ });
207
+ const mapSignal = (r) => ({
208
+ id: r.id,
209
+ name: r.name,
210
+ payload: p(r.payload)
211
+ });
212
+ const mapCron = (r) => ({
213
+ name: r.name,
214
+ schedule: r.schedule,
215
+ flowName: r.flow_name,
216
+ flowVersion: r.flow_version,
217
+ input: p(r.input),
218
+ overlap: r.overlap,
219
+ nextRunAt: at(r.next_run_at),
220
+ lastRunAt: r.last_run_at === null ? void 0 : at(r.last_run_at)
221
+ });
222
+ //#endregion
223
+ //#region src/statements.ts
224
+ const inList$1 = (n) => `(${Array.from({ length: n }, () => "?").join(",")})`;
225
+ /** @internal */
226
+ const enqueueStmt = (sql, t, runId, opts) => sql.exec(`INSERT INTO ${t.job} (run_id, run_at, priority, version) VALUES (?, ?, ?, 1)
227
+ ON DUPLICATE KEY UPDATE run_at = VALUES(run_at), priority = VALUES(priority), version = version + 1`, [
228
+ runId,
229
+ opts?.runAt ? opts.runAt.getTime() : 0,
230
+ opts?.priority ?? 0
231
+ ]);
232
+ /** @internal */
233
+ const scheduleStmt = (sql, t, runId, fireAt) => sql.exec(`INSERT INTO ${t.timer} (run_id, fire_at) VALUES (?, ?)
234
+ ON DUPLICATE KEY UPDATE fire_at = VALUES(fire_at)`, [runId, fireAt.getTime()]);
235
+ /** @internal */
236
+ const applyOutbox = async (sql, t, fx) => {
237
+ for (const s of fx.spawn ?? []) {
238
+ await sql.exec(`INSERT IGNORE INTO ${t.run}
239
+ (id, name, version, status, input, idempotency_key, tags, parent_run_id, parent_cursor_key, depth, created_at)
240
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`, [
241
+ s.runId,
242
+ s.spec.name,
243
+ s.spec.version,
244
+ j(s.spec.input),
245
+ s.spec.idempotencyKey ?? null,
246
+ j(s.spec.tags),
247
+ s.spec.parentRunId ?? null,
248
+ s.spec.parentCursorKey ?? null,
249
+ s.spec.depth ?? 0,
250
+ (s.spec.createdAt ?? /* @__PURE__ */ new Date()).getTime()
251
+ ]);
252
+ await enqueueStmt(sql, t, s.runId, s.enqueue);
253
+ }
254
+ for (const e of fx.enqueue ?? []) await enqueueStmt(sql, t, e.runId, e.opts);
255
+ for (const tm of fx.timers ?? []) await scheduleStmt(sql, t, tm.runId, tm.fireAt);
256
+ if (fx.cancelTimers?.length) await sql.exec(`DELETE FROM ${t.timer} WHERE run_id IN ${inList$1(fx.cancelTimers.length)}`, fx.cancelTimers);
257
+ if (fx.consumeSignals?.length) await sql.exec(`DELETE FROM ${t.signal} WHERE id IN ${inList$1(fx.consumeSignals.length)}`, fx.consumeSignals);
258
+ if (fx.joinTarget) await sql.exec(`UPDATE ${t.run} SET join_remaining = ? WHERE id = ?`, [fx.joinTarget.count, fx.joinTarget.runId]);
259
+ };
260
+ //#endregion
261
+ //#region src/store.ts
262
+ const sqlTuple = (statuses) => `(${statuses.map((s) => `'${s}'`).join(",")})`;
263
+ const TERMINAL = sqlTuple(TERMINAL_STATUSES);
264
+ const RECONCILABLE = sqlTuple(RECONCILABLE_STATUSES);
265
+ const NON_SUCCESS_TERMINAL = sqlTuple(NON_SUCCESS_TERMINAL_STATUSES);
266
+ const inList = (n) => `(${Array.from({ length: n }, () => "?").join(",")})`;
267
+ /** @internal */
268
+ const createMysqlStore = (sql, t, id) => {
269
+ const loadStep = async (exec, runId, cursorKey) => {
270
+ const row = (await exec.query(`SELECT status, result, error, attempts, shape FROM ${t.step} WHERE run_id = ? AND cursor_key = ?`, [runId, cursorKey]))[0];
271
+ if (!row) throw new Error(`checkpointStep: step ${runId}/${cursorKey} vanished after write`);
272
+ return mapStep(row);
273
+ };
274
+ const startOne = async (exec, spec) => {
275
+ const runId = id();
276
+ if (spec.idempotencyKey) {
277
+ if ((await exec.exec(`INSERT IGNORE INTO ${t.run}
278
+ (id, name, version, status, input, idempotency_key, tags, parent_run_id, parent_cursor_key, depth, created_at)
279
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`, [
280
+ runId,
281
+ spec.name,
282
+ spec.version,
283
+ j(spec.input),
284
+ spec.idempotencyKey,
285
+ j(spec.tags),
286
+ spec.parentRunId ?? null,
287
+ spec.parentCursorKey ?? null,
288
+ spec.depth ?? 0,
289
+ (spec.createdAt ?? /* @__PURE__ */ new Date()).getTime()
290
+ ])).affectedRows === 1) return {
291
+ runId,
292
+ created: true,
293
+ status: "pending"
294
+ };
295
+ const hit = await exec.query(`SELECT id, status FROM ${t.run} WHERE name = ? AND version = ? AND idempotency_key = ?`, [
296
+ spec.name,
297
+ spec.version,
298
+ spec.idempotencyKey
299
+ ]);
300
+ return {
301
+ runId: hit[0].id,
302
+ created: false,
303
+ status: hit[0].status
304
+ };
305
+ }
306
+ await exec.exec(`INSERT INTO ${t.run}
307
+ (id, name, version, status, input, tags, parent_run_id, parent_cursor_key, depth, created_at)
308
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`, [
309
+ runId,
310
+ spec.name,
311
+ spec.version,
312
+ j(spec.input),
313
+ j(spec.tags),
314
+ spec.parentRunId ?? null,
315
+ spec.parentCursorKey ?? null,
316
+ spec.depth ?? 0,
317
+ (spec.createdAt ?? /* @__PURE__ */ new Date()).getTime()
318
+ ]);
319
+ return {
320
+ runId,
321
+ created: true,
322
+ status: "pending"
323
+ };
324
+ };
325
+ return {
326
+ startRun(spec) {
327
+ return startOne(sql, spec);
328
+ },
329
+ startManyRuns(specs) {
330
+ return sql.tx(async (tx) => {
331
+ const out = [];
332
+ for (const s of specs) out.push(await startOne(tx, s));
333
+ return out;
334
+ });
335
+ },
336
+ async loadRun(runId) {
337
+ const [runRows, stepRows, sigRows] = await Promise.all([
338
+ sql.query(`SELECT * FROM ${t.run} WHERE id = ?`, [runId]),
339
+ sql.query(`SELECT cursor_key, status, result, error, attempts, shape FROM ${t.step} WHERE run_id = ?`, [runId]),
340
+ sql.query(`SELECT id, name, payload FROM ${t.signal} WHERE run_id = ? ORDER BY seq`, [runId])
341
+ ]);
342
+ const runRow = runRows[0];
343
+ if (!runRow) return void 0;
344
+ return {
345
+ run: mapRun(runRow),
346
+ steps: new Map(stepRows.map((r) => [r.cursor_key, mapStep(r)])),
347
+ signals: sigRows.map(mapSignal)
348
+ };
349
+ },
350
+ async loadRunRow(runId) {
351
+ const rows = await sql.query(`SELECT * FROM ${t.run} WHERE id = ?`, [runId]);
352
+ return rows[0] ? mapRun(rows[0]) : void 0;
353
+ },
354
+ async loadRunRows(runIds) {
355
+ if (runIds.length === 0) return [];
356
+ const rows = await sql.query(`SELECT * FROM ${t.run} WHERE id IN ${inList(runIds.length)}`, runIds);
357
+ const byId = new Map(rows.map((r) => [r.id, mapRun(r)]));
358
+ return runIds.map((runId) => byId.get(runId));
359
+ },
360
+ arriveAtJoin(parentRunId) {
361
+ return sql.tx(async (tx) => {
362
+ if ((await tx.exec(`UPDATE ${t.run} SET join_remaining = join_remaining - 1 WHERE id = ?`, [parentRunId])).affectedRows === 0) return void 0;
363
+ const rows = await tx.query(`SELECT join_remaining FROM ${t.run} WHERE id = ?`, [parentRunId]);
364
+ return rows[0] ? Number(rows[0].join_remaining) : void 0;
365
+ });
366
+ },
367
+ async postSignal(runId, name, payload, opts) {
368
+ return sql.tx(async (tx) => {
369
+ if ((await tx.exec(`INSERT IGNORE INTO ${t.signal} (id, run_id, name, payload, idem_key) VALUES (?, ?, ?, ?, ?)`, [
370
+ id(),
371
+ runId,
372
+ name,
373
+ j(payload),
374
+ opts?.idempotencyKey ?? null
375
+ ])).affectedRows === 0) return { delivered: false };
376
+ await enqueueStmt(tx, t, runId);
377
+ return { delivered: true };
378
+ });
379
+ },
380
+ markRunning(runId) {
381
+ return sql.tx(async (tx) => {
382
+ const cur = await tx.query(`SELECT status, attempts FROM ${t.run} WHERE id = ? FOR UPDATE`, [runId]);
383
+ if (!cur[0]) throw new Error(`markRunning: run ${runId} not found`);
384
+ const attempts = Number(cur[0].attempts);
385
+ if (TERMINAL_STATUSES.includes(cur[0].status)) return attempts;
386
+ await tx.exec(`UPDATE ${t.run} SET status = 'running', attempts = attempts + 1 WHERE id = ?`, [runId]);
387
+ return attempts + 1;
388
+ });
389
+ },
390
+ checkpointStep(c, fx) {
391
+ return sql.tx(async (tx) => {
392
+ if (!(await tx.query(`SELECT 1 AS ok FROM ${t.run} WHERE id = ?`, [c.runId]))[0]) throw new Error(`checkpointStep: run ${c.runId} not found`);
393
+ if ((await tx.exec(`INSERT IGNORE INTO ${t.step} (run_id, cursor_key, status, result, error, attempts, shape)
394
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
395
+ c.runId,
396
+ c.cursorKey,
397
+ c.status,
398
+ j(c.result),
399
+ j(c.error),
400
+ c.attempts,
401
+ c.shape ?? null
402
+ ])).affectedRows === 1 && fx) await applyOutbox(tx, t, fx);
403
+ return loadStep(tx, c.runId, c.cursorKey);
404
+ });
405
+ },
406
+ suspendRun(runId, status, fx) {
407
+ return sql.tx(async (tx) => {
408
+ const reset = status !== "retrying" ? ", attempts = 0" : "";
409
+ if ((await tx.exec(`UPDATE ${t.run} SET status = ?${reset} WHERE id = ? AND status NOT IN ${TERMINAL}`, [status, runId])).affectedRows > 0 && fx) await applyOutbox(tx, t, fx);
410
+ });
411
+ },
412
+ markTerminal(runId, outcome, fx) {
413
+ return sql.tx(async (tx) => {
414
+ const output = outcome.status === "done" ? outcome.output : void 0;
415
+ const error = outcome.status === "done" ? void 0 : outcome.error;
416
+ if ((await tx.exec(`UPDATE ${t.run} SET status = ?, output = ?, error = ?
417
+ WHERE id = ? AND status <> 'canceled'`, [
418
+ outcome.status,
419
+ j(output),
420
+ j(error),
421
+ runId
422
+ ])).affectedRows > 0 && fx) await applyOutbox(tx, t, fx);
423
+ });
424
+ },
425
+ async listRuns(filter, page) {
426
+ const statuses = statusList(filter.status);
427
+ const where = [];
428
+ const params = [];
429
+ if (statuses) {
430
+ where.push(`status IN ${inList(statuses.length)}`);
431
+ params.push(...statuses);
432
+ }
433
+ if (filter.name) {
434
+ where.push(`name = ?`);
435
+ params.push(filter.name);
436
+ }
437
+ if (filter.tag) {
438
+ where.push(`JSON_CONTAINS(tags, JSON_QUOTE(?))`);
439
+ params.push(filter.tag);
440
+ }
441
+ if (page.cursor) {
442
+ where.push(`seq < ?`);
443
+ params.push(Number(page.cursor));
444
+ }
445
+ params.push(page.limit);
446
+ const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
447
+ const rows = await sql.query(`SELECT * FROM ${t.run} ${clause} ORDER BY seq DESC LIMIT ?`, params);
448
+ const last = rows[rows.length - 1];
449
+ const cursor = rows.length === page.limit && last ? String(last.seq) : void 0;
450
+ return {
451
+ runs: rows.map((r) => mapRun(r)),
452
+ cursor
453
+ };
454
+ },
455
+ async childrenOf(runId) {
456
+ return (await sql.query(`SELECT * FROM ${t.run} WHERE parent_run_id = ?`, [runId])).map((r) => mapRun(r));
457
+ },
458
+ async runStats() {
459
+ const rows = await sql.query(`SELECT status, count(*) AS n FROM ${t.run} GROUP BY status`);
460
+ const stats = zeroRunStats();
461
+ for (const r of rows) stats[r.status] = Number(r.n);
462
+ return stats;
463
+ },
464
+ async orphanedRuns(limit) {
465
+ return (await sql.query(orphanedRunsSql({
466
+ run: t.run,
467
+ job: t.job,
468
+ timer: t.timer,
469
+ reconcilable: RECONCILABLE,
470
+ terminal: TERMINAL,
471
+ nonSuccessTerminal: NON_SUCCESS_TERMINAL,
472
+ order: "r.seq",
473
+ limit: "?"
474
+ }), [limit])).map((r) => r.id);
475
+ },
476
+ deleteRunsOlderThan(before, limit) {
477
+ return sql.tx(async (tx) => {
478
+ const ids = (await tx.query(`SELECT id FROM ${t.run}
479
+ WHERE status IN ${TERMINAL} AND created_at < ?
480
+ ORDER BY seq LIMIT ?`, [before.getTime(), limit])).map((r) => r.id);
481
+ if (ids.length === 0) return 0;
482
+ const inIds = inList(ids.length);
483
+ await tx.exec(`DELETE FROM ${t.step} WHERE run_id IN ${inIds}`, ids);
484
+ await tx.exec(`DELETE FROM ${t.signal} WHERE run_id IN ${inIds}`, ids);
485
+ await tx.exec(`DELETE FROM ${t.job} WHERE run_id IN ${inIds}`, ids);
486
+ await tx.exec(`DELETE FROM ${t.timer} WHERE run_id IN ${inIds}`, ids);
487
+ await tx.exec(`DELETE FROM ${t.run} WHERE id IN ${inIds}`, ids);
488
+ return ids.length;
489
+ });
490
+ },
491
+ retryRun(runId) {
492
+ return sql.tx(async (tx) => {
493
+ if ((await tx.exec(`UPDATE ${t.run} SET status = 'pending', error = NULL WHERE id = ? AND status = 'failed'`, [runId])).affectedRows !== 1) return { retried: false };
494
+ await enqueueStmt(tx, t, runId);
495
+ return { retried: true };
496
+ });
497
+ },
498
+ async upsertCron(spec) {
499
+ await sql.exec(`INSERT INTO ${t.cron}
500
+ (name, schedule, flow_name, flow_version, input, overlap, next_run_at)
501
+ VALUES (?, ?, ?, ?, ?, ?, ?)
502
+ ON DUPLICATE KEY UPDATE
503
+ schedule = VALUES(schedule),
504
+ flow_name = VALUES(flow_name),
505
+ flow_version = VALUES(flow_version),
506
+ input = VALUES(input),
507
+ overlap = VALUES(overlap)`, [
508
+ spec.name,
509
+ spec.schedule,
510
+ spec.flowName,
511
+ spec.flowVersion,
512
+ j(spec.input),
513
+ spec.overlap ?? "allow",
514
+ spec.nextRunAt.getTime()
515
+ ]);
516
+ },
517
+ async dueCrons(now, limit) {
518
+ return (await sql.query(`SELECT * FROM ${t.cron} WHERE next_run_at <= ? ORDER BY next_run_at LIMIT ?`, [now.getTime(), limit])).map(mapCron);
519
+ },
520
+ async advanceCron(name, expectedNextRunAt, nextRunAt, lastRunAt) {
521
+ return (await sql.exec(`UPDATE ${t.cron} SET next_run_at = ?, last_run_at = ?
522
+ WHERE name = ? AND next_run_at = ?`, [
523
+ nextRunAt.getTime(),
524
+ lastRunAt.getTime(),
525
+ name,
526
+ expectedNextRunAt.getTime()
527
+ ])).affectedRows === 1;
528
+ }
529
+ };
530
+ };
531
+ //#endregion
532
+ //#region src/timer.ts
533
+ /** @internal */
534
+ const createMysqlTimer = (sql, t) => {
535
+ return {
536
+ async schedule(runId, fireAt) {
537
+ await sql.exec(`INSERT INTO ${t.timer} (run_id, fire_at) VALUES (?, ?)
538
+ ON DUPLICATE KEY UPDATE fire_at = VALUES(fire_at)`, [runId, fireAt.getTime()]);
539
+ },
540
+ async dueBatch({ now, limit }) {
541
+ const at = (now ?? /* @__PURE__ */ new Date()).getTime();
542
+ return sql.tx(async (tx) => {
543
+ const due = await tx.query(`SELECT run_id FROM ${t.timer} WHERE fire_at <= ? ORDER BY fire_at LIMIT ? FOR UPDATE SKIP LOCKED`, [at, limit]);
544
+ if (!due.length) return [];
545
+ const ids = due.map((r) => r.run_id);
546
+ const holes = ids.map(() => "?").join(", ");
547
+ await tx.exec(`DELETE FROM ${t.timer} WHERE run_id IN (${holes})`, ids);
548
+ return ids;
549
+ });
550
+ },
551
+ async cancel(runId) {
552
+ await sql.exec(`DELETE FROM ${t.timer} WHERE run_id = ?`, [runId]);
553
+ }
554
+ };
555
+ };
556
+ //#endregion
557
+ //#region src/backend.ts
558
+ /**
559
+ * The MySQL {@link Backend}: the four ports over one {@link Sql} connection (an InnoDB database).
560
+ * Store/Queue/Timer share the database, so an outbox commits in one transaction; `claim` uses
561
+ * `FOR UPDATE SKIP LOCKED`. Wakeup is in-process. Run {@link applySchema} once before use.
562
+ */
563
+ const createMysqlBackend = (sql, opts = {}) => {
564
+ const t = tables(opts.prefix ?? "");
565
+ const id = opts.id ?? newId;
566
+ return {
567
+ store: createMysqlStore(sql, t, id),
568
+ queue: createMysqlQueue(sql, t, id),
569
+ timer: createMysqlTimer(sql, t),
570
+ wakeup: createLocalWakeup()
571
+ };
572
+ };
573
+ //#endregion
574
+ //#region src/sql.ts
575
+ const write = (r) => {
576
+ const h = r[0];
577
+ return {
578
+ affectedRows: h.affectedRows,
579
+ insertId: h.insertId
580
+ };
581
+ };
582
+ const bind = (q) => ({
583
+ query: (text, params) => q.query(text, params).then((r) => r[0]),
584
+ exec: (text, params) => q.query(text, params).then(write)
585
+ });
586
+ const onConn = (c) => ({
587
+ ...bind(c),
588
+ tx: (fn) => fn(onConn(c))
589
+ });
590
+ /** Adapt a `mysql2/promise` {@link Pool} to {@link Sql}. `tx` checks out one connection for the unit. */
591
+ const mysqlPool = (pool) => {
592
+ const configured = /* @__PURE__ */ new WeakSet();
593
+ return {
594
+ ...bind(pool),
595
+ async tx(fn) {
596
+ const c = await pool.getConnection();
597
+ try {
598
+ if (!configured.has(c)) {
599
+ await c.query("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");
600
+ configured.add(c);
601
+ }
602
+ await c.beginTransaction();
603
+ const out = await fn(onConn(c));
604
+ await c.commit();
605
+ return out;
606
+ } catch (e) {
607
+ await c.rollback().catch(() => void 0);
608
+ throw e;
609
+ } finally {
610
+ c.release();
611
+ }
612
+ }
613
+ };
614
+ };
615
+ //#endregion
616
+ export { applySchema, createMysqlBackend, ddl, mysqlPool };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@iterativeflow/mysql",
3
+ "version": "2.0.0-alpha.2",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ahmedrowaihi/iterativeflow.git",
8
+ "directory": "v2/packages/mysql"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "type": "module",
15
+ "imports": {
16
+ "#*": "./src/*.ts"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.mts",
21
+ "default": "./dist/index.mjs"
22
+ }
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown"
29
+ },
30
+ "dependencies": {
31
+ "@iterativeflow/core": "2.0.0-alpha.2"
32
+ },
33
+ "peerDependencies": {
34
+ "mysql2": ">=3.0.0"
35
+ }
36
+ }