@iterativeflow/mysql 2.0.0-alpha.10

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