@adonis-agora/durable 1.0.0-rc.15 → 1.0.0-rc.17

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 (76) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/spa/assets/{index-DlaQG-kn.js → index-CnR8n2nD.js} +3 -3
  3. package/dist/assets/spa/assets/index-CnR8n2nD.js.map +1 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/providers/durable_provider.d.ts.map +1 -1
  6. package/dist/providers/durable_provider.js +2 -0
  7. package/dist/providers/durable_provider.js.map +1 -1
  8. package/dist/src/dashboard/dev_tools.d.ts.map +1 -1
  9. package/dist/src/dashboard/dev_tools.js +48 -30
  10. package/dist/src/dashboard/dev_tools.js.map +1 -1
  11. package/dist/src/dashboard/handlers.d.ts +27 -35
  12. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  13. package/dist/src/dashboard/handlers.js +154 -76
  14. package/dist/src/dashboard/handlers.js.map +1 -1
  15. package/dist/src/dashboard/index.d.ts +1 -1
  16. package/dist/src/dashboard/index.js +1 -1
  17. package/dist/src/dashboard/list-filters.d.ts +27 -0
  18. package/dist/src/dashboard/list-filters.d.ts.map +1 -0
  19. package/dist/src/dashboard/list-filters.js +69 -0
  20. package/dist/src/dashboard/list-filters.js.map +1 -0
  21. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  22. package/dist/src/dashboard/openapi.js +115 -50
  23. package/dist/src/dashboard/openapi.js.map +1 -1
  24. package/dist/src/dashboard/run-filter.d.ts +18 -15
  25. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  26. package/dist/src/dashboard/run-filter.js +29 -22
  27. package/dist/src/dashboard/run-filter.js.map +1 -1
  28. package/dist/src/define_config.d.ts +8 -0
  29. package/dist/src/define_config.d.ts.map +1 -1
  30. package/dist/src/define_config.js.map +1 -1
  31. package/dist/src/doctor/checks.d.ts +6 -0
  32. package/dist/src/doctor/checks.d.ts.map +1 -1
  33. package/dist/src/doctor/checks.js +91 -0
  34. package/dist/src/doctor/checks.js.map +1 -1
  35. package/dist/src/index.d.ts +2 -2
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +1 -1
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/sql-schema.d.ts +54 -0
  40. package/dist/src/sql-schema.d.ts.map +1 -0
  41. package/dist/src/sql-schema.js +145 -0
  42. package/dist/src/sql-schema.js.map +1 -0
  43. package/dist/src/stores/factory.d.ts +6 -0
  44. package/dist/src/stores/factory.d.ts.map +1 -1
  45. package/dist/src/stores/factory.js +3 -0
  46. package/dist/src/stores/factory.js.map +1 -1
  47. package/dist/src/stores/lucid-schema.d.ts +17 -3
  48. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  49. package/dist/src/stores/lucid-schema.js +84 -62
  50. package/dist/src/stores/lucid-schema.js.map +1 -1
  51. package/dist/src/stores/lucid.d.ts +27 -0
  52. package/dist/src/stores/lucid.d.ts.map +1 -1
  53. package/dist/src/stores/lucid.js +253 -205
  54. package/dist/src/stores/lucid.js.map +1 -1
  55. package/dist/src/transports/db-schema.d.ts +13 -5
  56. package/dist/src/transports/db-schema.d.ts.map +1 -1
  57. package/dist/src/transports/db-schema.js +50 -32
  58. package/dist/src/transports/db-schema.js.map +1 -1
  59. package/dist/src/transports/db.d.ts +6 -0
  60. package/dist/src/transports/db.d.ts.map +1 -1
  61. package/dist/src/transports/db.js +49 -39
  62. package/dist/src/transports/db.js.map +1 -1
  63. package/dist/src/transports/factory.d.ts +16 -0
  64. package/dist/src/transports/factory.d.ts.map +1 -1
  65. package/dist/src/transports/factory.js +6 -1
  66. package/dist/src/transports/factory.js.map +1 -1
  67. package/dist/src/transports/worker-keys-db.d.ts +5 -0
  68. package/dist/src/transports/worker-keys-db.d.ts.map +1 -1
  69. package/dist/src/transports/worker-keys-db.js +10 -8
  70. package/dist/src/transports/worker-keys-db.js.map +1 -1
  71. package/dist/stubs/database/migrations/create_durable_tables.stub +12 -2
  72. package/dist/stubs/database/migrations/create_durable_transport_messages.stub +34 -5
  73. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +30 -8
  74. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -2
  75. package/package.json +6 -6
  76. package/dist/assets/spa/assets/index-DlaQG-kn.js.map +0 -1
@@ -7,9 +7,10 @@ import { runPageWindow } from '../run-pagination.js';
7
7
  import { axisIsRunColumn, mergeRunValueFacetRows, RUN_VALUE_FACET_LIMIT, RUN_VALUE_FACET_SCAN, runValueFacetsFromRuns, } from '../run-value-facets.js';
8
8
  import { attributeColumnFor, attributeOperand, attributeOperands, normalizeAttributeRows, sqlComparator, } from '../search-attributes.js';
9
9
  import { SINGLETON_ACTIVE_STATUSES, SINGLETON_ADMITTED_TAG, settlesTerminally, singletonAdmissionDenialPatch, singletonAdmissionTags, withoutAdmission, } from '../singleton-admission.js';
10
+ import { assertSchemaSupported, hasColumn, hasTable, qualify, qualifyAll, schemaExists as schemaExistsIn, validateSchemaName, } from '../sql-schema.js';
10
11
  import { CHECKPOINT_COLUMNS_BUT_INPUT, checkpointToRow, parseJsonColumn, rowToCheckpoint, rowToRun, rowToSignalWaiter, runPatchToRow, runToRow, SETTLED_CHECKPOINT_COLUMNS, settlementToRow, } from './lucid-mappers.js';
11
12
  import { LucidPgHotPath, quote } from './lucid-pg-hot-path.js';
12
- import { createDurableTables, DURABLE_TABLES } from './lucid-schema.js';
13
+ import { createDurableTables, DURABLE_SCHEMA_META_TABLE, DURABLE_TABLES } from './lucid-schema.js';
13
14
  /**
14
15
  * A production-grade, persistent `StateStore` backed by AdonisJS **Lucid** (Knex). Runs, checkpoints,
15
16
  * timers, signal waiters, run attributes and recovery leases live in SQL, so durable state survives
@@ -27,11 +28,22 @@ export class LucidStateStore {
27
28
  options;
28
29
  /** The namespace run reads are fenced to, or undefined for the operator view. */
29
30
  scopeNamespace;
31
+ /** The schema the tables live in, or undefined for the connection's current one. */
32
+ schema;
33
+ /** The durable table names, qualified with {@link schema}, for the query builder. */
34
+ t;
35
+ /** The Postgres hot path's statements over {@link t}. */
36
+ sql;
30
37
  constructor(db, options = {}) {
31
38
  this.db = db;
32
39
  this.connectionName = options.connectionName;
33
40
  this.options = options;
34
41
  this.scopeNamespace = options.scope?.namespace;
42
+ this.schema = validateSchemaName(options.schema, 'stores.lucid');
43
+ if (this.schema !== undefined)
44
+ assertSchemaSupported(this.client(), this.schema, 'stores.lucid');
45
+ this.t = qualifyAll(DURABLE_TABLES, this.schema);
46
+ this.sql = hotStatements(this.schema);
35
47
  }
36
48
  /**
37
49
  * A store over the same `Database` whose run reads are confined to `scope.namespace` (see
@@ -72,6 +84,7 @@ export class LucidStateStore {
72
84
  hotReads(hot) {
73
85
  this.hotReadStatements ??= hot
74
86
  .rows(COLUMNS_PRESENT_SQL, [
87
+ this.schema ?? null,
75
88
  [DURABLE_TABLES.runs, DURABLE_TABLES.checkpoints],
76
89
  [...RUN_OPTIONAL_COLUMNS, ...CHECKPOINT_OPTIONAL_COLUMNS],
77
90
  ])
@@ -86,7 +99,7 @@ export class LucidStateStore {
86
99
  ...CHECKPOINT_BASE_COLUMNS,
87
100
  ...CHECKPOINT_OPTIONAL_COLUMNS.filter(has(DURABLE_TABLES.checkpoints)),
88
101
  ];
89
- return hotReadSql(runCols.map(quote).join(', '), checkpointCols.map(quote).join(', '));
102
+ return this.sql.reads(runCols.map(quote).join(', '), checkpointCols.map(quote).join(', '));
90
103
  })
91
104
  .catch((error) => {
92
105
  this.hotReadStatements = undefined; // a failed probe is retried, never cached
@@ -96,10 +109,58 @@ export class LucidStateStore {
96
109
  }
97
110
  /** Idempotently provision the durable tables (called on boot when `autoSchema` is on). */
98
111
  async ensureSchema() {
99
- await createDurableTables(this.db, this.connectionName, this.options.collation !== undefined ? { collation: this.options.collation } : {});
112
+ await createDurableTables(this.db, this.connectionName, {
113
+ ...(this.options.collation !== undefined ? { collation: this.options.collation } : {}),
114
+ ...(this.schema !== undefined ? { schema: this.schema } : {}),
115
+ });
100
116
  // The tables may have gained columns: the next hot read probes them again.
101
117
  this.hotReadStatements = undefined;
102
118
  }
119
+ /** The database schema the store's tables live in, or `undefined` for the connection's current one. */
120
+ get schemaName() {
121
+ return this.schema;
122
+ }
123
+ /**
124
+ * What `agora:doctor` reports for a store with a `schema`: whether the schema exists, which durable
125
+ * tables are missing from it, which of `optionalTables` (tables other pieces create when they first
126
+ * run, like the db transport's) are in it, and whether the connection can write there (a probe row
127
+ * inserted into the schema marker table, in a transaction that is rolled back).
128
+ */
129
+ async inspectSchema(optionalTables = []) {
130
+ const client = this.client();
131
+ const schema = this.schema;
132
+ const schemaExists = schema === undefined || (await schemaExistsIn(client, schema));
133
+ const missingTables = [];
134
+ for (const table of [...Object.values(DURABLE_TABLES), DURABLE_SCHEMA_META_TABLE]) {
135
+ if (!schemaExists || !(await hasTable(client, schema, table)))
136
+ missingTables.push(table);
137
+ }
138
+ const optionalPresent = [];
139
+ if (schemaExists) {
140
+ for (const table of optionalTables) {
141
+ if (await hasTable(client, schema, table))
142
+ optionalPresent.push(table);
143
+ }
144
+ }
145
+ let writeError;
146
+ if (!missingTables.includes(DURABLE_SCHEMA_META_TABLE)) {
147
+ const rollback = new Error('durable doctor probe rollback');
148
+ try {
149
+ await client.transaction(async (trx) => {
150
+ await trx
151
+ .insertQuery()
152
+ .table(qualify(schema, DURABLE_SCHEMA_META_TABLE))
153
+ .insert({ id: '__doctor_probe__', fingerprint: 'probe', applied_at: Date.now() });
154
+ throw rollback;
155
+ });
156
+ }
157
+ catch (error) {
158
+ if (error !== rollback)
159
+ writeError = error instanceof Error ? error.message : String(error);
160
+ }
161
+ }
162
+ return { schema, schemaExists, missingTables, optionalPresent, writeError };
163
+ }
103
164
  // --- runs ---------------------------------------------------------------
104
165
  /**
105
166
  * Insert the run, or reject with {@link RunAlreadyExistsError} when its id is taken. The insert
@@ -114,21 +175,21 @@ export class LucidStateStore {
114
175
  if (hot) {
115
176
  const row = runToRow(run);
116
177
  const keys = Object.keys(row);
117
- const inserted = await hot.rows(insertRunIfAbsentSql(keys), keys.map((k) => row[k]));
178
+ const inserted = await hot.rows(this.sql.insertRunIfAbsent(keys), keys.map((k) => row[k]));
118
179
  if (inserted.length === 0)
119
180
  throw new RunAlreadyExistsError(run.id);
120
- await hot.run(DELETE_ATTRIBUTES_SQL, [run.id]);
181
+ await hot.run(this.sql.deleteAttributes, [run.id]);
121
182
  return;
122
183
  }
123
184
  const client = this.client();
124
- if (!(await insertIfAbsent(client, DURABLE_TABLES.runs, runToRow(run), ['id']))) {
185
+ if (!(await insertIfAbsent(client, this.t.runs, runToRow(run), ['id']))) {
125
186
  throw new RunAlreadyExistsError(run.id);
126
187
  }
127
- await client.from(DURABLE_TABLES.attributes).where('run_id', run.id).delete();
188
+ await client.from(this.t.attributes).where('run_id', run.id).delete();
128
189
  return;
129
190
  }
130
191
  await this.client().transaction(async (trx) => {
131
- if (!(await insertIfAbsent(trx, DURABLE_TABLES.runs, runToRow(run), ['id']))) {
192
+ if (!(await insertIfAbsent(trx, this.t.runs, runToRow(run), ['id']))) {
132
193
  throw new RunAlreadyExistsError(run.id);
133
194
  }
134
195
  await this.reindexAttributes(trx, run.id, run.searchAttributes);
@@ -150,7 +211,7 @@ export class LucidStateStore {
150
211
  const row = await this.releaseSlot(this.client(), runId, patch);
151
212
  const keys = Object.keys(row);
152
213
  if (keys.length) {
153
- await hot.run(updateRunSql(keys, false), [...keys.map((k) => row[k]), runId]);
214
+ await hot.run(this.sql.updateRun(keys, false), [...keys.map((k) => row[k]), runId]);
154
215
  }
155
216
  return;
156
217
  }
@@ -158,7 +219,7 @@ export class LucidStateStore {
158
219
  const row = await this.releaseSlot(trx, runId, patch);
159
220
  // Knex throws on an empty `.update({})`; skip the UPDATE when no mapped column changed.
160
221
  if (Object.keys(row).length) {
161
- await trx.from(DURABLE_TABLES.runs).where('id', runId).update(row);
222
+ await trx.from(this.t.runs).where('id', runId).update(row);
162
223
  }
163
224
  if ('searchAttributes' in patch) {
164
225
  await this.reindexAttributes(trx, runId, patch.searchAttributes);
@@ -171,10 +232,10 @@ export class LucidStateStore {
171
232
  const row = await this.releaseSlot(this.client(), runId, patch);
172
233
  const keys = Object.keys(row);
173
234
  if (keys.length === 0) {
174
- const found = await hot.rows(RUN_IN_STATUSES_SQL, [runId, expectedStatuses]);
235
+ const found = await hot.rows(this.sql.runInStatuses, [runId, expectedStatuses]);
175
236
  return found.length > 0;
176
237
  }
177
- const { count } = await hot.run(updateRunSql(keys, true), [
238
+ const { count } = await hot.run(this.sql.updateRun(keys, true), [
178
239
  ...keys.map((k) => row[k]),
179
240
  runId,
180
241
  expectedStatuses,
@@ -189,7 +250,7 @@ export class LucidStateStore {
189
250
  // structure — the database arbitrates, not a prior JS read. Two racing writers can never both
190
251
  // report `true` for the same row.
191
252
  const affected = await trx
192
- .from(DURABLE_TABLES.runs)
253
+ .from(this.t.runs)
193
254
  .where('id', runId)
194
255
  .whereIn('status', expectedStatuses)
195
256
  .update(row);
@@ -199,7 +260,7 @@ export class LucidStateStore {
199
260
  // Knex throws on an empty `.update({})`. Nothing to write, but the predicate's outcome is
200
261
  // still observable: report whether it would have matched, without mutating anything.
201
262
  const current = await trx
202
- .from(DURABLE_TABLES.runs)
263
+ .from(this.t.runs)
203
264
  .where('id', runId)
204
265
  .whereIn('status', expectedStatuses)
205
266
  .first();
@@ -226,7 +287,7 @@ export class LucidStateStore {
226
287
  // Sorted, so two batches sharing runs take their row locks in the same order.
227
288
  const ids = [...new Set(runIds)].sort();
228
289
  await this.client()
229
- .from(DURABLE_TABLES.runs)
290
+ .from(this.t.runs)
230
291
  .whereIn('id', ids)
231
292
  .where('status', 'suspended')
232
293
  .andWhere((w) => w.whereNull('wake_at').orWhere('wake_at', '>', wakeAt))
@@ -236,9 +297,7 @@ export class LucidStateStore {
236
297
  decisionMarkerColumn;
237
298
  hasDecisionMarkerColumn(trx) {
238
299
  // Probed on the caller's transaction: a single-connection pool (SQLite) has no other to use.
239
- this.decisionMarkerColumn ??= trx.schema
240
- .hasColumn(DURABLE_TABLES.runs, 'awaiting_decision_task_id')
241
- .catch(() => {
300
+ this.decisionMarkerColumn ??= hasColumn(trx, this.schema, DURABLE_TABLES.runs, 'awaiting_decision_task_id').catch(() => {
242
301
  this.decisionMarkerColumn = undefined; // a failed probe is retried, never cached
243
302
  return true;
244
303
  });
@@ -273,7 +332,7 @@ export class LucidStateStore {
273
332
  tags = patch.tags;
274
333
  }
275
334
  else {
276
- const q = trx.from(DURABLE_TABLES.runs).where('id', runId).select('tags');
335
+ const q = trx.from(this.t.runs).where('id', runId).select('tags');
277
336
  if (locksRows(trx))
278
337
  q.forUpdate();
279
338
  const current = (await q.first());
@@ -287,7 +346,7 @@ export class LucidStateStore {
287
346
  // Every active run sharing the key. The tag LIKE may over-match (it is only a lock-scope
288
347
  // narrowing); the decision re-filters exactly.
289
348
  const sharing = () => trx
290
- .from(DURABLE_TABLES.runs)
349
+ .from(this.t.runs)
291
350
  .where('workflow', workflow)
292
351
  .whereIn('status', SINGLETON_ACTIVE_STATUSES)
293
352
  .whereRaw(`tags like ? escape '!'`, [`%"${escapeLike(tag)}"%`]);
@@ -311,13 +370,13 @@ export class LucidStateStore {
311
370
  if (!tags) {
312
371
  const parked = singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt);
313
372
  if (parked) {
314
- await trx.from(DURABLE_TABLES.runs).where('id', runId).update(runPatchToRow(parked));
373
+ await trx.from(this.t.runs).where('id', runId).update(runPatchToRow(parked));
315
374
  }
316
375
  return false;
317
376
  }
318
377
  // Already a holder (a resumed turn): nothing to write.
319
378
  if (!runs.find((r) => r.id === runId)?.tags?.includes(SINGLETON_ADMITTED_TAG)) {
320
- await trx.from(DURABLE_TABLES.runs).where('id', runId).update(runPatchToRow({ tags }));
379
+ await trx.from(this.t.runs).where('id', runId).update(runPatchToRow({ tags }));
321
380
  }
322
381
  return true;
323
382
  });
@@ -331,14 +390,14 @@ export class LucidStateStore {
331
390
  : await hot.rows(sql.getScoped, [runId, namespaceFilterValues(this.scopeNamespace)]);
332
391
  return rows[0] ? rowToRun(rows[0]) : null;
333
392
  }
334
- const row = await this.fenced(this.client().from(DURABLE_TABLES.runs).where('id', runId)).first();
393
+ const row = await this.fenced(this.client().from(this.t.runs).where('id', runId)).first();
335
394
  return row ? rowToRun(row) : null;
336
395
  }
337
396
  async getRuns(runIds) {
338
397
  const ids = [...new Set(runIds)];
339
398
  if (ids.length === 0)
340
399
  return [];
341
- const rows = await this.fenced(this.client().from(DURABLE_TABLES.runs).whereIn('id', ids));
400
+ const rows = await this.fenced(this.client().from(this.t.runs).whereIn('id', ids));
342
401
  return rows.map(rowToRun);
343
402
  }
344
403
  async deleteRun(runId) {
@@ -346,10 +405,10 @@ export class LucidStateStore {
346
405
  // transaction so a crash can't leave a half-deleted run. (Buffered signals are token-keyed,
347
406
  // not run-scoped, so they are not swept here.)
348
407
  await this.client().transaction(async (trx) => {
349
- await trx.from(DURABLE_TABLES.checkpoints).where('run_id', runId).delete();
350
- await trx.from(DURABLE_TABLES.signalWaiters).where('run_id', runId).delete();
351
- await trx.from(DURABLE_TABLES.attributes).where('run_id', runId).delete();
352
- await trx.from(DURABLE_TABLES.runs).where('id', runId).delete();
408
+ await trx.from(this.t.checkpoints).where('run_id', runId).delete();
409
+ await trx.from(this.t.signalWaiters).where('run_id', runId).delete();
410
+ await trx.from(this.t.attributes).where('run_id', runId).delete();
411
+ await trx.from(this.t.runs).where('id', runId).delete();
353
412
  });
354
413
  }
355
414
  async deleteRuns(runIds) {
@@ -361,10 +420,10 @@ export class LucidStateStore {
361
420
  await this.client().transaction(async (trx) => {
362
421
  for (let at = 0; at < ids.length; at += DELETE_RUNS_CHUNK) {
363
422
  const chunk = ids.slice(at, at + DELETE_RUNS_CHUNK);
364
- await trx.from(DURABLE_TABLES.checkpoints).whereIn('run_id', chunk).delete();
365
- await trx.from(DURABLE_TABLES.signalWaiters).whereIn('run_id', chunk).delete();
366
- await trx.from(DURABLE_TABLES.attributes).whereIn('run_id', chunk).delete();
367
- await trx.from(DURABLE_TABLES.runs).whereIn('id', chunk).delete();
423
+ await trx.from(this.t.checkpoints).whereIn('run_id', chunk).delete();
424
+ await trx.from(this.t.signalWaiters).whereIn('run_id', chunk).delete();
425
+ await trx.from(this.t.attributes).whereIn('run_id', chunk).delete();
426
+ await trx.from(this.t.runs).whereIn('id', chunk).delete();
368
427
  }
369
428
  });
370
429
  }
@@ -376,7 +435,7 @@ export class LucidStateStore {
376
435
  return rows[0] ? rowToCheckpoint(rows[0]) : null;
377
436
  }
378
437
  const row = await this.client()
379
- .from(DURABLE_TABLES.checkpoints)
438
+ .from(this.t.checkpoints)
380
439
  .where('run_id', runId)
381
440
  .andWhere('seq', seq)
382
441
  .first();
@@ -387,7 +446,7 @@ export class LucidStateStore {
387
446
  const out = [];
388
447
  for (let at = 0; at < unique.length; at += CHECKPOINT_BATCH) {
389
448
  const rows = (await this.client()
390
- .from(DURABLE_TABLES.checkpoints)
449
+ .from(this.t.checkpoints)
391
450
  .where('run_id', runId)
392
451
  .whereIn('seq', unique.slice(at, at + CHECKPOINT_BATCH)));
393
452
  out.push(...rows.map(rowToCheckpoint));
@@ -398,7 +457,7 @@ export class LucidStateStore {
398
457
  const unique = [...new Set(seqs)];
399
458
  const hot = this.hot();
400
459
  if (hot && unique.length <= CHECKPOINT_BATCH) {
401
- const rows = await hot.rows(CHECKPOINTS_FOR_SETTLE_SQL, [runId, unique]);
460
+ const rows = await hot.rows(this.sql.checkpointsForSettle, [runId, unique]);
402
461
  return rows
403
462
  .map(rowToCheckpoint)
404
463
  .sort((a, b) => a.seq - b.seq);
@@ -406,7 +465,7 @@ export class LucidStateStore {
406
465
  const out = [];
407
466
  for (let at = 0; at < unique.length; at += CHECKPOINT_BATCH) {
408
467
  const rows = (await this.client()
409
- .from(DURABLE_TABLES.checkpoints)
468
+ .from(this.t.checkpoints)
410
469
  .where('run_id', runId)
411
470
  .whereIn('seq', unique.slice(at, at + CHECKPOINT_BATCH))
412
471
  .select(...CHECKPOINT_COLUMNS_BUT_INPUT));
@@ -423,18 +482,18 @@ export class LucidStateStore {
423
482
  // One multi-row upsert per chunk, in one transaction: a rejection writes nothing.
424
483
  await this.client().transaction(async (trx) => {
425
484
  for (let at = 0; at < checkpoints.length; at += CHECKPOINT_UPSERT_BATCH) {
426
- await upsertCheckpointRows(trx, checkpoints.slice(at, at + CHECKPOINT_UPSERT_BATCH));
485
+ await upsertCheckpointRows(trx, this.t.checkpoints, checkpoints.slice(at, at + CHECKPOINT_UPSERT_BATCH));
427
486
  }
428
487
  });
429
488
  }
430
489
  async saveCheckpointIfPending(checkpoint) {
431
- return updateCheckpointIfPending(this.client(), checkpoint);
490
+ return updateCheckpointIfPending(this.client(), this.t.checkpoints, checkpoint);
432
491
  }
433
492
  async renewCheckpointLease(runId, seq, expectedWakeAt, wakeAt) {
434
493
  // One guarded UPDATE: the row-level compare-and-set is atomic on every dialect, so a result or a
435
494
  // re-dispatch committed since the caller's read makes this match nothing.
436
495
  const query = this.client()
437
- .from(DURABLE_TABLES.checkpoints)
496
+ .from(this.t.checkpoints)
438
497
  .where('run_id', runId)
439
498
  .andWhere('seq', seq)
440
499
  .andWhere('status', 'pending');
@@ -452,7 +511,7 @@ export class LucidStateStore {
452
511
  // for a row is compared against the first one's write), then write every winner with one
453
512
  // multi-row upsert per chunk.
454
513
  return this.client().transaction(async (trx) => {
455
- const pending = await lockPendingCheckpoints(trx, checkpoints, ['seq']);
514
+ const pending = await lockPendingCheckpoints(trx, this.t.checkpoints, checkpoints, ['seq']);
456
515
  const wrote = [];
457
516
  const writes = new Map();
458
517
  for (const cp of checkpoints) {
@@ -468,7 +527,7 @@ export class LucidStateStore {
468
527
  }
469
528
  const rows = [...writes.values()];
470
529
  for (let at = 0; at < rows.length; at += CHECKPOINT_UPSERT_BATCH) {
471
- await upsertCheckpointRows(trx, rows.slice(at, at + CHECKPOINT_UPSERT_BATCH));
530
+ await upsertCheckpointRows(trx, this.t.checkpoints, rows.slice(at, at + CHECKPOINT_UPSERT_BATCH));
472
531
  }
473
532
  return wrote;
474
533
  });
@@ -479,7 +538,7 @@ export class LucidStateStore {
479
538
  return [];
480
539
  if (settlements.length === 1) {
481
540
  const affected = await this.client()
482
- .from(DURABLE_TABLES.checkpoints)
541
+ .from(this.t.checkpoints)
483
542
  .where('run_id', only.runId)
484
543
  .andWhere('seq', only.seq)
485
544
  .andWhere('status', 'pending')
@@ -492,7 +551,7 @@ export class LucidStateStore {
492
551
  // locked, so the upsert never inserts: its row carries just the columns an insert would require,
493
552
  // and `input` is neither sent nor rewritten.
494
553
  return this.client().transaction(async (trx) => {
495
- const pending = await lockPendingCheckpoints(trx, settlements, [
554
+ const pending = await lockPendingCheckpoints(trx, this.t.checkpoints, settlements, [
496
555
  'seq',
497
556
  'name',
498
557
  'kind',
@@ -521,7 +580,7 @@ export class LucidStateStore {
521
580
  for (let at = 0; at < rows.length; at += CHECKPOINT_UPSERT_BATCH) {
522
581
  await trx
523
582
  .insertQuery()
524
- .table(DURABLE_TABLES.checkpoints)
583
+ .table(this.t.checkpoints)
525
584
  .knexQuery.insert(rows.slice(at, at + CHECKPOINT_UPSERT_BATCH))
526
585
  .onConflict(['run_id', 'seq'])
527
586
  .merge([...SETTLED_CHECKPOINT_COLUMNS]);
@@ -544,7 +603,7 @@ export class LucidStateStore {
544
603
  const live = results.filter((_, i) => first[i]);
545
604
  const client = this.client();
546
605
  const settled = client.dialect.name === 'postgres'
547
- ? await settleResultsReturningPg(client, this.hot(), live)
606
+ ? await settleResultsReturningPg(client, this.hot(), this.schema, live)
548
607
  : await this.settleResultsPlain(live);
549
608
  const out = [];
550
609
  let k = 0;
@@ -569,7 +628,7 @@ export class LucidStateStore {
569
628
  const out = new Map();
570
629
  const client = this.client();
571
630
  const runRows = (await client
572
- .from(DURABLE_TABLES.runs)
631
+ .from(this.t.runs)
573
632
  .whereIn('id', [...new Set(results.map((r) => r.runId))])
574
633
  .select('id', 'status', 'wake_at'));
575
634
  const runs = new Map(runRows.map((row) => [row.id, row]));
@@ -634,16 +693,16 @@ export class LucidStateStore {
634
693
  if (hot) {
635
694
  const keys = Object.keys(row);
636
695
  const values = keys.map((k) => row[k] ?? null);
637
- return (await hot.rows(insertCheckpointIfAbsentSql(keys), values)).length > 0;
696
+ return (await hot.rows(this.sql.insertCheckpointIfAbsent(keys), values)).length > 0;
638
697
  }
639
- return insertIfAbsent(client, DURABLE_TABLES.checkpoints, row, ['run_id', 'seq']);
698
+ return insertIfAbsent(client, this.t.checkpoints, row, ['run_id', 'seq']);
640
699
  }
641
700
  /** Persist the latest worker heartbeat on the step's checkpoint row (see the interface doc).
642
701
  * A targeted two-column UPDATE — never an upsert: a beat racing the checkpoint's own insert (or
643
702
  * outliving a deleted run) matches zero rows and is a silent no-op, by design. */
644
703
  async recordStepHeartbeat(runId, seq, at, progress) {
645
704
  await this.client()
646
- .from(DURABLE_TABLES.checkpoints)
705
+ .from(this.t.checkpoints)
647
706
  .where('run_id', runId)
648
707
  .andWhere('seq', seq)
649
708
  .update({
@@ -661,7 +720,7 @@ export class LucidStateStore {
661
720
  const row = checkpointToRow(checkpoint);
662
721
  await client
663
722
  .insertQuery()
664
- .table(DURABLE_TABLES.checkpoints)
723
+ .table(this.t.checkpoints)
665
724
  .knexQuery.insert(row)
666
725
  .onConflict(['run_id', 'seq'])
667
726
  .merge();
@@ -669,7 +728,7 @@ export class LucidStateStore {
669
728
  // --- recovery / dispatch queries ----------------------------------------
670
729
  async listIncompleteRuns(namespace) {
671
730
  const q = this.fenced(this.client()
672
- .from(DURABLE_TABLES.runs)
731
+ .from(this.t.runs)
673
732
  .whereIn('status', [...RECOVERABLE_RUN_STATUSES]));
674
733
  if (namespace !== undefined)
675
734
  q.whereIn('namespace', namespaceFilterValues(namespace));
@@ -682,7 +741,7 @@ export class LucidStateStore {
682
741
  // running run — and issues ~zero lock-probe UPDATEs. `owner` adds the runs still leased to that
683
742
  // instance id (a rebooted instance's own leftovers; the engine filters what it is executing).
684
743
  const q = this.fenced(this.client()
685
- .from(DURABLE_TABLES.runs)
744
+ .from(this.t.runs)
686
745
  .whereIn('status', [...RECOVERABLE_RUN_STATUSES])
687
746
  .andWhere((sub) => {
688
747
  sub.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
@@ -695,7 +754,7 @@ export class LucidStateStore {
695
754
  return rows.map(rowToRun);
696
755
  }
697
756
  async listPendingRuns(limit, namespace) {
698
- const q = this.fenced(this.client().from(DURABLE_TABLES.runs).where('status', 'pending'));
757
+ const q = this.fenced(this.client().from(this.t.runs).where('status', 'pending'));
699
758
  if (namespace !== undefined)
700
759
  q.whereIn('namespace', namespaceFilterValues(namespace));
701
760
  const rows = await q
@@ -722,9 +781,9 @@ export class LucidStateStore {
722
781
  if (dialect === 'postgres') {
723
782
  // One statement: pick the oldest free pending rows, skipping any another claimer holds locked
724
783
  // right now, and lease them. Several pollers at once each get a different slice.
725
- const pick = oldest(client.from(DURABLE_TABLES.runs)).forUpdate().skipLocked();
784
+ const pick = oldest(client.from(this.t.runs)).forUpdate().skipLocked();
726
785
  rows = (await client
727
- .from(DURABLE_TABLES.runs)
786
+ .from(this.t.runs)
728
787
  .whereIn('id', pick)
729
788
  .update({ locked_by: owner, locked_until: leaseUntilMs })
730
789
  .returning('*'));
@@ -733,20 +792,20 @@ export class LucidStateStore {
733
792
  // No UPDATE … RETURNING: lock the batch (skipping rows another claimer holds), lease it and read
734
793
  // it back, in one transaction.
735
794
  rows = await client.transaction(async (trx) => {
736
- const ids = (await oldest(trx.from(DURABLE_TABLES.runs)).forUpdate().skipLocked()).map((r) => r.id);
795
+ const ids = (await oldest(trx.from(this.t.runs)).forUpdate().skipLocked()).map((r) => r.id);
737
796
  if (ids.length === 0)
738
797
  return [];
739
798
  await trx
740
- .from(DURABLE_TABLES.runs)
799
+ .from(this.t.runs)
741
800
  .whereIn('id', ids)
742
801
  .update({ locked_by: owner, locked_until: leaseUntilMs });
743
- return (await trx.from(DURABLE_TABLES.runs).whereIn('id', ids));
802
+ return (await trx.from(this.t.runs).whereIn('id', ids));
744
803
  });
745
804
  }
746
805
  else {
747
806
  // SQLite has no row locks to skip: each run is taken with the lease's own compare-and-set.
748
807
  const claimed = [];
749
- const candidates = (await free(client.from(DURABLE_TABLES.runs))
808
+ const candidates = (await free(client.from(this.t.runs))
750
809
  .orderBy('created_at', 'asc')
751
810
  .orderBy('id', 'asc')
752
811
  .limit(limit));
@@ -764,7 +823,7 @@ export class LucidStateStore {
764
823
  async listDueTimers(nowMs, namespace, limit) {
765
824
  const marker = await this.hasWakeMarkerColumn();
766
825
  const q = this.fenced(this.client()
767
- .from(DURABLE_TABLES.runs)
826
+ .from(this.t.runs)
768
827
  .where('status', 'suspended')
769
828
  .where((w) => {
770
829
  w.where((d) => d.whereNotNull('wake_at').andWhere('wake_at', '<=', nowMs));
@@ -787,7 +846,7 @@ export class LucidStateStore {
787
846
  // Atomic compare-and-set: acquire ONLY if currently unlocked or the lease has expired. The single
788
847
  // conditional UPDATE is the concurrency guard — two instances racing can never both report `true`.
789
848
  const affected = await this.client()
790
- .from(DURABLE_TABLES.runs)
849
+ .from(this.t.runs)
791
850
  .where('id', runId)
792
851
  .andWhere((q) => {
793
852
  q.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
@@ -815,7 +874,7 @@ export class LucidStateStore {
815
874
  // One statement: the lease's compare-and-set and the read of the row it locked. The scope
816
875
  // fence sits in the WHERE clause, so a run outside it is neither locked nor read.
817
876
  const rows = (await this.fenced(client
818
- .from(DURABLE_TABLES.runs)
877
+ .from(this.t.runs)
819
878
  .where('id', runId)
820
879
  .andWhere((q) => {
821
880
  q.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
@@ -836,15 +895,15 @@ export class LucidStateStore {
836
895
  const hot = this.hot();
837
896
  if (hot) {
838
897
  if (owner === undefined)
839
- await hot.run(RELEASE_LOCK_SQL, [runId]);
898
+ await hot.run(this.sql.releaseLock, [runId]);
840
899
  else
841
- await hot.run(RELEASE_OWN_LOCK_SQL, [runId, owner]);
900
+ await hot.run(this.sql.releaseOwnLock, [runId, owner]);
842
901
  return;
843
902
  }
844
903
  // Owner-scoped release (when `owner` is given): the `locked_by` predicate lives in the UPDATE's
845
904
  // WHERE clause, so a stale executor whose lease was taken over matches zero rows and cannot
846
905
  // wipe the new owner's lease — same conditional-write shape as renewRunLock.
847
- const q = this.client().from(DURABLE_TABLES.runs).where('id', runId);
906
+ const q = this.client().from(this.t.runs).where('id', runId);
848
907
  if (owner !== undefined)
849
908
  q.andWhere('locked_by', owner);
850
909
  await q.update({ locked_by: null, locked_until: null });
@@ -855,19 +914,13 @@ export class LucidStateStore {
855
914
  const marker = await this.hasWakeMarkerColumn();
856
915
  const hot = this.hot();
857
916
  if (hot) {
858
- const released = await hot.run(marker ? RELEASE_UNLESS_WAKE_SQL : RELEASE_OWN_LOCK_SQL, [
859
- runId,
860
- owner,
861
- ]);
917
+ const released = await hot.run(marker ? this.sql.releaseUnlessWake : this.sql.releaseOwnLock, [runId, owner]);
862
918
  if (released.count === 1)
863
919
  return true;
864
- const rows = await hot.rows(LOCKED_BY_SQL, [runId]);
920
+ const rows = await hot.rows(this.sql.lockedBy, [runId]);
865
921
  return rows[0]?.locked_by !== owner;
866
922
  }
867
- const q = this.client()
868
- .from(DURABLE_TABLES.runs)
869
- .where('id', runId)
870
- .andWhere('locked_by', owner);
923
+ const q = this.client().from(this.t.runs).where('id', runId).andWhere('locked_by', owner);
871
924
  if (marker)
872
925
  q.whereNull('wake_requested_at');
873
926
  const affected = await q.update({ locked_by: null, locked_until: null });
@@ -875,7 +928,7 @@ export class LucidStateStore {
875
928
  return true;
876
929
  // Nothing matched: the lease is not ours (any more), or a turn was asked for.
877
930
  const row = (await this.client()
878
- .from(DURABLE_TABLES.runs)
931
+ .from(this.t.runs)
879
932
  .where('id', runId)
880
933
  .select('locked_by')
881
934
  .first());
@@ -883,9 +936,7 @@ export class LucidStateStore {
883
936
  }
884
937
  /** {@link hasWakeMarkerColumn} on the caller's transaction (a single-connection pool has no other). */
885
938
  hasWakeMarkerColumnIn(trx) {
886
- this.wakeMarkerColumn ??= trx.schema
887
- .hasColumn(DURABLE_TABLES.runs, 'wake_requested_at')
888
- .catch(() => {
939
+ this.wakeMarkerColumn ??= hasColumn(trx, this.schema, DURABLE_TABLES.runs, 'wake_requested_at').catch(() => {
889
940
  this.wakeMarkerColumn = undefined;
890
941
  return false;
891
942
  });
@@ -894,9 +945,7 @@ export class LucidStateStore {
894
945
  /** Whether the runs table has `wake_requested_at`, probed once per store and cached. */
895
946
  wakeMarkerColumn;
896
947
  hasWakeMarkerColumn() {
897
- this.wakeMarkerColumn ??= this.client()
898
- .schema.hasColumn(DURABLE_TABLES.runs, 'wake_requested_at')
899
- .catch(() => {
948
+ this.wakeMarkerColumn ??= hasColumn(this.client(), this.schema, DURABLE_TABLES.runs, 'wake_requested_at').catch(() => {
900
949
  this.wakeMarkerColumn = undefined;
901
950
  return false;
902
951
  });
@@ -905,10 +954,10 @@ export class LucidStateStore {
905
954
  async renewRunLock(runId, owner, leaseUntilMs) {
906
955
  const hot = this.hot();
907
956
  if (hot)
908
- return (await hot.run(RENEW_LOCK_SQL, [leaseUntilMs, runId, owner])).count === 1;
957
+ return (await hot.run(this.sql.renewLock, [leaseUntilMs, runId, owner])).count === 1;
909
958
  // Extend ONLY if `owner` still holds the lease — a dead worker's lease still expires & is reclaimed.
910
959
  const affected = await this.client()
911
- .from(DURABLE_TABLES.runs)
960
+ .from(this.t.runs)
912
961
  .where('id', runId)
913
962
  .andWhere('locked_by', owner)
914
963
  .update({ locked_until: leaseUntilMs });
@@ -920,7 +969,7 @@ export class LucidStateStore {
920
969
  // runs once per wait registration, i.e. on every signal/child/event suspension).
921
970
  await this.client()
922
971
  .insertQuery()
923
- .table(DURABLE_TABLES.signalWaiters)
972
+ .table(this.t.signalWaiters)
924
973
  .knexQuery.insert({
925
974
  token: waiter.token,
926
975
  run_id: waiter.runId,
@@ -935,10 +984,10 @@ export class LucidStateStore {
935
984
  // the same waiter to two callers. The DELETE arbitrates, so no transaction is needed around it,
936
985
  // and a token with no waiter (the common case) costs one read.
937
986
  const client = this.client();
938
- const row = await client.from(DURABLE_TABLES.signalWaiters).where('token', token).first();
987
+ const row = await client.from(this.t.signalWaiters).where('token', token).first();
939
988
  if (!row)
940
989
  return null;
941
- const deleted = await client.from(DURABLE_TABLES.signalWaiters).where('token', token).delete();
990
+ const deleted = await client.from(this.t.signalWaiters).where('token', token).delete();
942
991
  if (rowsAffected(deleted) !== 1)
943
992
  return null;
944
993
  return rowToSignalWaiter(row);
@@ -947,14 +996,14 @@ export class LucidStateStore {
947
996
  // The prefix embeds caller-supplied names (event names via eventPrefix) — escape their LIKE
948
997
  // metacharacters so a name containing `%`/`_` can't scan (or match) other tokens.
949
998
  const rows = await this.client()
950
- .from(DURABLE_TABLES.signalWaiters)
999
+ .from(this.t.signalWaiters)
951
1000
  .whereRaw(`token like ? escape '!'`, [`${escapeLike(prefix)}%`]);
952
1001
  return rows.map(rowToSignalWaiter);
953
1002
  }
954
1003
  async listSignalWaitersByRunIds(runIds) {
955
1004
  if (runIds.length === 0)
956
1005
  return [];
957
- const rows = await this.client().from(DURABLE_TABLES.signalWaiters).whereIn('run_id', runIds);
1006
+ const rows = await this.client().from(this.t.signalWaiters).whereIn('run_id', runIds);
958
1007
  return rows.map(rowToSignalWaiter);
959
1008
  }
960
1009
  async hasSignalWaiter(runId, tokenPrefix) {
@@ -962,7 +1011,7 @@ export class LucidStateStore {
962
1011
  // MySQL's default collations ignore case), so a hit is confirmed in JS. Only a match that is not
963
1012
  // a real prefix match pays for reading the run's whole matching set.
964
1013
  const matching = () => this.client()
965
- .from(DURABLE_TABLES.signalWaiters)
1014
+ .from(this.t.signalWaiters)
966
1015
  .select('token')
967
1016
  .where('run_id', runId)
968
1017
  .andWhereRaw(`token like ? escape '!'`, [`${escapeLike(tokenPrefix)}%`]);
@@ -985,7 +1034,7 @@ export class LucidStateStore {
985
1034
  const chunk = entries.slice(at, at + CHILD_WAIT_ROWS);
986
1035
  await trx
987
1036
  .insertQuery()
988
- .table(DURABLE_TABLES.signalWaiters)
1037
+ .table(this.t.signalWaiters)
989
1038
  .knexQuery.insert(chunk.map(({ waiter }) => ({
990
1039
  token: waiter.token,
991
1040
  run_id: waiter.runId,
@@ -999,7 +1048,7 @@ export class LucidStateStore {
999
1048
  const chunk = entries.slice(at, at + CHILD_WAIT_ROWS);
1000
1049
  await trx
1001
1050
  .insertQuery()
1002
- .table(DURABLE_TABLES.checkpoints)
1051
+ .table(this.t.checkpoints)
1003
1052
  .knexQuery.insert(chunk.map(({ checkpoint }) => checkpointToRow(checkpoint)))
1004
1053
  .onConflict(['run_id', 'seq'])
1005
1054
  .merge();
@@ -1009,7 +1058,7 @@ export class LucidStateStore {
1009
1058
  async compactCheckpoints(runId, fromSeq, toSeq, into) {
1010
1059
  await this.client().transaction(async (trx) => {
1011
1060
  await trx
1012
- .from(DURABLE_TABLES.checkpoints)
1061
+ .from(this.t.checkpoints)
1013
1062
  .where('run_id', runId)
1014
1063
  .andWhere('seq', '>=', fromSeq)
1015
1064
  .andWhere('seq', '<=', toSeq)
@@ -1022,7 +1071,7 @@ export class LucidStateStore {
1022
1071
  // primary key, so a later putSignalWaiter for the same token may have replaced the row with a
1023
1072
  // different run's waiter — the run_id/seq predicate leaves that one untouched. No-op if absent.
1024
1073
  await this.client()
1025
- .from(DURABLE_TABLES.signalWaiters)
1074
+ .from(this.t.signalWaiters)
1026
1075
  .where('token', waiter.token)
1027
1076
  .andWhere('run_id', waiter.runId)
1028
1077
  .andWhere('seq', waiter.seq)
@@ -1031,12 +1080,12 @@ export class LucidStateStore {
1031
1080
  // --- buffered signals (FIFO per token) ----------------------------------
1032
1081
  async bufferSignal(token, payload) {
1033
1082
  await this.client()
1034
- .table(DURABLE_TABLES.bufferedSignals)
1083
+ .table(this.t.bufferedSignals)
1035
1084
  .insert({ token, payload: payload === undefined ? null : JSON.stringify(payload) });
1036
1085
  }
1037
1086
  async listBufferedSignalTokens(prefix, limit) {
1038
1087
  const rows = (await this.client()
1039
- .from(DURABLE_TABLES.bufferedSignals)
1088
+ .from(this.t.bufferedSignals)
1040
1089
  .whereRaw(`token like ? escape '!'`, [`${escapeLike(prefix)}%`])
1041
1090
  .distinct('token')
1042
1091
  .orderBy('token', 'asc')
@@ -1045,7 +1094,7 @@ export class LucidStateStore {
1045
1094
  }
1046
1095
  async listBufferedSignals(tokenPrefix, afterId, limit) {
1047
1096
  const q = this.client()
1048
- .from(DURABLE_TABLES.bufferedSignals)
1097
+ .from(this.t.bufferedSignals)
1049
1098
  .whereRaw(`token like ? escape '!'`, [`${escapeLike(tokenPrefix)}%`]);
1050
1099
  if (afterId !== undefined)
1051
1100
  q.where('id', '>', Number(afterId));
@@ -1061,7 +1110,7 @@ export class LucidStateStore {
1061
1110
  async deleteBufferedSignals(ids) {
1062
1111
  for (let at = 0; at < ids.length; at += 500) {
1063
1112
  await this.client()
1064
- .from(DURABLE_TABLES.bufferedSignals)
1113
+ .from(this.t.bufferedSignals)
1065
1114
  .whereIn('id', ids.slice(at, at + 500).map((id) => Number(id)))
1066
1115
  .delete();
1067
1116
  }
@@ -1072,9 +1121,9 @@ export class LucidStateStore {
1072
1121
  const client = this.client();
1073
1122
  const hot = this.hot();
1074
1123
  const row = hot
1075
- ? (await hot.rows(OLDEST_BUFFERED_SIGNAL_SQL, [token]))[0]
1124
+ ? (await hot.rows(this.sql.oldestBufferedSignal, [token]))[0]
1076
1125
  : await client
1077
- .from(DURABLE_TABLES.bufferedSignals)
1126
+ .from(this.t.bufferedSignals)
1078
1127
  .where('token', token)
1079
1128
  .orderBy('id', 'asc')
1080
1129
  .first();
@@ -1082,8 +1131,8 @@ export class LucidStateStore {
1082
1131
  return null;
1083
1132
  const r = row;
1084
1133
  const deleted = hot
1085
- ? (await hot.run(DELETE_BUFFERED_SIGNAL_SQL, [r.id])).count
1086
- : rowsAffected(await client.from(DURABLE_TABLES.bufferedSignals).where('id', r.id).delete());
1134
+ ? (await hot.run(this.sql.deleteBufferedSignal, [r.id])).count
1135
+ : rowsAffected(await client.from(this.t.bufferedSignals).where('id', r.id).delete());
1087
1136
  if (deleted !== 1)
1088
1137
  return null;
1089
1138
  return {
@@ -1093,7 +1142,7 @@ export class LucidStateStore {
1093
1142
  // --- buffered events (name-keyed, match-based consumption) ---------------
1094
1143
  async bufferEvent(input) {
1095
1144
  await this.client()
1096
- .table(DURABLE_TABLES.bufferedEvents)
1145
+ .table(this.t.bufferedEvents)
1097
1146
  .insert({
1098
1147
  id: input.id,
1099
1148
  name: input.name,
@@ -1104,9 +1153,9 @@ export class LucidStateStore {
1104
1153
  async listBufferedEvents(name, limit) {
1105
1154
  const hot = this.hot();
1106
1155
  const rows = hot
1107
- ? await hot.rows(BUFFERED_EVENTS_SQL, [name, limit])
1156
+ ? await hot.rows(this.sql.bufferedEvents, [name, limit])
1108
1157
  : await this.client()
1109
- .from(DURABLE_TABLES.bufferedEvents)
1158
+ .from(this.t.bufferedEvents)
1110
1159
  .where('name', name)
1111
1160
  .orderBy('published_at', 'asc') // oldest first
1112
1161
  .limit(limit);
@@ -1123,7 +1172,7 @@ export class LucidStateStore {
1123
1172
  // IN subquery. The age predicate is repeated on the DELETE so a row re-published under the same
1124
1173
  // id meanwhile is left alone.
1125
1174
  const rows = (await this.client()
1126
- .from(DURABLE_TABLES.bufferedEvents)
1175
+ .from(this.t.bufferedEvents)
1127
1176
  .select('id')
1128
1177
  .where('published_at', '<', publishedBefore)
1129
1178
  .orderBy('published_at', 'asc')
@@ -1131,7 +1180,7 @@ export class LucidStateStore {
1131
1180
  if (rows.length === 0)
1132
1181
  return 0;
1133
1182
  const deleted = await this.client()
1134
- .from(DURABLE_TABLES.bufferedEvents)
1183
+ .from(this.t.bufferedEvents)
1135
1184
  .whereIn('id', rows.map((r) => r.id))
1136
1185
  .andWhere('published_at', '<', publishedBefore)
1137
1186
  .delete();
@@ -1140,10 +1189,7 @@ export class LucidStateStore {
1140
1189
  async removeBufferedEvent(id) {
1141
1190
  // Atomic claim: whoever's delete affects the row (returns 1) is the one that gets to deliver;
1142
1191
  // a concurrent claimant sees 0 and backs off. `id` is the primary key.
1143
- const deleted = await this.client()
1144
- .from(DURABLE_TABLES.bufferedEvents)
1145
- .where('id', id)
1146
- .delete();
1192
+ const deleted = await this.client().from(this.t.bufferedEvents).where('id', id).delete();
1147
1193
  return rowsAffected(deleted) === 1;
1148
1194
  }
1149
1195
  // --- transaction (exactly-once business write + checkpoint) -------------
@@ -1175,13 +1221,13 @@ export class LucidStateStore {
1175
1221
  const row = scheduleToRow(record);
1176
1222
  await this.client()
1177
1223
  .insertQuery()
1178
- .table(DURABLE_TABLES.schedules)
1224
+ .table(this.t.schedules)
1179
1225
  .knexQuery.insert(row)
1180
1226
  .onConflict('id')
1181
1227
  .merge();
1182
1228
  }
1183
1229
  async getSchedule(id) {
1184
- const row = await this.client().from(DURABLE_TABLES.schedules).where('id', id).first();
1230
+ const row = await this.client().from(this.t.schedules).where('id', id).first();
1185
1231
  return row ? rowToSchedule(row) : null;
1186
1232
  }
1187
1233
  /** One conditional UPDATE: the compare-and-set on `next_fire_at` lives in its WHERE clause. */
@@ -1205,7 +1251,7 @@ export class LucidStateStore {
1205
1251
  set.updated_at = patch.updatedAt.getTime();
1206
1252
  if (!Object.keys(set).length)
1207
1253
  return (await this.getSchedule(id)) !== null;
1208
- const q = this.client().from(DURABLE_TABLES.schedules).where('id', id);
1254
+ const q = this.client().from(this.t.schedules).where('id', id);
1209
1255
  if (expectedNextFireAt === null)
1210
1256
  q.whereNull('next_fire_at');
1211
1257
  else if (expectedNextFireAt !== undefined)
@@ -1213,11 +1259,11 @@ export class LucidStateStore {
1213
1259
  return rowsAffected(await q.update(set)) > 0;
1214
1260
  }
1215
1261
  async deleteSchedule(id) {
1216
- const affected = await this.client().from(DURABLE_TABLES.schedules).where('id', id).delete();
1262
+ const affected = await this.client().from(this.t.schedules).where('id', id).delete();
1217
1263
  return rowsAffected(affected) > 0;
1218
1264
  }
1219
1265
  async listSchedules(query) {
1220
- const q = this.client().from(DURABLE_TABLES.schedules);
1266
+ const q = this.client().from(this.t.schedules);
1221
1267
  if (query.namespace !== undefined) {
1222
1268
  q.whereIn('namespace', namespaceFilterValues(query.namespace));
1223
1269
  }
@@ -1300,7 +1346,7 @@ export class LucidStateStore {
1300
1346
  * exactly the set the list would show.
1301
1347
  */
1302
1348
  scopedRuns(query) {
1303
- const q = this.fenced(this.client().from(DURABLE_TABLES.runs));
1349
+ const q = this.fenced(this.client().from(this.t.runs));
1304
1350
  if (query.workflow)
1305
1351
  q.where('workflow', query.workflow);
1306
1352
  if (query.workflows) {
@@ -1404,10 +1450,10 @@ export class LucidStateStore {
1404
1450
  }
1405
1451
  q.whereExists((sub) => {
1406
1452
  sub
1407
- .from(DURABLE_TABLES.attributes)
1408
- .whereRaw(`${DURABLE_TABLES.attributes}.run_id = ${DURABLE_TABLES.runs}.id`)
1409
- .andWhere(`${DURABLE_TABLES.attributes}.key`, f.key)
1410
- .whereIn(`${DURABLE_TABLES.attributes}.${col}`, operands);
1453
+ .from(this.t.attributes)
1454
+ .whereRaw('?? = ??', [`${this.t.attributes}.run_id`, `${this.t.runs}.id`])
1455
+ .andWhere(`${this.t.attributes}.key`, f.key)
1456
+ .whereIn(`${this.t.attributes}.${col}`, operands);
1411
1457
  });
1412
1458
  return;
1413
1459
  }
@@ -1415,15 +1461,15 @@ export class LucidStateStore {
1415
1461
  const operand = attributeOperand(f);
1416
1462
  q.whereExists((sub) => {
1417
1463
  sub
1418
- .from(DURABLE_TABLES.attributes)
1419
- .whereRaw(`${DURABLE_TABLES.attributes}.run_id = ${DURABLE_TABLES.runs}.id`)
1420
- .andWhere(`${DURABLE_TABLES.attributes}.key`, f.key)
1421
- .andWhere(`${DURABLE_TABLES.attributes}.${col}`, cmp, operand);
1464
+ .from(this.t.attributes)
1465
+ .whereRaw('?? = ??', [`${this.t.attributes}.run_id`, `${this.t.runs}.id`])
1466
+ .andWhere(`${this.t.attributes}.key`, f.key)
1467
+ .andWhere(`${this.t.attributes}.${col}`, cmp, operand);
1422
1468
  });
1423
1469
  }
1424
1470
  async listCheckpoints(runId) {
1425
1471
  const rows = await this.client()
1426
- .from(DURABLE_TABLES.checkpoints)
1472
+ .from(this.t.checkpoints)
1427
1473
  .where('run_id', runId)
1428
1474
  .orderBy('seq', 'asc');
1429
1475
  return rows.map(rowToCheckpoint);
@@ -1431,9 +1477,9 @@ export class LucidStateStore {
1431
1477
  async listCheckpointsForReplay(runId) {
1432
1478
  const hot = this.hot();
1433
1479
  const rows = (hot
1434
- ? await hot.rows(REPLAY_CHECKPOINTS_SQL, [runId])
1480
+ ? await hot.rows(this.sql.replayCheckpoints, [runId])
1435
1481
  : await this.client()
1436
- .from(DURABLE_TABLES.checkpoints)
1482
+ .from(this.t.checkpoints)
1437
1483
  .where('run_id', runId)
1438
1484
  .orderBy('seq', 'asc')
1439
1485
  .select(...CHECKPOINT_COLUMNS_BUT_INPUT));
@@ -1446,7 +1492,7 @@ export class LucidStateStore {
1446
1492
  const inputs = new Map();
1447
1493
  for (let at = 0; at < unsettled.length; at += CHECKPOINT_BATCH) {
1448
1494
  const found = (await this.client()
1449
- .from(DURABLE_TABLES.checkpoints)
1495
+ .from(this.t.checkpoints)
1450
1496
  .where('run_id', runId)
1451
1497
  .whereIn('seq', unsettled.slice(at, at + CHECKPOINT_BATCH))
1452
1498
  .select('seq', 'input'));
@@ -1462,7 +1508,7 @@ export class LucidStateStore {
1462
1508
  }
1463
1509
  async getLatestCheckpointByName(runId, name) {
1464
1510
  const row = await this.client()
1465
- .from(DURABLE_TABLES.checkpoints)
1511
+ .from(this.t.checkpoints)
1466
1512
  .where('run_id', runId)
1467
1513
  .andWhere('name', name)
1468
1514
  .orderBy('seq', 'desc')
@@ -1473,7 +1519,7 @@ export class LucidStateStore {
1473
1519
  if (prefixes.length === 0)
1474
1520
  return [];
1475
1521
  const rows = await this.client()
1476
- .from(DURABLE_TABLES.checkpoints)
1522
+ .from(this.t.checkpoints)
1477
1523
  .where('run_id', runId)
1478
1524
  .andWhere((q) => {
1479
1525
  for (const p of prefixes)
@@ -1487,11 +1533,11 @@ export class LucidStateStore {
1487
1533
  return 0;
1488
1534
  const hot = this.hot();
1489
1535
  if (hot) {
1490
- const rows = await hot.rows(COUNT_CHECKPOINTS_SQL, [runId, fromSeq, toSeq, statuses]);
1536
+ const rows = await hot.rows(this.sql.countCheckpoints, [runId, fromSeq, toSeq, statuses]);
1491
1537
  return Number(rows[0]?.count ?? 0);
1492
1538
  }
1493
1539
  const rows = await this.client()
1494
- .from(DURABLE_TABLES.checkpoints)
1540
+ .from(this.t.checkpoints)
1495
1541
  .where('run_id', runId)
1496
1542
  .andWhere('seq', '>=', fromSeq)
1497
1543
  .andWhere('seq', '<=', toSeq)
@@ -1504,7 +1550,7 @@ export class LucidStateStore {
1504
1550
  if (statuses.length === 0)
1505
1551
  return false;
1506
1552
  const row = await this.client()
1507
- .from(DURABLE_TABLES.checkpoints)
1553
+ .from(this.t.checkpoints)
1508
1554
  .select('seq')
1509
1555
  .where('run_id', runId)
1510
1556
  .andWhere('seq', '>=', fromSeq)
@@ -1524,7 +1570,7 @@ export class LucidStateStore {
1524
1570
  const client = this.client();
1525
1571
  const kind = nameKindSql(client.dialect.name);
1526
1572
  const q = client
1527
- .from(DURABLE_TABLES.checkpoints)
1573
+ .from(this.t.checkpoints)
1528
1574
  .select('parallel_group as grp', 'status')
1529
1575
  .select(client.raw(`${kind} as name_kind`))
1530
1576
  .count('* as n')
@@ -1570,7 +1616,7 @@ export class LucidStateStore {
1570
1616
  if (query.statuses?.length === 0 || query.namePrefixes?.length === 0)
1571
1617
  return [];
1572
1618
  const client = this.client();
1573
- const q = client.from(DURABLE_TABLES.checkpoints).where('run_id', runId);
1619
+ const q = client.from(this.t.checkpoints).where('run_id', runId);
1574
1620
  if (query.group !== undefined)
1575
1621
  q.andWhere('parallel_group', query.group);
1576
1622
  if (query.excludeGroups?.length) {
@@ -1605,10 +1651,10 @@ export class LucidStateStore {
1605
1651
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
1606
1652
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
1607
1653
  async reindexAttributes(client, runId, attributes) {
1608
- await client.from(DURABLE_TABLES.attributes).where('run_id', runId).delete();
1654
+ await client.from(this.t.attributes).where('run_id', runId).delete();
1609
1655
  const rows = normalizeAttributeRows(runId, attributes);
1610
1656
  if (rows.length) {
1611
- await client.table(DURABLE_TABLES.attributes).multiInsert(rows.map((r) => ({
1657
+ await client.table(this.t.attributes).multiInsert(rows.map((r) => ({
1612
1658
  run_id: r.runId,
1613
1659
  key: r.key,
1614
1660
  str_value: r.strValue,
@@ -1704,20 +1750,20 @@ const CHECKPOINT_BATCH = 500;
1704
1750
  /** Most checkpoint rows one multi-row upsert writes (kept under every dialect's bind limit). */
1705
1751
  const CHECKPOINT_UPSERT_BATCH = 200;
1706
1752
  /** One multi-row `INSERT … ON CONFLICT (run_id, seq) DO UPDATE` (MySQL: `ON DUPLICATE KEY`). */
1707
- async function upsertCheckpointRows(client, checkpoints) {
1753
+ async function upsertCheckpointRows(client, checkpointsTable, checkpoints) {
1708
1754
  if (checkpoints.length === 0)
1709
1755
  return;
1710
1756
  await client
1711
1757
  .insertQuery()
1712
- .table(DURABLE_TABLES.checkpoints)
1758
+ .table(checkpointsTable)
1713
1759
  .knexQuery.insert(checkpoints.map(checkpointToRow))
1714
1760
  .onConflict(['run_id', 'seq'])
1715
1761
  .merge();
1716
1762
  }
1717
- async function updateCheckpointIfPending(client, checkpoint) {
1763
+ async function updateCheckpointIfPending(client, checkpointsTable, checkpoint) {
1718
1764
  const { run_id: _runId, seq: _seq, ...row } = checkpointToRow(checkpoint);
1719
1765
  const affected = await client
1720
- .from(DURABLE_TABLES.checkpoints)
1766
+ .from(checkpointsTable)
1721
1767
  .where('run_id', checkpoint.runId)
1722
1768
  .andWhere('seq', checkpoint.seq)
1723
1769
  .andWhere('status', 'pending')
@@ -1747,8 +1793,6 @@ function escapeLike(value) {
1747
1793
  return value.replace(/[!%_]/g, (c) => `!${c}`);
1748
1794
  }
1749
1795
  // --- the Postgres hot path's statements (see `lucid-pg-hot-path.ts`) --------------------------------
1750
- const RUNS_T = quote(DURABLE_TABLES.runs);
1751
- const CHECKPOINTS_T = quote(DURABLE_TABLES.checkpoints);
1752
1796
  const CHECKPOINT_LIST = CHECKPOINT_COLUMNS_BUT_INPUT.map(quote).join(', ');
1753
1797
  /**
1754
1798
  * The runs columns every schema has. The hot path names its columns instead of `*`: a named prepared
@@ -1794,65 +1838,69 @@ const CHECKPOINT_BASE_COLUMNS = [
1794
1838
  ...CHECKPOINT_COLUMNS_BUT_INPUT.filter((c) => !CHECKPOINT_OPTIONAL_COLUMNS.includes(c)),
1795
1839
  'input',
1796
1840
  ];
1797
- 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(?)`;
1841
+ /** The later-wave columns present, in the configured schema (`?` first) or the current one. */
1842
+ const COLUMNS_PRESENT_SQL = `select "table_name", "column_name" from information_schema.columns where "table_schema" = coalesce(?, current_schema()) and "table_name" = any(?) and "column_name" = any(?)`;
1798
1843
  const BUFFERED_SIGNAL_LIST = ['id', 'token', 'payload'].map(quote).join(', ');
1799
1844
  const BUFFERED_EVENT_LIST = ['id', 'name', 'payload', 'published_at'].map(quote).join(', ');
1800
- /** {@link HotReads} for the runs column list `cols` and checkpoints column list `checkpointCols`
1801
- * (each quoted, comma-joined). */
1802
- function hotReadSql(cols, checkpointCols) {
1803
- return {
1804
- getCheckpoint: shapedSql(`checkpoint-get:${checkpointCols}`, () => `select ${checkpointCols} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`),
1805
- get: shapedSql(`run-get:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? limit 1`),
1806
- getScoped: shapedSql(`run-get-scoped:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`),
1807
- 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}`),
1808
- 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}`),
1809
- };
1845
+ /** A quoted, schema-qualified table name for raw SQL: `"schema"."table"`, or `"table"`. */
1846
+ function quotedTable(schema, table) {
1847
+ return schema ? `${quote(schema)}.${quote(table)}` : quote(table);
1810
1848
  }
1811
- const RUN_IN_STATUSES_SQL = `select 1 from ${RUNS_T} where "id" = ? and "status" = any(?) limit 1`;
1812
- const RELEASE_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ?`;
1813
- const RELEASE_OWN_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ?`;
1814
- 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`;
1815
- const LOCKED_BY_SQL = `select "locked_by" from ${RUNS_T} where "id" = ? limit 1`;
1816
- const RENEW_LOCK_SQL = `update ${RUNS_T} set "locked_until" = ? where "id" = ? and "locked_by" = ?`;
1817
- const DELETE_ATTRIBUTES_SQL = `delete from ${quote(DURABLE_TABLES.attributes)} where "run_id" = ?`;
1818
- const CHECKPOINTS_FOR_SETTLE_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = any(?)`;
1819
- const REPLAY_CHECKPOINTS_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? order by "seq" asc`;
1820
- const COUNT_CHECKPOINTS_SQL = `select count(*) as "count" from ${CHECKPOINTS_T} where "run_id" = ? and "seq" >= ? and "seq" <= ? and "status" = any(?)`;
1821
- const OLDEST_BUFFERED_SIGNAL_SQL = `select ${BUFFERED_SIGNAL_LIST} from ${quote(DURABLE_TABLES.bufferedSignals)} where "token" = ? order by "id" asc limit 1`;
1822
- const DELETE_BUFFERED_SIGNAL_SQL = `delete from ${quote(DURABLE_TABLES.bufferedSignals)} where "id" = ?`;
1823
- const BUFFERED_EVENTS_SQL = `select ${BUFFERED_EVENT_LIST} from ${quote(DURABLE_TABLES.bufferedEvents)} where "name" = ? order by "published_at" asc limit ?`;
1824
- /** Statements whose column list is the row's own keys, built once per key set. */
1825
- const shaped = new Map();
1826
- function shapedSql(key, build) {
1827
- let text = shaped.get(key);
1828
- if (text === undefined) {
1829
- text = build();
1830
- shaped.set(key, text);
1831
- }
1832
- return text;
1833
- }
1834
- /** `INSERT` of one row of `table` with the columns `keys`. */
1835
- function insertSql(table, keys) {
1836
- return shapedSql(`insert:${table}:${keys.join(',')}`, () => {
1837
- const cols = keys.map(quote).join(', ');
1838
- return `insert into ${quote(table)} (${cols}) values (${keys.map(() => '?').join(', ')})`;
1839
- });
1840
- }
1841
- /** {@link insertSql} of a run that writes nothing when its id is taken, returning the id it wrote. */
1842
- function insertRunIfAbsentSql(keys) {
1843
- return shapedSql(`insert-run-if-absent:${keys.join(',')}`, () => `${insertSql(DURABLE_TABLES.runs, keys)} on conflict ("id") do nothing returning "id"`);
1844
- }
1845
- /** {@link insertSql} of a checkpoint that writes nothing when its position is taken. */
1846
- function insertCheckpointIfAbsentSql(keys) {
1847
- return shapedSql(`insert-if-absent:${keys.join(',')}`, () => `${insertSql(DURABLE_TABLES.checkpoints, keys)} on conflict ("run_id", "seq") do nothing returning "seq"`);
1848
- }
1849
- /** `UPDATE` of one run's columns `keys`, with a status predicate when `conditional`. */
1850
- function updateRunSql(keys, conditional) {
1851
- return shapedSql(`update-run:${conditional}:${keys.join(',')}`, () => {
1852
- const set = keys.map((k) => `${quote(k)} = ?`).join(', ');
1853
- const where = conditional ? `"id" = ? and "status" = any(?)` : `"id" = ?`;
1854
- return `update ${RUNS_T} set ${set} where ${where}`;
1855
- });
1849
+ const hotStatementsBySchema = new Map();
1850
+ /** {@link HotStatements} for `schema` (the connection's current schema when unset). */
1851
+ function hotStatements(schema) {
1852
+ const cacheKey = schema ?? '';
1853
+ const cached = hotStatementsBySchema.get(cacheKey);
1854
+ if (cached)
1855
+ return cached;
1856
+ const RUNS_T = quotedTable(schema, DURABLE_TABLES.runs);
1857
+ const CHECKPOINTS_T = quotedTable(schema, DURABLE_TABLES.checkpoints);
1858
+ const ATTRIBUTES_T = quotedTable(schema, DURABLE_TABLES.attributes);
1859
+ const SIGNALS_T = quotedTable(schema, DURABLE_TABLES.bufferedSignals);
1860
+ const EVENTS_T = quotedTable(schema, DURABLE_TABLES.bufferedEvents);
1861
+ /** Statements whose column list is the row's own keys, built once per key set. */
1862
+ const shaped = new Map();
1863
+ const shapedSql = (key, build) => {
1864
+ let text = shaped.get(key);
1865
+ if (text === undefined) {
1866
+ text = build();
1867
+ shaped.set(key, text);
1868
+ }
1869
+ return text;
1870
+ };
1871
+ /** `INSERT` of one row of `table` (quoted) with the columns `keys`. */
1872
+ const insertSql = (table, keys) => `insert into ${table} (${keys.map(quote).join(', ')}) values (${keys.map(() => '?').join(', ')})`;
1873
+ const statements = {
1874
+ reads: (cols, checkpointCols) => ({
1875
+ getCheckpoint: shapedSql(`checkpoint-get:${checkpointCols}`, () => `select ${checkpointCols} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`),
1876
+ get: shapedSql(`run-get:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? limit 1`),
1877
+ getScoped: shapedSql(`run-get-scoped:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`),
1878
+ 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}`),
1879
+ 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}`),
1880
+ }),
1881
+ runInStatuses: `select 1 from ${RUNS_T} where "id" = ? and "status" = any(?) limit 1`,
1882
+ releaseLock: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ?`,
1883
+ releaseOwnLock: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ?`,
1884
+ releaseUnlessWake: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ? and "wake_requested_at" is null`,
1885
+ lockedBy: `select "locked_by" from ${RUNS_T} where "id" = ? limit 1`,
1886
+ renewLock: `update ${RUNS_T} set "locked_until" = ? where "id" = ? and "locked_by" = ?`,
1887
+ deleteAttributes: `delete from ${ATTRIBUTES_T} where "run_id" = ?`,
1888
+ checkpointsForSettle: `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = any(?)`,
1889
+ replayCheckpoints: `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? order by "seq" asc`,
1890
+ countCheckpoints: `select count(*) as "count" from ${CHECKPOINTS_T} where "run_id" = ? and "seq" >= ? and "seq" <= ? and "status" = any(?)`,
1891
+ oldestBufferedSignal: `select ${BUFFERED_SIGNAL_LIST} from ${SIGNALS_T} where "token" = ? order by "id" asc limit 1`,
1892
+ deleteBufferedSignal: `delete from ${SIGNALS_T} where "id" = ?`,
1893
+ bufferedEvents: `select ${BUFFERED_EVENT_LIST} from ${EVENTS_T} where "name" = ? order by "published_at" asc limit ?`,
1894
+ insertRunIfAbsent: (keys) => shapedSql(`insert-run-if-absent:${keys.join(',')}`, () => `${insertSql(RUNS_T, keys)} on conflict ("id") do nothing returning "id"`),
1895
+ insertCheckpointIfAbsent: (keys) => shapedSql(`insert-if-absent:${keys.join(',')}`, () => `${insertSql(CHECKPOINTS_T, keys)} on conflict ("run_id", "seq") do nothing returning "seq"`),
1896
+ updateRun: (keys, conditional) => shapedSql(`update-run:${conditional}:${keys.join(',')}`, () => {
1897
+ const set = keys.map((k) => `${quote(k)} = ?`).join(', ');
1898
+ const where = conditional ? `"id" = ? and "status" = any(?)` : `"id" = ?`;
1899
+ return `update ${RUNS_T} set ${set} where ${where}`;
1900
+ }),
1901
+ };
1902
+ hotStatementsBySchema.set(cacheKey, statements);
1903
+ return statements;
1856
1904
  }
1857
1905
  /**
1858
1906
  * {@link LucidStateStore.settleResultsIfPending} on Postgres: one `UPDATE … FROM (VALUES …) …
@@ -1860,7 +1908,7 @@ function updateRunSql(keys, conditional) {
1860
1908
  * in the same statement, and read back with its `wake_at`. What a result does not carry keeps its
1861
1909
  * stored value: `output` unless it completed, `events` and `started_at` unless present.
1862
1910
  */
1863
- async function settleResultsReturningPg(client, hot, results) {
1911
+ async function settleResultsReturningPg(client, hot, schema, results) {
1864
1912
  const values = [];
1865
1913
  const bindings = [];
1866
1914
  for (const r of results) {
@@ -1874,7 +1922,7 @@ async function settleResultsReturningPg(client, hot, results) {
1874
1922
  const cols = CHECKPOINT_COLUMNS_BUT_INPUT.map((c) => `c.${c}`).join(', ');
1875
1923
  const statuses = SETTLEABLE_RUN_STATUSES.map(() => '?').join(', ');
1876
1924
  bindings.push(...SETTLEABLE_RUN_STATUSES);
1877
- const sql = `update ${DURABLE_TABLES.checkpoints} as c set
1925
+ const sql = `update ${quotedTable(schema, DURABLE_TABLES.checkpoints)} as c set
1878
1926
  status = v.status,
1879
1927
  output = case when v.status = 'completed' then v.output else c.output end,
1880
1928
  error = v.error,
@@ -1883,7 +1931,7 @@ async function settleResultsReturningPg(client, hot, results) {
1883
1931
  finished_at = v.finished_at
1884
1932
  from (values ${values.join(', ')})
1885
1933
  as v(run_id, seq, status, output, error, has_events, events, started_at, finished_at),
1886
- ${DURABLE_TABLES.runs} as r
1934
+ ${quotedTable(schema, DURABLE_TABLES.runs)} as r
1887
1935
  where c.run_id = v.run_id and c.seq = v.seq and c.status = 'pending'
1888
1936
  and r.id = c.run_id and r.status in (${statuses})
1889
1937
  returning ${cols}, r.status as run_status, r.wake_at as run_wake_at`;
@@ -1941,11 +1989,11 @@ function checkpointKey(runId, seq) {
1941
1989
  * so the database write lock is taken FIRST with a no-op write, and no other connection or process
1942
1990
  * settles a row between the read and the caller's writes.
1943
1991
  */
1944
- async function lockPendingCheckpoints(trx, entries, columns) {
1992
+ async function lockPendingCheckpoints(trx, checkpointsTable, entries, columns) {
1945
1993
  const first = entries[0];
1946
1994
  if (first && !locksRows(trx)) {
1947
1995
  await trx
1948
- .from(DURABLE_TABLES.checkpoints)
1996
+ .from(checkpointsTable)
1949
1997
  .where('run_id', first.runId)
1950
1998
  .andWhere('seq', first.seq)
1951
1999
  .andWhere('status', 'pending')
@@ -1962,7 +2010,7 @@ async function lockPendingCheckpoints(trx, entries, columns) {
1962
2010
  const seqs = [...seqSet];
1963
2011
  for (let at = 0; at < seqs.length; at += CHECKPOINT_BATCH) {
1964
2012
  const q = trx
1965
- .from(DURABLE_TABLES.checkpoints)
2013
+ .from(checkpointsTable)
1966
2014
  .where('run_id', runId)
1967
2015
  .whereIn('seq', seqs.slice(at, at + CHECKPOINT_BATCH))
1968
2016
  .andWhere('status', 'pending')