@dudousxd/nestjs-durable-transport-db 0.2.0 → 0.2.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 CHANGED
@@ -1,11 +1,11 @@
1
1
  # @dudousxd/nestjs-durable-transport-db
2
2
 
3
3
  A **database-backed `Transport`** for [nestjs-durable](https://github.com/DavideCarvalho/nestjs-durable) —
4
- DBOS-style. Remote steps (`ctx.call`) travel as **rows in the database you already use for durable
4
+ DBOS-style. Dispatched steps (`ctx.step`) travel as **rows in the database you already use for durable
5
5
  state**, not through a broker. Dispatch inserts a task row; a worker claims it with
6
6
  `SELECT … FOR UPDATE SKIP LOCKED`, runs it, and writes a result row the engine polls.
7
7
 
8
- **Why:** it's the "`ctx.call` with zero new infrastructure" option. No Redis, no SQS, no new queue —
8
+ **Why:** it's the "`ctx.step` with zero new infrastructure" option. No Redis, no SQS, no new queue —
9
9
  the DB is the queue. The right fit when you can't (or won't) add a broker.
10
10
 
11
11
  **Trade-off:** throughput is bounded by polling + row contention. Excellent for workflow/pipeline
@@ -63,7 +63,7 @@ import { MyStateStore } from './store';
63
63
  export class AppModule {}
64
64
  ```
65
65
 
66
- Workers register handlers with the `@DurableStep('name')` decorator (from `@dudousxd/nestjs-durable`)
66
+ Workers register handlers with the `@Step('name')` decorator (from `@dudousxd/nestjs-durable`)
67
67
  — the registrar wires them onto the transport's `group`. On shutdown, call `transport.close()` to
68
68
  stop the pollers (wire it to `OnModuleDestroy`).
69
69
 
package/dist/index.cjs CHANGED
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  module.exports = __toCommonJS(index_exports);
29
29
 
30
30
  // src/db-transport.ts
31
+ var import_node_crypto = require("crypto");
31
32
  var import_nestjs_durable_core = require("@dudousxd/nestjs-durable-core");
32
33
 
33
34
  // src/executors.ts
@@ -70,6 +71,10 @@ function mikroOrmExecutor(em) {
70
71
  __name(mikroOrmExecutor, "mikroOrmExecutor");
71
72
 
72
73
  // src/db-transport.ts
74
+ function numberOption(value, fallback, min) {
75
+ return value !== void 0 && Number.isFinite(value) && value >= min ? value : fallback;
76
+ }
77
+ __name(numberOption, "numberOption");
73
78
  var DbTransport = class {
74
79
  static {
75
80
  __name(this, "DbTransport");
@@ -81,6 +86,8 @@ var DbTransport = class {
81
86
  pollMs;
82
87
  leaseMs;
83
88
  batchSize;
89
+ concurrency;
90
+ onError;
84
91
  autoCreate;
85
92
  instanceId;
86
93
  isPg;
@@ -89,17 +96,25 @@ var DbTransport = class {
89
96
  schemaReady;
90
97
  taskLoop;
91
98
  resultLoop;
99
+ /** Tasks running on this instance (at most `concurrency`). */
100
+ runningTasks = /* @__PURE__ */ new Set();
101
+ /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */
102
+ held = /* @__PURE__ */ new Map();
103
+ renewTimer;
104
+ renewal;
92
105
  constructor(options) {
93
106
  this.exec = options.executor;
94
107
  this.group = options.group;
95
108
  const prefix = options.prefix ?? "durable";
96
109
  this.tasksTable = `${prefix}_transport_tasks`;
97
110
  this.resultsTable = `${prefix}_transport_results`;
98
- this.pollMs = options.pollMs ?? 500;
99
- this.leaseMs = options.leaseMs ?? 3e4;
100
- this.batchSize = options.batchSize ?? 10;
111
+ this.pollMs = numberOption(options.pollMs, 500, 0);
112
+ this.leaseMs = numberOption(options.leaseMs, 3e4, 1);
113
+ this.batchSize = Math.floor(numberOption(options.batchSize, 10, 1));
114
+ this.concurrency = Math.floor(numberOption(options.concurrency, 1, 1));
115
+ this.onError = options.onError ?? ((err) => console.error("[DbTransport]", err));
101
116
  this.autoCreate = options.autoCreate ?? true;
102
- this.instanceId = options.instanceId ?? `db-${Math.floor(Date.now()).toString(36)}`;
117
+ this.instanceId = options.instanceId ?? `db-${(0, import_node_crypto.randomUUID)()}`;
103
118
  this.isPg = this.exec.dialect === "postgres";
104
119
  }
105
120
  // ── dialect helpers ──────────────────────────────────────────────────────────────────────────
@@ -115,16 +130,33 @@ var DbTransport = class {
115
130
  }
116
131
  // ── schema ───────────────────────────────────────────────────────────────────────────────────
117
132
  ensureSchema() {
118
- if (!this.schemaReady) this.schemaReady = this.createTables();
133
+ if (!this.schemaReady) {
134
+ this.schemaReady = this.createTables().catch((err) => {
135
+ this.schemaReady = void 0;
136
+ throw err;
137
+ });
138
+ }
119
139
  return this.schemaReady;
120
140
  }
141
+ /**
142
+ * Run one `CREATE … IF NOT EXISTS`. Two instances booting on a fresh database race it, and Postgres
143
+ * can reject the loser with a unique violation on its catalog even with `IF NOT EXISTS`; the
144
+ * statement is simply re-run, by which point the table exists and it is a no-op.
145
+ */
146
+ async createIfMissing(sql) {
147
+ try {
148
+ await this.exec.query(sql);
149
+ } catch {
150
+ await this.exec.query(sql);
151
+ }
152
+ }
121
153
  async createTables() {
122
154
  if (!this.autoCreate) return;
123
155
  const str = "varchar(191)";
124
156
  const txt = this.isPg ? "text" : "longtext";
125
157
  const tasks = this.q(this.tasksTable);
126
158
  const results = this.q(this.resultsTable);
127
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${tasks} (
159
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${tasks} (
128
160
  ${this.q("step_id")} ${str} PRIMARY KEY,
129
161
  ${this.q("run_id")} ${str} NOT NULL,
130
162
  ${this.q("seq")} integer NOT NULL,
@@ -137,7 +169,7 @@ var DbTransport = class {
137
169
  ${this.q("claimed_at")} bigint,
138
170
  ${this.q("created_at")} bigint NOT NULL
139
171
  )`);
140
- await this.exec.query(`CREATE TABLE IF NOT EXISTS ${results} (
172
+ await this.createIfMissing(`CREATE TABLE IF NOT EXISTS ${results} (
141
173
  ${this.q("step_id")} ${str} PRIMARY KEY,
142
174
  ${this.q("run_id")} ${str} NOT NULL,
143
175
  ${this.q("seq")} integer NOT NULL,
@@ -195,23 +227,56 @@ var DbTransport = class {
195
227
  // ── polling ──────────────────────────────────────────────────────────────────────────────────
196
228
  /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */
197
229
  async loop(tick) {
198
- await this.ensureSchema();
199
230
  while (this.running) {
200
231
  let processed = 0;
201
232
  try {
233
+ await this.ensureSchema();
202
234
  processed = await tick();
203
235
  } catch (err) {
204
- if (this.running) console.error("[DbTransport] poll failed", err);
236
+ if (this.running) this.onError(err);
205
237
  }
206
- if (processed === 0) await this.sleep(this.pollMs);
238
+ if (processed === 0 && this.running) await this.sleep(this.pollMs);
207
239
  }
208
240
  }
209
- /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */
241
+ /**
242
+ * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that
243
+ * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for
244
+ * one to free instead of polling.
245
+ */
210
246
  async drainTasks(group) {
211
- const claimed = await this.claim(this.tasksTable, `${this.q("grp")} = ${this.ph(1)}`, [
247
+ const free = this.concurrency - this.runningTasks.size;
248
+ if (free <= 0) {
249
+ await Promise.race(this.runningTasks);
250
+ return 1;
251
+ }
252
+ const { rows, token } = await this.claim(this.tasksTable, Math.min(free, this.batchSize), `${this.q("grp")} = ${this.ph(1)}`, [
212
253
  group
213
254
  ]);
214
- for (const row of claimed) {
255
+ if (rows.length === 0) return 0;
256
+ this.hold(this.tasksTable, token);
257
+ let left = rows.length;
258
+ for (const row of rows) {
259
+ const run = this.runTask(row, group, token).finally(() => {
260
+ this.runningTasks.delete(run);
261
+ left -= 1;
262
+ if (left === 0) this.release(this.tasksTable, token);
263
+ });
264
+ this.runningTasks.add(run);
265
+ }
266
+ return rows.length;
267
+ }
268
+ /**
269
+ * Run one claimed task and settle it — only while this instance still holds the claim: the claim is
270
+ * re-checked before running, and the task row is deleted and its result written in ONE transaction,
271
+ * conditional on the claim token, so an instance whose claim was taken over neither deletes the new
272
+ * holder's row nor publishes a second result.
273
+ */
274
+ async runTask(row, group, token) {
275
+ try {
276
+ if (!await this.stillHolds(row.step_id, token)) {
277
+ this.onError(new Error(`[DbTransport] lost the claim on task ${row.step_id} before running it \u2014 another instance took it over; not running it here`));
278
+ return;
279
+ }
215
280
  const task = {
216
281
  runId: row.run_id,
217
282
  seq: Number(row.seq),
@@ -222,62 +287,169 @@ var DbTransport = class {
222
287
  attempt: Number(row.attempt)
223
288
  };
224
289
  const result = await (0, import_nestjs_durable_core.runStepHandler)(task, this.handlers.get(task.name));
225
- await this.insertResult(result);
226
- await this.exec.query(`DELETE FROM ${this.q(this.tasksTable)} WHERE ${this.q("step_id")} = ${this.ph(1)}`, [
227
- task.stepId
228
- ]);
290
+ if (!await this.settleTask(task.stepId, token, result)) {
291
+ this.onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} while running it (this instance went silent past leaseMs) \u2014 another instance took it over and may run it too; this run's result was dropped. Raise leaseMs if steps block the event loop`));
292
+ }
293
+ } catch (err) {
294
+ this.onError(err);
229
295
  }
230
- return claimed.length;
231
296
  }
232
- /** Claim a batch of results, deliver each to the engine, then delete it. */
233
- async drainResults(handler) {
234
- const claimed = await this.claim(this.resultsTable, "1 = 1", []);
235
- for (const row of claimed) {
236
- const result = {
237
- runId: row.run_id,
238
- seq: Number(row.seq),
239
- stepId: row.step_id,
240
- status: row.status,
241
- output: row.output == null ? void 0 : JSON.parse(row.output),
242
- error: row.error == null ? void 0 : JSON.parse(row.error),
243
- startedAt: row.started_at == null ? void 0 : Number(row.started_at)
244
- };
245
- await handler(result);
246
- await this.exec.query(`DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q("step_id")} = ${this.ph(1)}`, [
247
- result.stepId
297
+ /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */
298
+ async stillHolds(stepId, token) {
299
+ const t = this.q(this.tasksTable);
300
+ const rows = await this.exec.transaction(async (tx) => {
301
+ const held = await tx.query(`SELECT ${this.q("step_id")} FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)} FOR UPDATE`, [
302
+ stepId,
303
+ token
304
+ ]);
305
+ if (held.length > 0) {
306
+ await tx.query(`UPDATE ${t} SET ${this.q("claimed_at")} = ${this.ph(1)} WHERE ${this.q("step_id")} = ${this.ph(2)} AND ${this.q("claimed_by")} = ${this.ph(3)}`, [
307
+ this.now(),
308
+ stepId,
309
+ token
310
+ ]);
311
+ }
312
+ return held;
313
+ });
314
+ return rows.length > 0;
315
+ }
316
+ /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */
317
+ async settleTask(stepId, token, result) {
318
+ const t = this.q(this.tasksTable);
319
+ return this.exec.transaction(async (tx) => {
320
+ const held = await tx.query(`SELECT ${this.q("step_id")} FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)} FOR UPDATE`, [
321
+ stepId,
322
+ token
248
323
  ]);
324
+ if (held.length === 0) return false;
325
+ await tx.query(`DELETE FROM ${t} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)}`, [
326
+ stepId,
327
+ token
328
+ ]);
329
+ await this.insertResult(result, tx);
330
+ return true;
331
+ });
332
+ }
333
+ /**
334
+ * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran
335
+ * (at-least-once), and only while this round still holds the claim (a newer result for the same
336
+ * step that replaced the row resets the claim, so it is delivered next instead of deleted). The
337
+ * batch's claim is renewed until it is done. A throwing handler is reported and its row left for
338
+ * re-delivery after the lease; the rest of the batch continues.
339
+ */
340
+ async drainResults(handler) {
341
+ const { rows, token } = await this.claim(this.resultsTable, this.batchSize, "1 = 1", []);
342
+ if (rows.length === 0) return 0;
343
+ this.hold(this.resultsTable, token);
344
+ try {
345
+ for (const row of rows) {
346
+ if (!this.running) break;
347
+ try {
348
+ const result = {
349
+ runId: row.run_id,
350
+ seq: Number(row.seq),
351
+ stepId: row.step_id,
352
+ status: row.status,
353
+ output: row.output == null ? void 0 : JSON.parse(row.output),
354
+ error: row.error == null ? void 0 : JSON.parse(row.error),
355
+ startedAt: row.started_at == null ? void 0 : Number(row.started_at)
356
+ };
357
+ await handler(result);
358
+ await this.exec.query(`DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q("step_id")} = ${this.ph(1)} AND ${this.q("claimed_by")} = ${this.ph(2)}`, [
359
+ row.step_id,
360
+ token
361
+ ]);
362
+ } catch (err) {
363
+ this.onError(err);
364
+ }
365
+ }
366
+ } finally {
367
+ this.release(this.resultsTable, token);
249
368
  }
250
- return claimed.length;
369
+ return rows.length;
251
370
  }
252
371
  /**
253
- * Atomically claim up to `batchSize` un-leased rows matching `where` and stamp them with this
254
- * instance's lease. `SELECT … FOR UPDATE SKIP LOCKED` inside the txn means concurrent instances
255
- * skip each other's locked rows instead of blocking, so no row is claimed twice.
372
+ * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token
373
+ * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn
374
+ * means concurrent instances skip each other's locked rows instead of blocking, so no row is
375
+ * claimed twice; the token is what every later delete/settle is conditional on.
256
376
  */
257
- async claim(table, where, whereParams) {
377
+ async claim(table, limit, where, whereParams) {
258
378
  const t = this.q(table);
259
379
  const staleBefore = this.now() - this.leaseMs;
260
- return this.exec.transaction(async (tx) => {
380
+ const token = `${this.instanceId}:${(0, import_node_crypto.randomUUID)()}`;
381
+ const rows = await this.exec.transaction(async (tx) => {
261
382
  const leaseClause = `(${this.q("claimed_at")} IS NULL OR ${this.q("claimed_at")} < ${this.ph(whereParams.length + 1)})`;
262
- const rows = await tx.query(`SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}
263
- ORDER BY ${this.q("created_at")} ASC LIMIT ${this.batchSize} FOR UPDATE SKIP LOCKED`, [
383
+ const found = await tx.query(`SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}
384
+ ORDER BY ${this.q("created_at")} ASC LIMIT ${Math.max(1, Math.floor(limit))} FOR UPDATE SKIP LOCKED`, [
264
385
  ...whereParams,
265
386
  staleBefore
266
387
  ]);
267
- if (rows.length > 0) {
268
- const ids = rows.map((r) => r.step_id);
388
+ if (found.length > 0) {
389
+ const ids = found.map((r) => r.step_id);
269
390
  const placeholders = ids.map((_, i) => this.ph(i + 3)).join(", ");
270
391
  await tx.query(`UPDATE ${t} SET ${this.q("claimed_by")} = ${this.ph(1)}, ${this.q("claimed_at")} = ${this.ph(2)}
271
392
  WHERE ${this.q("step_id")} IN (${placeholders})`, [
272
- this.instanceId,
393
+ token,
273
394
  this.now(),
274
395
  ...ids
275
396
  ]);
276
397
  }
277
- return rows;
398
+ return found;
278
399
  });
400
+ return {
401
+ rows,
402
+ token
403
+ };
404
+ }
405
+ // ── claim renewal: every claim held, not only the row being run ─────────────────────────────
406
+ hold(table, token) {
407
+ let tokens = this.held.get(table);
408
+ if (!tokens) {
409
+ tokens = /* @__PURE__ */ new Set();
410
+ this.held.set(table, tokens);
411
+ }
412
+ tokens.add(token);
413
+ if (this.renewTimer) return;
414
+ const intervalMs = Math.max(10, Math.floor(this.leaseMs / 3));
415
+ this.renewTimer = setInterval(() => {
416
+ if (this.renewal) return;
417
+ const round = this.renewHeld().catch((err) => this.onError(err)).finally(() => {
418
+ if (this.renewal === round) this.renewal = void 0;
419
+ });
420
+ this.renewal = round;
421
+ }, intervalMs);
422
+ this.renewTimer.unref?.();
423
+ }
424
+ release(table, token) {
425
+ const tokens = this.held.get(table);
426
+ tokens?.delete(token);
427
+ if (tokens?.size === 0) this.held.delete(table);
428
+ if (this.held.size === 0 && this.renewTimer) {
429
+ clearInterval(this.renewTimer);
430
+ this.renewTimer = void 0;
431
+ }
432
+ }
433
+ /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */
434
+ async renewHeld() {
435
+ for (const [table, tokens] of this.held) {
436
+ if (tokens.size === 0) continue;
437
+ const list = [
438
+ ...tokens
439
+ ];
440
+ const placeholders = list.map((_, i) => this.ph(i + 2)).join(", ");
441
+ await this.exec.query(`UPDATE ${this.q(table)} SET ${this.q("claimed_at")} = ${this.ph(1)} WHERE ${this.q("claimed_by")} IN (${placeholders})`, [
442
+ this.now(),
443
+ ...list
444
+ ]);
445
+ }
279
446
  }
280
- async insertResult(result) {
447
+ /**
448
+ * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet
449
+ * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across
450
+ * attempts, so keeping the old row would silently drop a retry's result.
451
+ */
452
+ async insertResult(result, tx = this.exec) {
281
453
  const t = this.q(this.resultsTable);
282
454
  const cols = [
283
455
  "step_id",
@@ -289,9 +461,26 @@ var DbTransport = class {
289
461
  "started_at",
290
462
  "created_at"
291
463
  ];
292
- const head = this.isPg ? `INSERT INTO ${t} (...) ON CONFLICT (${this.q("step_id")}) DO NOTHING` : `INSERT IGNORE INTO ${t} (...)`;
293
- const sql = head.replace("(...)", `(${cols.map((c) => this.q(c)).join(", ")}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(", ")})`);
294
- await this.exec.query(sql, [
464
+ const replace = [
465
+ "run_id",
466
+ "seq",
467
+ "status",
468
+ "output",
469
+ "error",
470
+ "started_at",
471
+ "created_at"
472
+ ];
473
+ const values = `(${cols.map((c) => this.q(c)).join(", ")}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(", ")})`;
474
+ const sql = this.isPg ? `INSERT INTO ${t} ${values} ON CONFLICT (${this.q("step_id")}) DO UPDATE SET ${[
475
+ ...replace.map((c) => `${this.q(c)} = EXCLUDED.${this.q(c)}`),
476
+ `${this.q("claimed_by")} = NULL`,
477
+ `${this.q("claimed_at")} = NULL`
478
+ ].join(", ")}` : `INSERT INTO ${t} ${values} ON DUPLICATE KEY UPDATE ${[
479
+ ...replace.map((c) => `${this.q(c)} = VALUES(${this.q(c)})`),
480
+ `${this.q("claimed_by")} = NULL`,
481
+ `${this.q("claimed_at")} = NULL`
482
+ ].join(", ")}`;
483
+ await tx.query(sql, [
295
484
  result.stepId,
296
485
  result.runId,
297
486
  result.seq,
@@ -312,6 +501,12 @@ var DbTransport = class {
312
501
  this.taskLoop,
313
502
  this.resultLoop
314
503
  ]);
504
+ await Promise.allSettled([
505
+ ...this.runningTasks
506
+ ]);
507
+ if (this.renewTimer) clearInterval(this.renewTimer);
508
+ this.renewTimer = void 0;
509
+ await this.renewal;
315
510
  }
316
511
  };
317
512
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/db-transport.ts","../src/executors.ts"],"sourcesContent":["export * from './db-transport';\n","import {\n type Heartbeat,\n type RemoteTask,\n type StepHandler,\n type StepResult,\n type Transport,\n runStepHandler,\n} from '@dudousxd/nestjs-durable-core';\nimport type { SqlExecutor } from './executors';\n\nexport { mikroOrmExecutor, type SqlExecutor, type SqlTx, typeOrmExecutor } from './executors';\n\n/** Raw row shapes returned by `SELECT *` (declared fields, so they're not `| undefined` under noUncheckedIndexedAccess). */\ninterface TaskRow {\n step_id: string;\n run_id: string;\n seq: string | number;\n name: string;\n input: string | null;\n attempt: string | number;\n}\ninterface ResultRow {\n step_id: string;\n run_id: string;\n seq: string | number;\n status: string;\n output: string | null;\n error: string | null;\n started_at: string | number | null;\n}\n\nexport interface DbTransportOptions {\n /**\n * How to run SQL — built from the app's **own** ORM/connection (no broker, no extra connection).\n * Use `mikroOrmExecutor(em)` to ride the app's MikroORM, `typeOrmExecutor(dataSource)` for TypeORM,\n * or implement `SqlExecutor` for anything else.\n */\n executor: SqlExecutor;\n /** The worker group this instance serves. Required to register `handle()` consumers. */\n group?: string;\n /** Table-name prefix. Tables: `${prefix}_transport_tasks` / `${prefix}_transport_results`. Default `durable`. */\n prefix?: string;\n /** Poll interval in ms when a queue is empty. Default 500. */\n pollMs?: number;\n /** How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000. */\n leaseMs?: number;\n /** Max rows claimed per poll. Default 10. */\n batchSize?: number;\n /** Create the two tables on first use if missing. Default true. */\n autoCreate?: boolean;\n /** Identifies this instance in `claimed_by` (for debugging). Defaults to a random id. */\n instanceId?: string;\n}\n\n/**\n * A SQL-backed `Transport` — DBOS-style. Instead of a broker (Redis/SQS), remote steps are **rows**\n * in the same database the durable store already uses: `dispatch` inserts a task row, a worker\n * claims it with `SELECT … FOR UPDATE SKIP LOCKED` (so instances never double-claim), runs it, and\n * writes a result row the engine polls. Zero new infrastructure — the DB you already have IS the\n * queue.\n *\n * Trade-off vs a real broker: throughput is bounded by polling + row contention. Great for\n * workflow/pipeline scale (modest rate, long steps); not for high-fanout firehoses.\n *\n * Requires `FOR UPDATE SKIP LOCKED` — **MySQL 8+** or **Postgres 9.5+** (not SQLite). Run one\n * instance engine-side (`onResult`, dispatches) and one per worker process (`handle()` for its group).\n */\nexport class DbTransport implements Transport {\n private readonly exec: SqlExecutor;\n private readonly group?: string | undefined;\n private readonly tasksTable: string;\n private readonly resultsTable: string;\n private readonly pollMs: number;\n private readonly leaseMs: number;\n private readonly batchSize: number;\n private readonly autoCreate: boolean;\n private readonly instanceId: string;\n private readonly isPg: boolean;\n\n private readonly handlers = new Map<string, StepHandler>();\n private running = true;\n private schemaReady?: Promise<void>;\n private taskLoop?: Promise<void>;\n private resultLoop?: Promise<void>;\n\n constructor(options: DbTransportOptions) {\n this.exec = options.executor;\n this.group = options.group;\n const prefix = options.prefix ?? 'durable';\n this.tasksTable = `${prefix}_transport_tasks`;\n this.resultsTable = `${prefix}_transport_results`;\n this.pollMs = options.pollMs ?? 500;\n this.leaseMs = options.leaseMs ?? 30_000;\n this.batchSize = options.batchSize ?? 10;\n this.autoCreate = options.autoCreate ?? true;\n this.instanceId = options.instanceId ?? `db-${Math.floor(Date.now()).toString(36)}`;\n this.isPg = this.exec.dialect === 'postgres';\n }\n\n // ── dialect helpers ──────────────────────────────────────────────────────────────────────────\n /** Positional placeholder for param `i` (1-based): `$i` on Postgres, `?` elsewhere. */\n private ph(i: number): string {\n return this.isPg ? `$${i}` : '?';\n }\n private q(id: string): string {\n return this.exec.escapeId(id);\n }\n private now(): number {\n return Math.floor(Date.now());\n }\n\n // ── schema ───────────────────────────────────────────────────────────────────────────────────\n private ensureSchema(): Promise<void> {\n if (!this.schemaReady) this.schemaReady = this.createTables();\n return this.schemaReady;\n }\n\n private async createTables(): Promise<void> {\n if (!this.autoCreate) return;\n const str = 'varchar(191)';\n const txt = this.isPg ? 'text' : 'longtext';\n const tasks = this.q(this.tasksTable);\n const results = this.q(this.resultsTable);\n await this.exec.query(\n `CREATE TABLE IF NOT EXISTS ${tasks} (\n ${this.q('step_id')} ${str} PRIMARY KEY,\n ${this.q('run_id')} ${str} NOT NULL,\n ${this.q('seq')} integer NOT NULL,\n ${this.q('name')} ${str} NOT NULL,\n ${this.q('grp')} ${str} NOT NULL,\n ${this.q('input')} ${txt},\n ${this.q('attempt')} integer NOT NULL,\n ${this.q('status')} varchar(32) NOT NULL,\n ${this.q('claimed_by')} ${str},\n ${this.q('claimed_at')} bigint,\n ${this.q('created_at')} bigint NOT NULL\n )`,\n );\n await this.exec.query(\n `CREATE TABLE IF NOT EXISTS ${results} (\n ${this.q('step_id')} ${str} PRIMARY KEY,\n ${this.q('run_id')} ${str} NOT NULL,\n ${this.q('seq')} integer NOT NULL,\n ${this.q('status')} varchar(32) NOT NULL,\n ${this.q('output')} ${txt},\n ${this.q('error')} ${txt},\n ${this.q('started_at')} bigint,\n ${this.q('claimed_by')} ${str},\n ${this.q('claimed_at')} bigint,\n ${this.q('created_at')} bigint NOT NULL\n )`,\n );\n // Best-effort index for the group poll (MySQL has no CREATE INDEX IF NOT EXISTS < 8.0.13).\n await this.exec\n .query(\n `CREATE INDEX ${this.q(`${this.tasksTable}_grp_idx`)} ON ${tasks} (${this.q('grp')}, ${this.q('status')}, ${this.q('created_at')})`,\n )\n .catch(() => {});\n }\n\n // ── engine → worker ────────────────────────────────────────────────────────────────────────\n async dispatch(task: RemoteTask): Promise<void> {\n await this.ensureSchema();\n const t = this.q(this.tasksTable);\n // Idempotent: a redelivered dispatch for the same step_id is ignored (the row already exists).\n const ignore = this.isPg\n ? `INSERT INTO ${t} (...) ON CONFLICT (${this.q('step_id')}) DO NOTHING`\n : `INSERT IGNORE INTO ${t} (...)`;\n const cols = ['step_id', 'run_id', 'seq', 'name', 'grp', 'input', 'attempt', 'status', 'created_at'];\n const sql = ignore.replace(\n '(...)',\n `(${cols.map((c) => this.q(c)).join(', ')}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(', ')})`,\n );\n await this.exec.query(sql, [\n task.stepId,\n task.runId,\n task.seq,\n task.name,\n task.group,\n JSON.stringify(task.input ?? null),\n task.attempt,\n 'pending',\n this.now(),\n ]);\n }\n\n // ── worker → engine ────────────────────────────────────────────────────────────────────────\n handle(name: string, fn: StepHandler): void {\n if (!this.group) throw new Error('DbTransport needs a `group` to register handlers');\n this.handlers.set(name, fn);\n if (!this.taskLoop) this.taskLoop = this.loop(() => this.drainTasks(this.group as string));\n }\n\n onResult(handler: (result: StepResult) => Promise<void>): void {\n if (!this.resultLoop) this.resultLoop = this.loop(() => this.drainResults(handler));\n }\n\n onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void {\n // Not modelled; the lease/reclaim on `claimed_at` is the liveness mechanism.\n }\n\n // ── polling ──────────────────────────────────────────────────────────────────────────────────\n /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */\n private async loop(tick: () => Promise<number>): Promise<void> {\n await this.ensureSchema();\n while (this.running) {\n let processed = 0;\n try {\n processed = await tick();\n } catch (err) {\n if (this.running) console.error('[DbTransport] poll failed', err);\n }\n if (processed === 0) await this.sleep(this.pollMs);\n }\n }\n\n /** Claim a batch of tasks (FOR UPDATE SKIP LOCKED), then run each outside the lock. */\n private async drainTasks(group: string): Promise<number> {\n const claimed = await this.claim<TaskRow>(\n this.tasksTable,\n `${this.q('grp')} = ${this.ph(1)}`,\n [group],\n );\n for (const row of claimed) {\n const task: RemoteTask = {\n runId: row.run_id,\n seq: Number(row.seq),\n stepId: row.step_id,\n name: row.name,\n group,\n input: row.input == null ? undefined : JSON.parse(row.input),\n attempt: Number(row.attempt),\n };\n const result = await runStepHandler(task, this.handlers.get(task.name));\n await this.insertResult(result);\n await this.exec.query(\n `DELETE FROM ${this.q(this.tasksTable)} WHERE ${this.q('step_id')} = ${this.ph(1)}`,\n [task.stepId],\n );\n }\n return claimed.length;\n }\n\n /** Claim a batch of results, deliver each to the engine, then delete it. */\n private async drainResults(handler: (result: StepResult) => Promise<void>): Promise<number> {\n const claimed = await this.claim<ResultRow>(this.resultsTable, '1 = 1', []);\n for (const row of claimed) {\n const result: StepResult = {\n runId: row.run_id,\n seq: Number(row.seq),\n stepId: row.step_id,\n status: row.status as StepResult['status'],\n output: row.output == null ? undefined : JSON.parse(row.output),\n error: row.error == null ? undefined : JSON.parse(row.error),\n startedAt: row.started_at == null ? undefined : Number(row.started_at),\n };\n await handler(result);\n await this.exec.query(\n `DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q('step_id')} = ${this.ph(1)}`,\n [result.stepId],\n );\n }\n return claimed.length;\n }\n\n /**\n * Atomically claim up to `batchSize` un-leased rows matching `where` and stamp them with this\n * instance's lease. `SELECT … FOR UPDATE SKIP LOCKED` inside the txn means concurrent instances\n * skip each other's locked rows instead of blocking, so no row is claimed twice.\n */\n private async claim<T extends { step_id: string }>(\n table: string,\n where: string,\n whereParams: unknown[],\n ): Promise<T[]> {\n const t = this.q(table);\n const staleBefore = this.now() - this.leaseMs;\n return this.exec.transaction(async (tx) => {\n const leaseClause = `(${this.q('claimed_at')} IS NULL OR ${this.q('claimed_at')} < ${this.ph(whereParams.length + 1)})`;\n const rows = await tx.query<T>(\n `SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}\n ORDER BY ${this.q('created_at')} ASC LIMIT ${this.batchSize} FOR UPDATE SKIP LOCKED`,\n [...whereParams, staleBefore],\n );\n if (rows.length > 0) {\n const ids = rows.map((r) => r.step_id);\n const placeholders = ids.map((_, i) => this.ph(i + 3)).join(', ');\n await tx.query(\n `UPDATE ${t} SET ${this.q('claimed_by')} = ${this.ph(1)}, ${this.q('claimed_at')} = ${this.ph(2)}\n WHERE ${this.q('step_id')} IN (${placeholders})`,\n [this.instanceId, this.now(), ...ids],\n );\n }\n return rows;\n });\n }\n\n private async insertResult(result: StepResult): Promise<void> {\n const t = this.q(this.resultsTable);\n const cols = ['step_id', 'run_id', 'seq', 'status', 'output', 'error', 'started_at', 'created_at'];\n const head = this.isPg\n ? `INSERT INTO ${t} (...) ON CONFLICT (${this.q('step_id')}) DO NOTHING`\n : `INSERT IGNORE INTO ${t} (...)`;\n const sql = head.replace(\n '(...)',\n `(${cols.map((c) => this.q(c)).join(', ')}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(', ')})`,\n );\n await this.exec.query(sql, [\n result.stepId,\n result.runId,\n result.seq,\n result.status,\n result.output === undefined ? null : JSON.stringify(result.output),\n result.error === undefined ? null : JSON.stringify(result.error),\n result.startedAt ?? null,\n this.now(),\n ]);\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n }\n\n /** Stop the pollers and wait for the current ticks to settle. Does not close the shared DataSource. */\n async close(): Promise<void> {\n this.running = false;\n await Promise.allSettled([this.taskLoop, this.resultLoop]);\n }\n}\n","/**\n * The tiny SQL surface `DbTransport` needs, so it rides **whatever ORM/connection the app already\n * has** instead of forcing a specific one. Implement it for your ORM (adapters for TypeORM and\n * MikroORM ship below) and hand it to `new DbTransport({ executor })`. Mirrors how the durable\n * *store* is ORM-pluggable (store-typeorm / store-mikro-orm / …).\n */\nexport interface SqlExecutor {\n /** Drives placeholder style (`$n` vs `?`) and a couple of DDL type choices. */\n readonly dialect: 'mysql' | 'postgres';\n /** Quote/escape an identifier (table/column name) for this dialect. */\n escapeId(id: string): string;\n /** Run a statement outside a transaction. */\n query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n /** Run `fn` inside a transaction — the claim uses `FOR UPDATE SKIP LOCKED` within it. */\n transaction<T>(fn: (tx: SqlTx) => Promise<T>): Promise<T>;\n}\n\nexport interface SqlTx {\n query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n}\n\n// ── TypeORM ──────────────────────────────────────────────────────────────────────────────────────\n\n/** Structural slice of a TypeORM `DataSource` — avoids a hard dependency on typeorm. */\ninterface TypeOrmDataSourceLike {\n options: { type: string };\n driver: { escape(id: string): string };\n query(sql: string, params?: unknown[]): Promise<unknown>;\n createQueryRunner(): {\n connect(): Promise<void>;\n startTransaction(): Promise<void>;\n commitTransaction(): Promise<void>;\n rollbackTransaction(): Promise<void>;\n release(): Promise<void>;\n query(sql: string, params?: unknown[]): Promise<unknown>;\n };\n}\n\n/** Build an executor from a TypeORM `DataSource` (e.g. the one the durable store already uses). */\nexport function typeOrmExecutor(ds: TypeOrmDataSourceLike): SqlExecutor {\n return {\n dialect: /postgres/.test(String(ds.options.type)) ? 'postgres' : 'mysql',\n escapeId: (id) => ds.driver.escape(id),\n query: <T>(sql: string, params?: unknown[]) => ds.query(sql, params) as Promise<T[]>,\n transaction: async <T>(fn: (tx: SqlTx) => Promise<T>) => {\n const qr = ds.createQueryRunner();\n await qr.connect();\n await qr.startTransaction();\n try {\n const result = await fn({\n query: <U>(sql: string, params?: unknown[]) => qr.query(sql, params) as Promise<U[]>,\n });\n await qr.commitTransaction();\n return result;\n } catch (err) {\n await qr.rollbackTransaction();\n throw err;\n } finally {\n await qr.release();\n }\n },\n };\n}\n\n// ── MikroORM ─────────────────────────────────────────────────────────────────────────────────────\n\n/** Structural slice of a MikroORM `EntityManager` — avoids a hard dependency on @mikro-orm/core. */\ninterface MikroOrmEmLike {\n getPlatform(): { constructor: { name: string }; quoteIdentifier(id: string): string };\n getConnection(): {\n execute(sql: string, params?: unknown[], method?: 'all', ctx?: unknown): Promise<unknown>;\n };\n getTransactionContext?(): unknown;\n transactional<T>(cb: (em: MikroOrmEmLike) => Promise<T>): Promise<T>;\n}\n\n/**\n * Build an executor from a MikroORM `EntityManager` — i.e. **the app's own configured ORM**. Runs\n * raw SQL through `em.getConnection().execute(...)` and wraps the claim in `em.transactional(...)`,\n * threading the transaction context so `FOR UPDATE SKIP LOCKED` locks within the tx.\n */\nexport function mikroOrmExecutor(em: MikroOrmEmLike): SqlExecutor {\n const platform = em.getPlatform();\n return {\n dialect: /postgre/i.test(platform.constructor.name) ? 'postgres' : 'mysql',\n escapeId: (id) => platform.quoteIdentifier(id),\n query: <T>(sql: string, params?: unknown[]) =>\n em.getConnection().execute(sql, params ?? [], 'all') as Promise<T[]>,\n transaction: <T>(fn: (tx: SqlTx) => Promise<T>) =>\n em.transactional((txEm) =>\n fn({\n query: <U>(sql: string, params?: unknown[]) =>\n txEm\n .getConnection()\n .execute(sql, params ?? [], 'all', txEm.getTransactionContext?.()) as Promise<U[]>,\n }),\n ),\n };\n}\n"],"mappings":"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","names":["typeOrmExecutor","ds","dialect","test","String","options","type","escapeId","id","driver","escape","query","sql","params","transaction","fn","qr","createQueryRunner","connect","startTransaction","result","commitTransaction","err","rollbackTransaction","release","mikroOrmExecutor","em","platform","getPlatform","name","quoteIdentifier","getConnection","execute","transactional","txEm","getTransactionContext","DbTransport","exec","group","tasksTable","resultsTable","pollMs","leaseMs","batchSize","autoCreate","instanceId","isPg","handlers","Map","running","schemaReady","taskLoop","resultLoop","options","executor","prefix","Math","floor","Date","now","toString","dialect","ph","i","q","id","escapeId","ensureSchema","createTables","str","txt","tasks","results","query","catch","dispatch","task","t","ignore","cols","sql","replace","map","c","join","_","stepId","runId","seq","name","JSON","stringify","input","attempt","handle","fn","Error","set","loop","drainTasks","onResult","handler","drainResults","onHeartbeat","_handler","tick","processed","err","console","error","sleep","claimed","claim","row","run_id","Number","step_id","undefined","parse","result","runStepHandler","get","insertResult","length","status","output","startedAt","started_at","table","where","whereParams","staleBefore","transaction","tx","leaseClause","rows","ids","r","placeholders","head","ms","Promise","setTimeout","close","allSettled"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/db-transport.ts","../src/executors.ts"],"sourcesContent":["export * from './db-transport';\n","import { randomUUID } from 'node:crypto';\nimport {\n type Heartbeat,\n type RemoteTask,\n type StepHandler,\n type StepResult,\n type Transport,\n runStepHandler,\n} from '@dudousxd/nestjs-durable-core';\nimport type { SqlExecutor, SqlTx } from './executors';\n\nexport { mikroOrmExecutor, type SqlExecutor, type SqlTx, typeOrmExecutor } from './executors';\n\n/** Raw row shapes returned by `SELECT *` (declared fields, so they're not `| undefined` under noUncheckedIndexedAccess). */\ninterface TaskRow {\n step_id: string;\n run_id: string;\n seq: string | number;\n name: string;\n input: string | null;\n attempt: string | number;\n}\ninterface ResultRow {\n step_id: string;\n run_id: string;\n seq: string | number;\n status: string;\n output: string | null;\n error: string | null;\n started_at: string | number | null;\n}\n\nexport interface DbTransportOptions {\n /**\n * How to run SQL — built from the app's **own** ORM/connection (no broker, no extra connection).\n * Use `mikroOrmExecutor(em)` to ride the app's MikroORM, `typeOrmExecutor(dataSource)` for TypeORM,\n * `drizzlePgExecutor(db)` (from `@dudousxd/nestjs-durable-store-drizzle/pg`) for Drizzle on\n * Postgres, or implement `SqlExecutor` for anything else.\n */\n executor: SqlExecutor;\n /** The worker group this instance serves. Required to register `handle()` consumers. */\n group?: string;\n /** Table-name prefix. Tables: `${prefix}_transport_tasks` / `${prefix}_transport_results`. Default `durable`. */\n prefix?: string;\n /** Poll interval in ms when a queue is empty. Default 500. */\n pollMs?: number;\n /**\n * How long a claimed row is owned before it's reclaimable (crash recovery), in ms. Default 30_000.\n * Every claim this instance holds is renewed every `leaseMs / 3` while it is waiting or running, so\n * a step may run longer than `leaseMs`; only a crashed (or event-loop-blocked) instance loses it.\n */\n leaseMs?: number;\n /**\n * Max rows claimed per poll. Default 10. The task loop claims only as many rows as it has free\n * {@link concurrency} slots (capped by this); the results loop claims up to this many.\n */\n batchSize?: number;\n /**\n * How many tasks this instance runs at once (worker side). Default 1. A row is claimed only when a\n * slot is free to run it, so rows never wait claimed behind a slow task while a sibling sits idle.\n */\n concurrency?: number;\n /**\n * Where a transport failure is reported: a failing poll, a throwing result handler (the result is\n * re-delivered after the lease), a lost claim. Default `console.error`.\n */\n onError?: (err: unknown) => void;\n /** Create the two tables on first use if missing. Default true. */\n autoCreate?: boolean;\n /** Identifies this instance in `claimed_by` (for debugging). Defaults to a random id. */\n instanceId?: string;\n}\n\n/**\n * A numeric option: `fallback` unless `value` is a finite number of at least `min`. `Math.max(1, NaN)`\n * is `NaN`, so a clamp alone would let a `NaN` through (to `LIMIT NaN`, or a `NaN` timer).\n */\nfunction numberOption(value: number | undefined, fallback: number, min: number): number {\n return value !== undefined && Number.isFinite(value) && value >= min ? value : fallback;\n}\n\n/**\n * A SQL-backed `Transport` — DBOS-style. Instead of a broker (Redis/SQS), remote steps are **rows**\n * in the same database the durable store already uses: `dispatch` inserts a task row, a worker\n * claims it with `SELECT … FOR UPDATE SKIP LOCKED` (so instances never double-claim), runs it, and\n * writes a result row the engine polls. Zero new infrastructure — the DB you already have IS the\n * queue.\n *\n * Trade-off vs a real broker: throughput is bounded by polling + row contention. Great for\n * workflow/pipeline scale (modest rate, long steps); not for high-fanout firehoses.\n *\n * Requires `FOR UPDATE SKIP LOCKED` — **MySQL 8+** or **Postgres 9.5+** (not SQLite). Run one\n * instance engine-side (`onResult`, dispatches) and one per worker process (`handle()` for its group).\n */\nexport class DbTransport implements Transport {\n private readonly exec: SqlExecutor;\n private readonly group?: string | undefined;\n private readonly tasksTable: string;\n private readonly resultsTable: string;\n private readonly pollMs: number;\n private readonly leaseMs: number;\n private readonly batchSize: number;\n private readonly concurrency: number;\n private readonly onError: (err: unknown) => void;\n private readonly autoCreate: boolean;\n private readonly instanceId: string;\n private readonly isPg: boolean;\n\n private readonly handlers = new Map<string, StepHandler>();\n private running = true;\n private schemaReady?: Promise<void> | undefined;\n private taskLoop?: Promise<void>;\n private resultLoop?: Promise<void>;\n /** Tasks running on this instance (at most `concurrency`). */\n private readonly runningTasks = new Set<Promise<void>>();\n /** Claim tokens this instance holds, by table — every one is renewed on `renewTimer`. */\n private readonly held = new Map<string, Set<string>>();\n private renewTimer?: ReturnType<typeof setInterval> | undefined;\n private renewal?: Promise<void> | undefined;\n\n constructor(options: DbTransportOptions) {\n this.exec = options.executor;\n this.group = options.group;\n const prefix = options.prefix ?? 'durable';\n this.tasksTable = `${prefix}_transport_tasks`;\n this.resultsTable = `${prefix}_transport_results`;\n this.pollMs = numberOption(options.pollMs, 500, 0);\n this.leaseMs = numberOption(options.leaseMs, 30_000, 1);\n this.batchSize = Math.floor(numberOption(options.batchSize, 10, 1));\n this.concurrency = Math.floor(numberOption(options.concurrency, 1, 1));\n this.onError = options.onError ?? ((err) => console.error('[DbTransport]', err));\n this.autoCreate = options.autoCreate ?? true;\n // Random, not time-based: two instances booted in the same millisecond must not share an id.\n this.instanceId = options.instanceId ?? `db-${randomUUID()}`;\n this.isPg = this.exec.dialect === 'postgres';\n }\n\n // ── dialect helpers ──────────────────────────────────────────────────────────────────────────\n /** Positional placeholder for param `i` (1-based): `$i` on Postgres, `?` elsewhere. */\n private ph(i: number): string {\n return this.isPg ? `$${i}` : '?';\n }\n private q(id: string): string {\n return this.exec.escapeId(id);\n }\n private now(): number {\n return Math.floor(Date.now());\n }\n\n // ── schema ───────────────────────────────────────────────────────────────────────────────────\n private ensureSchema(): Promise<void> {\n if (!this.schemaReady) {\n // A failed attempt is not cached: the next call tries again instead of failing forever.\n this.schemaReady = this.createTables().catch((err) => {\n this.schemaReady = undefined;\n throw err;\n });\n }\n return this.schemaReady;\n }\n\n /**\n * Run one `CREATE … IF NOT EXISTS`. Two instances booting on a fresh database race it, and Postgres\n * can reject the loser with a unique violation on its catalog even with `IF NOT EXISTS`; the\n * statement is simply re-run, by which point the table exists and it is a no-op.\n */\n private async createIfMissing(sql: string): Promise<void> {\n try {\n await this.exec.query(sql);\n } catch {\n await this.exec.query(sql);\n }\n }\n\n private async createTables(): Promise<void> {\n if (!this.autoCreate) return;\n const str = 'varchar(191)';\n const txt = this.isPg ? 'text' : 'longtext';\n const tasks = this.q(this.tasksTable);\n const results = this.q(this.resultsTable);\n await this.createIfMissing(\n `CREATE TABLE IF NOT EXISTS ${tasks} (\n ${this.q('step_id')} ${str} PRIMARY KEY,\n ${this.q('run_id')} ${str} NOT NULL,\n ${this.q('seq')} integer NOT NULL,\n ${this.q('name')} ${str} NOT NULL,\n ${this.q('grp')} ${str} NOT NULL,\n ${this.q('input')} ${txt},\n ${this.q('attempt')} integer NOT NULL,\n ${this.q('status')} varchar(32) NOT NULL,\n ${this.q('claimed_by')} ${str},\n ${this.q('claimed_at')} bigint,\n ${this.q('created_at')} bigint NOT NULL\n )`,\n );\n await this.createIfMissing(\n `CREATE TABLE IF NOT EXISTS ${results} (\n ${this.q('step_id')} ${str} PRIMARY KEY,\n ${this.q('run_id')} ${str} NOT NULL,\n ${this.q('seq')} integer NOT NULL,\n ${this.q('status')} varchar(32) NOT NULL,\n ${this.q('output')} ${txt},\n ${this.q('error')} ${txt},\n ${this.q('started_at')} bigint,\n ${this.q('claimed_by')} ${str},\n ${this.q('claimed_at')} bigint,\n ${this.q('created_at')} bigint NOT NULL\n )`,\n );\n // Best-effort index for the group poll (MySQL has no CREATE INDEX IF NOT EXISTS < 8.0.13).\n await this.exec\n .query(\n `CREATE INDEX ${this.q(`${this.tasksTable}_grp_idx`)} ON ${tasks} (${this.q('grp')}, ${this.q('status')}, ${this.q('created_at')})`,\n )\n .catch(() => {});\n }\n\n // ── engine → worker ────────────────────────────────────────────────────────────────────────\n async dispatch(task: RemoteTask): Promise<void> {\n await this.ensureSchema();\n const t = this.q(this.tasksTable);\n // Idempotent: a redelivered dispatch for the same step_id is ignored (the row already exists).\n const ignore = this.isPg\n ? `INSERT INTO ${t} (...) ON CONFLICT (${this.q('step_id')}) DO NOTHING`\n : `INSERT IGNORE INTO ${t} (...)`;\n const cols = [\n 'step_id',\n 'run_id',\n 'seq',\n 'name',\n 'grp',\n 'input',\n 'attempt',\n 'status',\n 'created_at',\n ];\n const sql = ignore.replace(\n '(...)',\n `(${cols.map((c) => this.q(c)).join(', ')}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(', ')})`,\n );\n await this.exec.query(sql, [\n task.stepId,\n task.runId,\n task.seq,\n task.name,\n task.group,\n JSON.stringify(task.input ?? null),\n task.attempt,\n 'pending',\n this.now(),\n ]);\n }\n\n // ── worker → engine ────────────────────────────────────────────────────────────────────────\n handle(name: string, fn: StepHandler): void {\n if (!this.group) throw new Error('DbTransport needs a `group` to register handlers');\n this.handlers.set(name, fn);\n if (!this.taskLoop) this.taskLoop = this.loop(() => this.drainTasks(this.group as string));\n }\n\n onResult(handler: (result: StepResult) => Promise<void>): void {\n if (!this.resultLoop) this.resultLoop = this.loop(() => this.drainResults(handler));\n }\n\n onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void {\n // Not modelled; the lease/reclaim on `claimed_at` is the liveness mechanism.\n }\n\n // ── polling ──────────────────────────────────────────────────────────────────────────────────\n /** Run `tick` repeatedly; sleep `pollMs` when it reports an empty queue. Stops on `close()`. */\n private async loop(tick: () => Promise<number>): Promise<void> {\n while (this.running) {\n let processed = 0;\n try {\n await this.ensureSchema();\n processed = await tick();\n } catch (err) {\n if (this.running) this.onError(err);\n }\n if (processed === 0 && this.running) await this.sleep(this.pollMs);\n }\n }\n\n /**\n * One task-loop round: claim as many rows as there are free slots (never more — a claimed row that\n * isn't running is one an idle sibling can't run), and start each. With every slot busy, wait for\n * one to free instead of polling.\n */\n private async drainTasks(group: string): Promise<number> {\n const free = this.concurrency - this.runningTasks.size;\n if (free <= 0) {\n await Promise.race(this.runningTasks);\n return 1;\n }\n const { rows, token } = await this.claim<TaskRow>(\n this.tasksTable,\n Math.min(free, this.batchSize),\n `${this.q('grp')} = ${this.ph(1)}`,\n [group],\n );\n if (rows.length === 0) return 0;\n this.hold(this.tasksTable, token);\n let left = rows.length;\n for (const row of rows) {\n const run = this.runTask(row, group, token).finally(() => {\n this.runningTasks.delete(run);\n left -= 1;\n if (left === 0) this.release(this.tasksTable, token);\n });\n this.runningTasks.add(run);\n }\n return rows.length;\n }\n\n /**\n * Run one claimed task and settle it — only while this instance still holds the claim: the claim is\n * re-checked before running, and the task row is deleted and its result written in ONE transaction,\n * conditional on the claim token, so an instance whose claim was taken over neither deletes the new\n * holder's row nor publishes a second result.\n */\n private async runTask(row: TaskRow, group: string, token: string): Promise<void> {\n try {\n if (!(await this.stillHolds(row.step_id, token))) {\n this.onError(\n new Error(\n `[DbTransport] lost the claim on task ${row.step_id} before running it — another instance took it over; not running it here`,\n ),\n );\n return;\n }\n const task: RemoteTask = {\n runId: row.run_id,\n seq: Number(row.seq),\n stepId: row.step_id,\n name: row.name,\n group,\n input: row.input == null ? undefined : JSON.parse(row.input),\n attempt: Number(row.attempt),\n };\n const result = await runStepHandler(task, this.handlers.get(task.name));\n if (!(await this.settleTask(task.stepId, token, result))) {\n this.onError(\n new Error(\n `[DbTransport] lost the claim on task ${task.stepId} while running it (this instance went silent past leaseMs) — another instance took it over and may run it too; this run's result was dropped. Raise leaseMs if steps block the event loop`,\n ),\n );\n }\n } catch (err) {\n // A database error settling the row (or a malformed row): it stays claimed, stops being\n // renewed, and is reclaimed after the lease — at-least-once, like a crash.\n this.onError(err);\n }\n }\n\n /** Whether the claim on `stepId` is still `token`'s (re-stamping it if so). */\n private async stillHolds(stepId: string, token: string): Promise<boolean> {\n const t = this.q(this.tasksTable);\n const rows = await this.exec.transaction(async (tx) => {\n const held = await tx.query<{ step_id: string }>(\n `SELECT ${this.q('step_id')} FROM ${t} WHERE ${this.q('step_id')} = ${this.ph(1)} AND ${this.q('claimed_by')} = ${this.ph(2)} FOR UPDATE`,\n [stepId, token],\n );\n if (held.length > 0) {\n await tx.query(\n `UPDATE ${t} SET ${this.q('claimed_at')} = ${this.ph(1)} WHERE ${this.q('step_id')} = ${this.ph(2)} AND ${this.q('claimed_by')} = ${this.ph(3)}`,\n [this.now(), stepId, token],\n );\n }\n return held;\n });\n return rows.length > 0;\n }\n\n /** Delete the task row and publish its result in ONE transaction, only while `token` holds it. */\n private async settleTask(stepId: string, token: string, result: StepResult): Promise<boolean> {\n const t = this.q(this.tasksTable);\n return this.exec.transaction(async (tx) => {\n const held = await tx.query<{ step_id: string }>(\n `SELECT ${this.q('step_id')} FROM ${t} WHERE ${this.q('step_id')} = ${this.ph(1)} AND ${this.q('claimed_by')} = ${this.ph(2)} FOR UPDATE`,\n [stepId, token],\n );\n if (held.length === 0) return false;\n await tx.query(\n `DELETE FROM ${t} WHERE ${this.q('step_id')} = ${this.ph(1)} AND ${this.q('claimed_by')} = ${this.ph(2)}`,\n [stepId, token],\n );\n await this.insertResult(result, tx);\n return true;\n });\n }\n\n /**\n * Claim a batch of results, deliver each to the engine, and delete it — only after its handler ran\n * (at-least-once), and only while this round still holds the claim (a newer result for the same\n * step that replaced the row resets the claim, so it is delivered next instead of deleted). The\n * batch's claim is renewed until it is done. A throwing handler is reported and its row left for\n * re-delivery after the lease; the rest of the batch continues.\n */\n private async drainResults(handler: (result: StepResult) => Promise<void>): Promise<number> {\n const { rows, token } = await this.claim<ResultRow>(\n this.resultsTable,\n this.batchSize,\n '1 = 1',\n [],\n );\n if (rows.length === 0) return 0;\n this.hold(this.resultsTable, token);\n try {\n for (const row of rows) {\n if (!this.running) break;\n try {\n const result: StepResult = {\n runId: row.run_id,\n seq: Number(row.seq),\n stepId: row.step_id,\n status: row.status as StepResult['status'],\n output: row.output == null ? undefined : JSON.parse(row.output),\n error: row.error == null ? undefined : JSON.parse(row.error),\n startedAt: row.started_at == null ? undefined : Number(row.started_at),\n };\n await handler(result);\n await this.exec.query(\n `DELETE FROM ${this.q(this.resultsTable)} WHERE ${this.q('step_id')} = ${this.ph(1)} AND ${this.q('claimed_by')} = ${this.ph(2)}`,\n [row.step_id, token],\n );\n } catch (err) {\n this.onError(err);\n }\n }\n } finally {\n this.release(this.resultsTable, token);\n }\n return rows.length;\n }\n\n /**\n * Atomically claim up to `limit` un-leased rows matching `where` and stamp them with a claim token\n * unique to this round (`<instanceId>:<uuid>`). `SELECT … FOR UPDATE SKIP LOCKED` inside the txn\n * means concurrent instances skip each other's locked rows instead of blocking, so no row is\n * claimed twice; the token is what every later delete/settle is conditional on.\n */\n private async claim<T extends { step_id: string }>(\n table: string,\n limit: number,\n where: string,\n whereParams: unknown[],\n ): Promise<{ rows: T[]; token: string }> {\n const t = this.q(table);\n const staleBefore = this.now() - this.leaseMs;\n const token = `${this.instanceId}:${randomUUID()}`;\n const rows = await this.exec.transaction(async (tx) => {\n const leaseClause = `(${this.q('claimed_at')} IS NULL OR ${this.q('claimed_at')} < ${this.ph(whereParams.length + 1)})`;\n const found = await tx.query<T>(\n `SELECT * FROM ${t} WHERE ${where} AND ${leaseClause}\n ORDER BY ${this.q('created_at')} ASC LIMIT ${Math.max(1, Math.floor(limit))} FOR UPDATE SKIP LOCKED`,\n [...whereParams, staleBefore],\n );\n if (found.length > 0) {\n const ids = found.map((r) => r.step_id);\n const placeholders = ids.map((_, i) => this.ph(i + 3)).join(', ');\n await tx.query(\n `UPDATE ${t} SET ${this.q('claimed_by')} = ${this.ph(1)}, ${this.q('claimed_at')} = ${this.ph(2)}\n WHERE ${this.q('step_id')} IN (${placeholders})`,\n [token, this.now(), ...ids],\n );\n }\n return found;\n });\n return { rows, token };\n }\n\n // ── claim renewal: every claim held, not only the row being run ─────────────────────────────\n private hold(table: string, token: string): void {\n let tokens = this.held.get(table);\n if (!tokens) {\n tokens = new Set();\n this.held.set(table, tokens);\n }\n tokens.add(token);\n if (this.renewTimer) return;\n const intervalMs = Math.max(10, Math.floor(this.leaseMs / 3));\n this.renewTimer = setInterval(() => {\n if (this.renewal) return; // a slow database never stacks renewal rounds\n const round = this.renewHeld()\n .catch((err) => this.onError(err))\n .finally(() => {\n if (this.renewal === round) this.renewal = undefined;\n });\n this.renewal = round;\n }, intervalMs);\n this.renewTimer.unref?.();\n }\n\n private release(table: string, token: string): void {\n const tokens = this.held.get(table);\n tokens?.delete(token);\n if (tokens?.size === 0) this.held.delete(table);\n if (this.held.size === 0 && this.renewTimer) {\n clearInterval(this.renewTimer);\n this.renewTimer = undefined;\n }\n }\n\n /** Re-stamp `claimed_at` on every row still claimed under a token this instance holds. */\n private async renewHeld(): Promise<void> {\n for (const [table, tokens] of this.held) {\n if (tokens.size === 0) continue;\n const list = [...tokens];\n const placeholders = list.map((_, i) => this.ph(i + 2)).join(', ');\n await this.exec.query(\n `UPDATE ${this.q(table)} SET ${this.q('claimed_at')} = ${this.ph(1)} WHERE ${this.q('claimed_by')} IN (${placeholders})`,\n [this.now(), ...list],\n );\n }\n }\n\n /**\n * Write a result row. A result already waiting for the same step (an earlier attempt's, not yet\n * deleted by the engine) is REPLACED and its claim reset, never kept: `step_id` is stable across\n * attempts, so keeping the old row would silently drop a retry's result.\n */\n private async insertResult(result: StepResult, tx: SqlTx = this.exec): Promise<void> {\n const t = this.q(this.resultsTable);\n const cols = [\n 'step_id',\n 'run_id',\n 'seq',\n 'status',\n 'output',\n 'error',\n 'started_at',\n 'created_at',\n ];\n const replace = ['run_id', 'seq', 'status', 'output', 'error', 'started_at', 'created_at'];\n const values = `(${cols.map((c) => this.q(c)).join(', ')}) VALUES (${cols.map((_, i) => this.ph(i + 1)).join(', ')})`;\n const sql = this.isPg\n ? `INSERT INTO ${t} ${values} ON CONFLICT (${this.q('step_id')}) DO UPDATE SET ${[\n ...replace.map((c) => `${this.q(c)} = EXCLUDED.${this.q(c)}`),\n `${this.q('claimed_by')} = NULL`,\n `${this.q('claimed_at')} = NULL`,\n ].join(', ')}`\n : `INSERT INTO ${t} ${values} ON DUPLICATE KEY UPDATE ${[\n ...replace.map((c) => `${this.q(c)} = VALUES(${this.q(c)})`),\n `${this.q('claimed_by')} = NULL`,\n `${this.q('claimed_at')} = NULL`,\n ].join(', ')}`;\n await tx.query(sql, [\n result.stepId,\n result.runId,\n result.seq,\n result.status,\n result.output === undefined ? null : JSON.stringify(result.output),\n result.error === undefined ? null : JSON.stringify(result.error),\n result.startedAt ?? null,\n this.now(),\n ]);\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n }\n\n /** Stop the pollers and wait for the current ticks to settle. Does not close the shared DataSource. */\n async close(): Promise<void> {\n this.running = false;\n await Promise.allSettled([this.taskLoop, this.resultLoop]);\n // Let running tasks settle (their claims keep being renewed until they do).\n await Promise.allSettled([...this.runningTasks]);\n if (this.renewTimer) clearInterval(this.renewTimer);\n this.renewTimer = undefined;\n await this.renewal;\n }\n}\n","/**\n * The tiny SQL surface `DbTransport` needs, so it rides **whatever ORM/connection the app already\n * has** instead of forcing a specific one. Implement it for your ORM (adapters for TypeORM and\n * MikroORM ship below) and hand it to `new DbTransport({ executor })`. Mirrors how the durable\n * *store* is ORM-pluggable (store-typeorm / store-mikro-orm / …).\n */\nexport interface SqlExecutor {\n /** Drives placeholder style (`$n` vs `?`) and a couple of DDL type choices. */\n readonly dialect: 'mysql' | 'postgres';\n /** Quote/escape an identifier (table/column name) for this dialect. */\n escapeId(id: string): string;\n /** Run a statement outside a transaction. */\n query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n /** Run `fn` inside a transaction — the claim uses `FOR UPDATE SKIP LOCKED` within it. */\n transaction<T>(fn: (tx: SqlTx) => Promise<T>): Promise<T>;\n}\n\nexport interface SqlTx {\n query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;\n}\n\n// ── TypeORM ──────────────────────────────────────────────────────────────────────────────────────\n\n/** Structural slice of a TypeORM `DataSource` — avoids a hard dependency on typeorm. */\ninterface TypeOrmDataSourceLike {\n options: { type: string };\n driver: { escape(id: string): string };\n query(sql: string, params?: unknown[]): Promise<unknown>;\n createQueryRunner(): {\n connect(): Promise<void>;\n startTransaction(): Promise<void>;\n commitTransaction(): Promise<void>;\n rollbackTransaction(): Promise<void>;\n release(): Promise<void>;\n query(sql: string, params?: unknown[]): Promise<unknown>;\n };\n}\n\n/** Build an executor from a TypeORM `DataSource` (e.g. the one the durable store already uses). */\nexport function typeOrmExecutor(ds: TypeOrmDataSourceLike): SqlExecutor {\n return {\n dialect: /postgres/.test(String(ds.options.type)) ? 'postgres' : 'mysql',\n escapeId: (id) => ds.driver.escape(id),\n query: <T>(sql: string, params?: unknown[]) => ds.query(sql, params) as Promise<T[]>,\n transaction: async <T>(fn: (tx: SqlTx) => Promise<T>) => {\n const qr = ds.createQueryRunner();\n await qr.connect();\n await qr.startTransaction();\n try {\n const result = await fn({\n query: <U>(sql: string, params?: unknown[]) => qr.query(sql, params) as Promise<U[]>,\n });\n await qr.commitTransaction();\n return result;\n } catch (err) {\n await qr.rollbackTransaction();\n throw err;\n } finally {\n await qr.release();\n }\n },\n };\n}\n\n// ── MikroORM ─────────────────────────────────────────────────────────────────────────────────────\n\n/** Structural slice of a MikroORM `EntityManager` — avoids a hard dependency on @mikro-orm/core. */\ninterface MikroOrmEmLike {\n getPlatform(): { constructor: { name: string }; quoteIdentifier(id: string): string };\n getConnection(): {\n execute(sql: string, params?: unknown[], method?: 'all', ctx?: unknown): Promise<unknown>;\n };\n getTransactionContext?(): unknown;\n transactional<T>(cb: (em: MikroOrmEmLike) => Promise<T>): Promise<T>;\n}\n\n/**\n * Build an executor from a MikroORM `EntityManager` — i.e. **the app's own configured ORM**. Runs\n * raw SQL through `em.getConnection().execute(...)` and wraps the claim in `em.transactional(...)`,\n * threading the transaction context so `FOR UPDATE SKIP LOCKED` locks within the tx.\n */\nexport function mikroOrmExecutor(em: MikroOrmEmLike): SqlExecutor {\n const platform = em.getPlatform();\n return {\n dialect: /postgre/i.test(platform.constructor.name) ? 'postgres' : 'mysql',\n escapeId: (id) => platform.quoteIdentifier(id),\n query: <T>(sql: string, params?: unknown[]) =>\n em.getConnection().execute(sql, params ?? [], 'all') as Promise<T[]>,\n transaction: <T>(fn: (tx: SqlTx) => Promise<T>) =>\n em.transactional((txEm) =>\n fn({\n query: <U>(sql: string, params?: unknown[]) =>\n txEm\n .getConnection()\n .execute(sql, params ?? [], 'all', txEm.getTransactionContext?.()) as Promise<U[]>,\n }),\n ),\n };\n}\n"],"mappings":"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","names":["typeOrmExecutor","ds","dialect","test","String","options","type","escapeId","id","driver","escape","query","sql","params","transaction","fn","qr","createQueryRunner","connect","startTransaction","result","commitTransaction","err","rollbackTransaction","release","mikroOrmExecutor","em","platform","getPlatform","name","quoteIdentifier","getConnection","execute","transactional","txEm","getTransactionContext","numberOption","value","fallback","min","undefined","Number","isFinite","DbTransport","exec","group","tasksTable","resultsTable","pollMs","leaseMs","batchSize","concurrency","onError","autoCreate","instanceId","isPg","handlers","Map","running","schemaReady","taskLoop","resultLoop","runningTasks","Set","held","renewTimer","renewal","options","executor","prefix","Math","floor","err","console","error","randomUUID","dialect","ph","i","q","id","escapeId","now","Date","ensureSchema","createTables","catch","createIfMissing","sql","query","str","txt","tasks","results","dispatch","task","t","ignore","cols","replace","map","c","join","_","stepId","runId","seq","name","JSON","stringify","input","attempt","handle","fn","Error","set","loop","drainTasks","onResult","handler","drainResults","onHeartbeat","_handler","tick","processed","sleep","free","size","Promise","race","rows","token","claim","length","hold","left","row","run","runTask","finally","delete","release","add","stillHolds","step_id","run_id","parse","result","runStepHandler","get","settleTask","transaction","tx","insertResult","status","output","startedAt","started_at","table","limit","where","whereParams","staleBefore","leaseClause","found","max","ids","r","placeholders","tokens","intervalMs","setInterval","round","renewHeld","unref","clearInterval","list","values","ms","setTimeout","close","allSettled"]}