@adonis-agora/durable 1.0.0-rc.0 → 1.0.0-rc.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.
Files changed (152) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/assets/spa/assets/index-B2kIfzsc.css +1 -0
  3. package/dist/assets/spa/assets/index-Bgf_8IJ0.js +16 -0
  4. package/dist/assets/spa/assets/index-Bgf_8IJ0.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/commands/runs.d.ts.map +1 -1
  7. package/dist/commands/runs.js +4 -2
  8. package/dist/commands/runs.js.map +1 -1
  9. package/dist/providers/dashboard_provider.d.ts +15 -49
  10. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  11. package/dist/providers/dashboard_provider.js +69 -244
  12. package/dist/providers/dashboard_provider.js.map +1 -1
  13. package/dist/providers/durable_provider.d.ts.map +1 -1
  14. package/dist/providers/durable_provider.js +11 -3
  15. package/dist/providers/durable_provider.js.map +1 -1
  16. package/dist/src/commands/runs.js +1 -1
  17. package/dist/src/commands/runs.js.map +1 -1
  18. package/dist/src/concurrency.d.ts.map +1 -1
  19. package/dist/src/concurrency.js +1 -0
  20. package/dist/src/concurrency.js.map +1 -1
  21. package/dist/src/control-flow-signal.d.ts +5 -4
  22. package/dist/src/control-flow-signal.d.ts.map +1 -1
  23. package/dist/src/control-flow-signal.js +5 -4
  24. package/dist/src/control-flow-signal.js.map +1 -1
  25. package/dist/src/dashboard/access_denied_page.d.ts +5 -7
  26. package/dist/src/dashboard/access_denied_page.d.ts.map +1 -1
  27. package/dist/src/dashboard/access_denied_page.js +2 -6
  28. package/dist/src/dashboard/access_denied_page.js.map +1 -1
  29. package/dist/src/dashboard/console_authorize.d.ts +76 -0
  30. package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
  31. package/dist/src/dashboard/console_authorize.js +101 -0
  32. package/dist/src/dashboard/console_authorize.js.map +1 -0
  33. package/dist/src/dashboard/define_config.d.ts +44 -81
  34. package/dist/src/dashboard/define_config.d.ts.map +1 -1
  35. package/dist/src/dashboard/define_config.js +16 -112
  36. package/dist/src/dashboard/define_config.js.map +1 -1
  37. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  38. package/dist/src/dashboard/handlers.js +6 -3
  39. package/dist/src/dashboard/handlers.js.map +1 -1
  40. package/dist/src/dashboard/index.d.ts +5 -8
  41. package/dist/src/dashboard/index.d.ts.map +1 -1
  42. package/dist/src/dashboard/index.js +4 -6
  43. package/dist/src/dashboard/index.js.map +1 -1
  44. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  45. package/dist/src/dashboard/openapi.js +11 -1
  46. package/dist/src/dashboard/openapi.js.map +1 -1
  47. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  48. package/dist/src/dashboard/run-filter.js +1 -0
  49. package/dist/src/dashboard/run-filter.js.map +1 -1
  50. package/dist/src/define_config.d.ts +6 -0
  51. package/dist/src/define_config.d.ts.map +1 -1
  52. package/dist/src/define_config.js.map +1 -1
  53. package/dist/src/diagnostics/index.d.ts +11 -0
  54. package/dist/src/diagnostics/index.d.ts.map +1 -0
  55. package/dist/src/diagnostics/index.js +6 -0
  56. package/dist/src/diagnostics/index.js.map +1 -0
  57. package/dist/src/diagnostics/transport.d.ts +71 -0
  58. package/dist/src/diagnostics/transport.d.ts.map +1 -0
  59. package/dist/src/diagnostics/transport.js +188 -0
  60. package/dist/src/diagnostics/transport.js.map +1 -0
  61. package/dist/src/diagnostics-bridge.d.ts +2 -1
  62. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  63. package/dist/src/diagnostics-bridge.js +7 -1
  64. package/dist/src/diagnostics-bridge.js.map +1 -1
  65. package/dist/src/engine.d.ts +30 -8
  66. package/dist/src/engine.d.ts.map +1 -1
  67. package/dist/src/engine.js +198 -72
  68. package/dist/src/engine.js.map +1 -1
  69. package/dist/src/errors.d.ts +9 -0
  70. package/dist/src/errors.d.ts.map +1 -1
  71. package/dist/src/errors.js +13 -0
  72. package/dist/src/errors.js.map +1 -1
  73. package/dist/src/event-trigger-bridge.d.ts +5 -14
  74. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  75. package/dist/src/event-trigger-bridge.js +11 -99
  76. package/dist/src/event-trigger-bridge.js.map +1 -1
  77. package/dist/src/index.d.ts +1 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +1 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/interfaces.d.ts +22 -6
  82. package/dist/src/interfaces.d.ts.map +1 -1
  83. package/dist/src/interfaces.js +8 -0
  84. package/dist/src/interfaces.js.map +1 -1
  85. package/dist/src/origin.js +3 -3
  86. package/dist/src/scheduler.d.ts.map +1 -1
  87. package/dist/src/scheduler.js +2 -1
  88. package/dist/src/scheduler.js.map +1 -1
  89. package/dist/src/singleton-admission.d.ts.map +1 -1
  90. package/dist/src/singleton-admission.js +2 -0
  91. package/dist/src/singleton-admission.js.map +1 -1
  92. package/dist/src/singleton-gate.d.ts +1 -1
  93. package/dist/src/singleton-gate.d.ts.map +1 -1
  94. package/dist/src/singleton-gate.js +4 -3
  95. package/dist/src/singleton-gate.js.map +1 -1
  96. package/dist/src/stores/lucid-pg-hot-path.d.ts +6 -2
  97. package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -1
  98. package/dist/src/stores/lucid-pg-hot-path.js +7 -3
  99. package/dist/src/stores/lucid-pg-hot-path.js.map +1 -1
  100. package/dist/src/stores/lucid.d.ts +15 -2
  101. package/dist/src/stores/lucid.d.ts.map +1 -1
  102. package/dist/src/stores/lucid.js +191 -49
  103. package/dist/src/stores/lucid.js.map +1 -1
  104. package/dist/src/testing/in-memory-state-store.js +3 -3
  105. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  106. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  107. package/dist/src/testing-kit/state-store-conformance.js +13 -0
  108. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  109. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  110. package/dist/src/testing-kit/transport-conformance.js +5 -1
  111. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  112. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  113. package/dist/src/transports/bullmq/bullmq-transport.js +13 -2
  114. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  115. package/dist/src/transports/db.d.ts.map +1 -1
  116. package/dist/src/transports/db.js +42 -3
  117. package/dist/src/transports/db.js.map +1 -1
  118. package/dist/src/transports/queue.d.ts.map +1 -1
  119. package/dist/src/transports/queue.js +44 -3
  120. package/dist/src/transports/queue.js.map +1 -1
  121. package/dist/src/workflow-discovery.d.ts +1 -1
  122. package/dist/src/workflow-discovery.d.ts.map +1 -1
  123. package/dist/src/workflow-discovery.js +4 -7
  124. package/dist/src/workflow-discovery.js.map +1 -1
  125. package/dist/src/workflow-events.d.ts +20 -51
  126. package/dist/src/workflow-events.d.ts.map +1 -1
  127. package/dist/src/workflow-events.js +12 -30
  128. package/dist/src/workflow-events.js.map +1 -1
  129. package/dist/src/workflow-ref.d.ts +1 -1
  130. package/dist/src/workflow-turn.d.ts.map +1 -1
  131. package/dist/src/workflow-turn.js +18 -1
  132. package/dist/src/workflow-turn.js.map +1 -1
  133. package/dist/stubs/config/durable_dashboard.stub +9 -29
  134. package/package.json +12 -2
  135. package/skills/durable-cluster/SKILL.md +8 -4
  136. package/skills/durable-reliability/SKILL.md +2 -2
  137. package/skills/durable-setup/SKILL.md +3 -1
  138. package/dist/assets/spa/assets/index-70hGzOnT.css +0 -1
  139. package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
  140. package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
  141. package/dist/src/dashboard/auth.d.ts +0 -134
  142. package/dist/src/dashboard/auth.d.ts.map +0 -1
  143. package/dist/src/dashboard/auth.js +0 -156
  144. package/dist/src/dashboard/auth.js.map +0 -1
  145. package/dist/src/dashboard/login_page.d.ts +0 -36
  146. package/dist/src/dashboard/login_page.d.ts.map +0 -1
  147. package/dist/src/dashboard/login_page.js +0 -158
  148. package/dist/src/dashboard/login_page.js.map +0 -1
  149. package/dist/src/dashboard/session_cookie.d.ts +0 -45
  150. package/dist/src/dashboard/session_cookie.d.ts.map +0 -1
  151. package/dist/src/dashboard/session_cookie.js +0 -97
  152. package/dist/src/dashboard/session_cookie.js.map +0 -1
@@ -1,4 +1,6 @@
1
- import { SETTLEABLE_RUN_STATUSES } from '../interfaces.js';
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { RunAlreadyExistsError } from '../errors.js';
3
+ import { RECOVERABLE_RUN_STATUSES, SETTLEABLE_RUN_STATUSES } from '../interfaces.js';
2
4
  import { namespaceFilterValues, normalizeNamespace } from '../namespaces.js';
3
5
  import { mergeRunFacetRows } from '../run-facets.js';
4
6
  import { runPageWindow } from '../run-pagination.js';
@@ -51,19 +53,59 @@ export class LucidStateStore {
51
53
  }
52
54
  /**
53
55
  * The Postgres hot path (see `lucid-pg-hot-path.ts`): the statements a turn and a step result run
54
- * on every step, as constant SQL. `undefined` on any other dialect, or with Lucid's `debug` on.
55
- * Resolved per call, like {@link client}: under a global transaction it is the transaction's.
56
+ * on every step, as constant SQL. `undefined` on any other dialect, with Lucid's `debug` on, or
57
+ * under a (global) transaction. Resolved per call, like {@link client}.
56
58
  */
57
59
  hot() {
60
+ // Not on a transaction (an app's global one): see `LucidPgHotPath.for`.
58
61
  return LucidPgHotPath.for(this.client(), {
59
62
  preparedStatements: this.options.preparedStatements,
60
63
  });
61
64
  }
65
+ /**
66
+ * The hot reads with explicit column lists: each table's base columns plus the later-wave ones it
67
+ * has, probed once (an older schema without them keeps working) and again after this store's own
68
+ * {@link ensureSchema}. A column another process adds later is not read until the next probe, and a
69
+ * statement already prepared keeps its shape.
70
+ */
71
+ hotReadStatements;
72
+ hotReads(hot) {
73
+ this.hotReadStatements ??= hot
74
+ .rows(COLUMNS_PRESENT_SQL, [
75
+ [DURABLE_TABLES.runs, DURABLE_TABLES.checkpoints],
76
+ [...RUN_OPTIONAL_COLUMNS, ...CHECKPOINT_OPTIONAL_COLUMNS],
77
+ ])
78
+ .then((rows) => {
79
+ const present = new Set(rows.map((r) => `${String(r.table_name)}.${String(r.column_name)}`));
80
+ const has = (table) => (c) => present.has(`${table}.${c}`);
81
+ const runCols = [
82
+ ...RUN_BASE_COLUMNS,
83
+ ...RUN_OPTIONAL_COLUMNS.filter(has(DURABLE_TABLES.runs)),
84
+ ];
85
+ const checkpointCols = [
86
+ ...CHECKPOINT_BASE_COLUMNS,
87
+ ...CHECKPOINT_OPTIONAL_COLUMNS.filter(has(DURABLE_TABLES.checkpoints)),
88
+ ];
89
+ return hotReadSql(runCols.map(quote).join(', '), checkpointCols.map(quote).join(', '));
90
+ })
91
+ .catch((error) => {
92
+ this.hotReadStatements = undefined; // a failed probe is retried, never cached
93
+ throw error;
94
+ });
95
+ return this.hotReadStatements;
96
+ }
62
97
  /** Idempotently provision the durable tables (called on boot when `autoSchema` is on). */
63
98
  async ensureSchema() {
64
99
  await createDurableTables(this.db, this.connectionName, this.options.collation !== undefined ? { collation: this.options.collation } : {});
100
+ // The tables may have gained columns: the next hot read probes them again.
101
+ this.hotReadStatements = undefined;
65
102
  }
66
103
  // --- runs ---------------------------------------------------------------
104
+ /**
105
+ * Insert the run, or reject with {@link RunAlreadyExistsError} when its id is taken. The insert
106
+ * writes nothing on a conflict instead of failing, so a run that already exists never aborts a
107
+ * surrounding transaction (an app's test `wrapInGlobalTransaction`, on Postgres).
108
+ */
67
109
  async createRun(run) {
68
110
  if (normalizeAttributeRows(run.id, run.searchAttributes).length === 0) {
69
111
  // Nothing to index: the insert, then clear any attribute rows a deleted run of this id left,
@@ -72,17 +114,23 @@ export class LucidStateStore {
72
114
  if (hot) {
73
115
  const row = runToRow(run);
74
116
  const keys = Object.keys(row);
75
- await hot.run(insertSql(DURABLE_TABLES.runs, keys), keys.map((k) => row[k]));
117
+ const inserted = await hot.rows(insertRunIfAbsentSql(keys), keys.map((k) => row[k]));
118
+ if (inserted.length === 0)
119
+ throw new RunAlreadyExistsError(run.id);
76
120
  await hot.run(DELETE_ATTRIBUTES_SQL, [run.id]);
77
121
  return;
78
122
  }
79
123
  const client = this.client();
80
- await client.table(DURABLE_TABLES.runs).insert(runToRow(run));
124
+ if (!(await insertIfAbsent(client, DURABLE_TABLES.runs, runToRow(run), ['id']))) {
125
+ throw new RunAlreadyExistsError(run.id);
126
+ }
81
127
  await client.from(DURABLE_TABLES.attributes).where('run_id', run.id).delete();
82
128
  return;
83
129
  }
84
130
  await this.client().transaction(async (trx) => {
85
- await trx.table(DURABLE_TABLES.runs).insert(runToRow(run));
131
+ if (!(await insertIfAbsent(trx, DURABLE_TABLES.runs, runToRow(run), ['id']))) {
132
+ throw new RunAlreadyExistsError(run.id);
133
+ }
86
134
  await this.reindexAttributes(trx, run.id, run.searchAttributes);
87
135
  });
88
136
  }
@@ -277,9 +325,10 @@ export class LucidStateStore {
277
325
  async getRun(runId) {
278
326
  const hot = this.hot();
279
327
  if (hot) {
328
+ const sql = await this.hotReads(hot);
280
329
  const rows = this.scopeNamespace === undefined
281
- ? await hot.rows(GET_RUN_SQL, [runId])
282
- : await hot.rows(GET_RUN_SCOPED_SQL, [runId, namespaceFilterValues(this.scopeNamespace)]);
330
+ ? await hot.rows(sql.get, [runId])
331
+ : await hot.rows(sql.getScoped, [runId, namespaceFilterValues(this.scopeNamespace)]);
283
332
  return rows[0] ? rowToRun(rows[0]) : null;
284
333
  }
285
334
  const row = await this.fenced(this.client().from(DURABLE_TABLES.runs).where('id', runId)).first();
@@ -323,7 +372,7 @@ export class LucidStateStore {
323
372
  async getCheckpoint(runId, seq) {
324
373
  const hot = this.hot();
325
374
  if (hot) {
326
- const rows = await hot.rows(GET_CHECKPOINT_SQL, [runId, seq]);
375
+ const rows = await hot.rows((await this.hotReads(hot)).getCheckpoint, [runId, seq]);
327
376
  return rows[0] ? rowToCheckpoint(rows[0]) : null;
328
377
  }
329
378
  const row = await this.client()
@@ -587,27 +636,7 @@ export class LucidStateStore {
587
636
  const values = keys.map((k) => row[k] ?? null);
588
637
  return (await hot.rows(insertCheckpointIfAbsentSql(keys), values)).length > 0;
589
638
  }
590
- if (client.dialect.name === 'postgres') {
591
- const inserted = (await client
592
- .insertQuery()
593
- .table(DURABLE_TABLES.checkpoints)
594
- .knexQuery.insert(row)
595
- .onConflict(['run_id', 'seq'])
596
- .ignore()
597
- .returning('seq'));
598
- return inserted.length > 0;
599
- }
600
- // MySQL and SQLite: a plain INSERT, whose duplicate-key error is the "already there" answer (an
601
- // `INSERT IGNORE` would also swallow every other error, and reports no row count through Knex).
602
- try {
603
- await client.table(DURABLE_TABLES.checkpoints).insert(row);
604
- return true;
605
- }
606
- catch (err) {
607
- if (isDuplicateKey(err))
608
- return false;
609
- throw err;
610
- }
639
+ return insertIfAbsent(client, DURABLE_TABLES.checkpoints, row, ['run_id', 'seq']);
611
640
  }
612
641
  /** Persist the latest worker heartbeat on the step's checkpoint row (see the interface doc).
613
642
  * A targeted two-column UPDATE — never an upsert: a beat racing the checkpoint's own insert (or
@@ -639,7 +668,9 @@ export class LucidStateStore {
639
668
  }
640
669
  // --- recovery / dispatch queries ----------------------------------------
641
670
  async listIncompleteRuns(namespace) {
642
- const q = this.fenced(this.client().from(DURABLE_TABLES.runs).where('status', 'running'));
671
+ const q = this.fenced(this.client()
672
+ .from(DURABLE_TABLES.runs)
673
+ .whereIn('status', [...RECOVERABLE_RUN_STATUSES]));
643
674
  if (namespace !== undefined)
644
675
  q.whereIn('namespace', namespaceFilterValues(namespace));
645
676
  const rows = await q;
@@ -652,7 +683,7 @@ export class LucidStateStore {
652
683
  // instance id (a rebooted instance's own leftovers; the engine filters what it is executing).
653
684
  const q = this.fenced(this.client()
654
685
  .from(DURABLE_TABLES.runs)
655
- .where('status', 'running')
686
+ .whereIn('status', [...RECOVERABLE_RUN_STATUSES])
656
687
  .andWhere((sub) => {
657
688
  sub.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
658
689
  if (owner !== undefined)
@@ -767,9 +798,10 @@ export class LucidStateStore {
767
798
  async tryLockRunAndGet(runId, owner, leaseUntilMs, nowMs) {
768
799
  const hot = this.hot();
769
800
  if (hot) {
801
+ const sql = await this.hotReads(hot);
770
802
  const rows = this.scopeNamespace === undefined
771
- ? await hot.rows(LOCK_AND_GET_SQL, [owner, leaseUntilMs, runId, nowMs])
772
- : await hot.rows(LOCK_AND_GET_SCOPED_SQL, [
803
+ ? await hot.rows(sql.lockAndGet, [owner, leaseUntilMs, runId, nowMs])
804
+ : await hot.rows(sql.lockAndGetScoped, [
773
805
  owner,
774
806
  leaseUntilMs,
775
807
  runId,
@@ -1568,6 +1600,43 @@ export class LucidStateStore {
1568
1600
  }
1569
1601
  }
1570
1602
  }
1603
+ /**
1604
+ * Serialize the store's work on a transaction client. A transaction is ONE connection: two store
1605
+ * calls in flight at once (a background run pickup next to a request's own write, under an app's test
1606
+ * `wrapInGlobalTransaction`) would send overlapping queries down it, which `pg` deprecates (and 9.0
1607
+ * removes), and a statement could land inside a savepoint the other call is rolling back. So while
1608
+ * the store's client is a transaction, each call waits for the previous one on that transaction to
1609
+ * finish. Re-entrant: a store method calling another one in the same call does not wait for itself.
1610
+ * A store on a plain connection is untouched (its pool hands each query its own connection).
1611
+ */
1612
+ const transactionTails = new WeakMap();
1613
+ const holdingTransaction = new AsyncLocalStorage();
1614
+ function serializeOnTransactions(proto) {
1615
+ const AsyncFunction = (async () => { }).constructor;
1616
+ for (const name of Object.getOwnPropertyNames(proto)) {
1617
+ if (name === 'constructor')
1618
+ continue;
1619
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
1620
+ const original = descriptor?.value;
1621
+ if (typeof original !== 'function' || !(original instanceof AsyncFunction))
1622
+ continue;
1623
+ const method = original;
1624
+ Object.defineProperty(proto, name, {
1625
+ ...descriptor,
1626
+ value: function (...args) {
1627
+ const client = this.client();
1628
+ if (!client.isTransaction || holdingTransaction.getStore() === client) {
1629
+ return method.apply(this, args);
1630
+ }
1631
+ const previous = transactionTails.get(client) ?? Promise.resolve();
1632
+ const run = previous.then(() => holdingTransaction.run(client, () => method.apply(this, args)), () => holdingTransaction.run(client, () => method.apply(this, args)));
1633
+ transactionTails.set(client, run.catch(() => undefined));
1634
+ return run;
1635
+ },
1636
+ });
1637
+ }
1638
+ }
1639
+ serializeOnTransactions(LucidStateStore.prototype);
1571
1640
  /**
1572
1641
  * Escape LIKE metacharacters (`%`, `_`) — and the escape character itself — using `!` as the ESCAPE
1573
1642
  * char. `!` because a backslash inside a SQL string literal is dialect-divergent (MySQL treats it as
@@ -1664,24 +1733,71 @@ function escapeLike(value) {
1664
1733
  const RUNS_T = quote(DURABLE_TABLES.runs);
1665
1734
  const CHECKPOINTS_T = quote(DURABLE_TABLES.checkpoints);
1666
1735
  const CHECKPOINT_LIST = CHECKPOINT_COLUMNS_BUT_INPUT.map(quote).join(', ');
1667
- const GET_RUN_SQL = `select * from ${RUNS_T} where "id" = ? limit 1`;
1668
- const GET_RUN_SCOPED_SQL = `select * from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`;
1736
+ /**
1737
+ * The runs columns every schema has. The hot path names its columns instead of `*`: a named prepared
1738
+ * statement's result type is fixed when it is parsed, so a `*` statement fails ("cached plan must
1739
+ * not change result type") once a column is added under it — another pod's `ensureSchema` during a
1740
+ * rolling upgrade.
1741
+ */
1742
+ const RUN_BASE_COLUMNS = [
1743
+ 'id',
1744
+ 'workflow',
1745
+ 'workflow_version',
1746
+ 'status',
1747
+ 'namespace',
1748
+ 'input',
1749
+ 'output',
1750
+ 'error',
1751
+ 'wake_at',
1752
+ 'locked_by',
1753
+ 'locked_until',
1754
+ 'recovery_attempts',
1755
+ 'tags',
1756
+ 'search_attributes',
1757
+ 'priority',
1758
+ 'created_at',
1759
+ 'updated_at',
1760
+ ];
1761
+ /** Runs columns that arrived in later schema waves: read only when the table has them. */
1762
+ const RUN_OPTIONAL_COLUMNS = ['origin', 'awaiting_decision_task_id', 'wake_requested_at'];
1763
+ /** Checkpoint columns that arrived in later schema waves: read only when the table has them. */
1764
+ const CHECKPOINT_OPTIONAL_COLUMNS = [
1765
+ 'queue',
1766
+ 'parallel_group',
1767
+ 'last_heartbeat_at',
1768
+ 'heartbeat_progress',
1769
+ ];
1770
+ const CHECKPOINT_BASE_COLUMNS = [
1771
+ ...CHECKPOINT_COLUMNS_BUT_INPUT.filter((c) => !CHECKPOINT_OPTIONAL_COLUMNS.includes(c)),
1772
+ 'input',
1773
+ ];
1774
+ const COLUMNS_PRESENT_SQL = `select "table_name", "column_name" from information_schema.columns where "table_schema" = current_schema() and "table_name" = any(?) and "column_name" = any(?)`;
1775
+ const BUFFERED_SIGNAL_LIST = ['id', 'token', 'payload'].map(quote).join(', ');
1776
+ const BUFFERED_EVENT_LIST = ['id', 'name', 'payload', 'published_at'].map(quote).join(', ');
1777
+ /** {@link HotReads} for the runs column list `cols` and checkpoints column list `checkpointCols`
1778
+ * (each quoted, comma-joined). */
1779
+ function hotReadSql(cols, checkpointCols) {
1780
+ return {
1781
+ getCheckpoint: shapedSql(`checkpoint-get:${checkpointCols}`, () => `select ${checkpointCols} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`),
1782
+ get: shapedSql(`run-get:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? limit 1`),
1783
+ getScoped: shapedSql(`run-get-scoped:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`),
1784
+ lockAndGet: shapedSql(`run-lock:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) returning ${cols}`),
1785
+ lockAndGetScoped: shapedSql(`run-lock-scoped:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) and "namespace" = any(?) returning ${cols}`),
1786
+ };
1787
+ }
1669
1788
  const RUN_IN_STATUSES_SQL = `select 1 from ${RUNS_T} where "id" = ? and "status" = any(?) limit 1`;
1670
- const LOCK_AND_GET_SQL = `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) returning *`;
1671
- const LOCK_AND_GET_SCOPED_SQL = `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) and "namespace" = any(?) returning *`;
1672
1789
  const RELEASE_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ?`;
1673
1790
  const RELEASE_OWN_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ?`;
1674
1791
  const RELEASE_UNLESS_WAKE_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ? and "wake_requested_at" is null`;
1675
1792
  const LOCKED_BY_SQL = `select "locked_by" from ${RUNS_T} where "id" = ? limit 1`;
1676
1793
  const RENEW_LOCK_SQL = `update ${RUNS_T} set "locked_until" = ? where "id" = ? and "locked_by" = ?`;
1677
1794
  const DELETE_ATTRIBUTES_SQL = `delete from ${quote(DURABLE_TABLES.attributes)} where "run_id" = ?`;
1678
- const GET_CHECKPOINT_SQL = `select * from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`;
1679
1795
  const CHECKPOINTS_FOR_SETTLE_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = any(?)`;
1680
1796
  const REPLAY_CHECKPOINTS_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? order by "seq" asc`;
1681
1797
  const COUNT_CHECKPOINTS_SQL = `select count(*) as "count" from ${CHECKPOINTS_T} where "run_id" = ? and "seq" >= ? and "seq" <= ? and "status" = any(?)`;
1682
- const OLDEST_BUFFERED_SIGNAL_SQL = `select * from ${quote(DURABLE_TABLES.bufferedSignals)} where "token" = ? order by "id" asc limit 1`;
1798
+ const OLDEST_BUFFERED_SIGNAL_SQL = `select ${BUFFERED_SIGNAL_LIST} from ${quote(DURABLE_TABLES.bufferedSignals)} where "token" = ? order by "id" asc limit 1`;
1683
1799
  const DELETE_BUFFERED_SIGNAL_SQL = `delete from ${quote(DURABLE_TABLES.bufferedSignals)} where "id" = ?`;
1684
- const BUFFERED_EVENTS_SQL = `select * from ${quote(DURABLE_TABLES.bufferedEvents)} where "name" = ? order by "published_at" asc limit ?`;
1800
+ const BUFFERED_EVENTS_SQL = `select ${BUFFERED_EVENT_LIST} from ${quote(DURABLE_TABLES.bufferedEvents)} where "name" = ? order by "published_at" asc limit ?`;
1685
1801
  /** Statements whose column list is the row's own keys, built once per key set. */
1686
1802
  const shaped = new Map();
1687
1803
  function shapedSql(key, build) {
@@ -1699,6 +1815,10 @@ function insertSql(table, keys) {
1699
1815
  return `insert into ${quote(table)} (${cols}) values (${keys.map(() => '?').join(', ')})`;
1700
1816
  });
1701
1817
  }
1818
+ /** {@link insertSql} of a run that writes nothing when its id is taken, returning the id it wrote. */
1819
+ function insertRunIfAbsentSql(keys) {
1820
+ return shapedSql(`insert-run-if-absent:${keys.join(',')}`, () => `${insertSql(DURABLE_TABLES.runs, keys)} on conflict ("id") do nothing returning "id"`);
1821
+ }
1702
1822
  /** {@link insertSql} of a checkpoint that writes nothing when its position is taken. */
1703
1823
  function insertCheckpointIfAbsentSql(keys) {
1704
1824
  return shapedSql(`insert-if-absent:${keys.join(',')}`, () => `${insertSql(DURABLE_TABLES.checkpoints, keys)} on conflict ("run_id", "seq") do nothing returning "seq"`);
@@ -1758,14 +1878,36 @@ async function settleResultsReturningPg(client, hot, results) {
1758
1878
  }
1759
1879
  return out;
1760
1880
  }
1761
- /** A duplicate-key violation, as MySQL and SQLite report it through Knex. */
1762
- function isDuplicateKey(err) {
1763
- const e = err;
1764
- return (e?.code === 'ER_DUP_ENTRY' ||
1765
- e?.errno === 1062 ||
1766
- e?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ||
1767
- e?.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
1768
- e?.code === '23505');
1881
+ /**
1882
+ * Insert `row` into `table` unless a row with the same `key` exists, as a statement that never
1883
+ * fails on the conflict, and say whether it wrote. A failing statement would abort a surrounding
1884
+ * Postgres transaction, so the conflict is never detected by catching a duplicate-key error.
1885
+ *
1886
+ * - Postgres and SQLite: `INSERT … ON CONFLICT (key) DO NOTHING RETURNING key`, a row back when it wrote.
1887
+ * - MySQL: `INSERT … ON DUPLICATE KEY UPDATE key = IF(LAST_INSERT_ID(1), key, key)`, which leaves an
1888
+ * existing row as it is and reports `insertId` 1 for it, 0 for a row it wrote (`table` has no
1889
+ * AUTO_INCREMENT). The affected-row count cannot tell them apart (mysql2 counts found rows), and
1890
+ * `INSERT IGNORE` would turn a value the column cannot hold into a silent truncation.
1891
+ */
1892
+ async function insertIfAbsent(client, table, row, key) {
1893
+ if (client.dialect.name === 'mysql') {
1894
+ const first = key[0];
1895
+ const knex = client.getWriteClient();
1896
+ const { sql, bindings } = client
1897
+ .insertQuery()
1898
+ .table(table)
1899
+ .knexQuery.insert(row)
1900
+ .onConflict(key)
1901
+ .merge({ [first]: knex.raw('if(last_insert_id(1), ??, ??)', [first, first]) })
1902
+ .toSQL()
1903
+ .toNative();
1904
+ const result = (await client.rawQuery(sql, bindings));
1905
+ const header = (Array.isArray(result) ? result[0] : result);
1906
+ return Number(header?.insertId ?? 0) === 0;
1907
+ }
1908
+ const insert = client.insertQuery().table(table).knexQuery.insert(row).onConflict(key);
1909
+ const inserted = (await insert.ignore().returning(key[0]));
1910
+ return inserted.length > 0;
1769
1911
  }
1770
1912
  function checkpointKey(runId, seq) {
1771
1913
  return `${runId}\u0000${seq}`;