cairnq 0.7.0 → 0.9.0

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 (48) hide show
  1. package/README.md +69 -4
  2. package/dist/_protocol/migrations/postgres/0007_purge_status_index.sql +11 -0
  3. package/dist/_protocol/migrations/sqlite/0007_purge_status_index.sql +11 -0
  4. package/dist/_protocol/sql/postgres/get_status.sql +7 -0
  5. package/dist/_protocol/sql/postgres/get_status_by_key.sql +7 -0
  6. package/dist/_protocol/sql/postgres/purge.sql +8 -1
  7. package/dist/_protocol/sql/sqlite/get_status.sql +6 -0
  8. package/dist/_protocol/sql/sqlite/get_status_by_key.sql +7 -0
  9. package/dist/_protocol/sql/sqlite/purge.sql +7 -1
  10. package/dist/client.d.ts +32 -15
  11. package/dist/client.js +38 -12
  12. package/dist/context.d.ts +25 -0
  13. package/dist/context.js +33 -0
  14. package/dist/errors.d.ts +4 -2
  15. package/dist/errors.js +7 -4
  16. package/dist/index.d.ts +9 -5
  17. package/dist/index.js +4 -1
  18. package/dist/models.d.ts +14 -2
  19. package/dist/models.js +12 -1
  20. package/dist/retention.d.ts +15 -3
  21. package/dist/retention.js +36 -14
  22. package/dist/store/base.d.ts +118 -1
  23. package/dist/store/base.js +122 -20
  24. package/dist/store/pg-executor.d.ts +77 -0
  25. package/dist/store/pg-executor.js +26 -0
  26. package/dist/store/pg-pool.d.ts +16 -0
  27. package/dist/store/pg-pool.js +147 -0
  28. package/dist/store/postgres.d.ts +41 -13
  29. package/dist/store/postgres.js +135 -147
  30. package/dist/store/sqlite.js +20 -2
  31. package/dist/wait.d.ts +9 -4
  32. package/dist/wait.js +27 -13
  33. package/dist/worker.d.ts +6 -3
  34. package/dist/worker.js +6 -5
  35. package/package.json +6 -4
  36. package/src/client.ts +60 -21
  37. package/src/context.ts +37 -0
  38. package/src/errors.ts +7 -4
  39. package/src/index.ts +16 -4
  40. package/src/models.ts +24 -3
  41. package/src/retention.ts +45 -15
  42. package/src/store/base.ts +204 -9
  43. package/src/store/pg-executor.ts +90 -0
  44. package/src/store/pg-pool.ts +156 -0
  45. package/src/store/postgres.ts +144 -141
  46. package/src/store/sqlite.ts +23 -2
  47. package/src/wait.ts +35 -14
  48. package/src/worker.ts +8 -6
package/dist/retention.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validatePurgeInput } from "./store/base.js";
1
2
  /** Sweep every hour unless asked otherwise — often enough that a queue with a
2
3
  * day of retention never carries more than an hour of extra rows, rare enough
3
4
  * that the sweep is invisible next to the task traffic. */
@@ -31,18 +32,35 @@ export class RetentionSweeper {
31
32
  /** The loop itself, awaited by stop() so no purge outlives the store. */
32
33
  loop = null;
33
34
  intervalMs;
34
- purgeInput;
35
+ /** Rows per purge statement while draining — see DEFAULT_LIMIT. */
36
+ limit;
37
+ /** One purge per cutoff: a lone entry for a number, one per status for a map. */
38
+ purgeInputs;
35
39
  constructor(store, opts) {
36
40
  this.store = store;
37
41
  this.opts = opts;
38
- if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) {
39
- throw new Error(`retention.olderThanMs must be >= 0, got ${opts.olderThanMs}`);
40
- }
41
42
  this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
42
43
  if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
43
44
  throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
44
45
  }
45
- this.purgeInput = { olderThanMs: opts.olderThanMs, limit: opts.limit ?? DEFAULT_LIMIT };
46
+ this.limit = opts.limit ?? DEFAULT_LIMIT;
47
+ const cutoffs = typeof opts.olderThanMs === "number"
48
+ ? [[undefined, opts.olderThanMs]]
49
+ : Object.entries(opts.olderThanMs);
50
+ // An empty map retains nothing and sweeps nothing — almost certainly a bug
51
+ // upstream of this call, so refuse it rather than silently never purging.
52
+ if (!cutoffs.length) {
53
+ throw new Error("retention.olderThanMs must name at least one status");
54
+ }
55
+ this.purgeInputs = cutoffs.map(([status, ms]) => ({
56
+ olderThanMs: ms,
57
+ status,
58
+ limit: this.limit,
59
+ }));
60
+ // Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
61
+ // the sweep runs an hour from now, and its errors only surface via onError.
62
+ for (const input of this.purgeInputs)
63
+ validatePurgeInput(input);
46
64
  }
47
65
  start() {
48
66
  if (this.active)
@@ -86,17 +104,21 @@ export class RetentionSweeper {
86
104
  * demand — after a backfill, or from a maintenance command.
87
105
  */
88
106
  async sweep() {
89
- const limit = this.purgeInput.limit;
90
107
  let deleted = 0;
91
- for (;;) {
92
- const ids = await this.store.purge(this.purgeInput);
93
- deleted += ids.length;
94
- if (ids.length < limit || this.stopping)
95
- return deleted;
96
- // Hand the loop back between batches: a large drain must not starve the
97
- // submits and claims sharing this process.
98
- await this.sleep(0);
108
+ for (const input of this.purgeInputs) {
109
+ for (;;) {
110
+ const ids = await this.store.purge(input);
111
+ deleted += ids.length;
112
+ if (this.stopping)
113
+ return deleted;
114
+ if (ids.length < this.limit)
115
+ break;
116
+ // Hand the loop back between batches: a large drain must not starve the
117
+ // submits and claims sharing this process.
118
+ await this.sleep(0);
119
+ }
99
120
  }
121
+ return deleted;
100
122
  }
101
123
  /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
102
124
  * pending sweep must never be the reason a process refuses to exit. */
@@ -1,4 +1,4 @@
1
- import { type Task, type TaskStatus } from "../models.js";
1
+ import { type Task, type TaskRef, type TaskStatus } from "../models.js";
2
2
  import { type BackpressureOptions } from "../backpressure.js";
3
3
  /** Encode a value for a protocol JSON column, raising SerializationError on
4
4
  * anything JSON cannot represent. Refuses what JSON.stringify would silently
@@ -39,8 +39,19 @@ export interface ListInput {
39
39
  limit?: number;
40
40
  offset?: number;
41
41
  }
42
+ /** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
43
+ * construction on the same rules an hourly sweep would otherwise only surface
44
+ * through its onError hook — one statement of the rules, two callers. */
45
+ export declare function validatePurgeInput(input: PurgeInput): void;
42
46
  export interface PurgeInput {
43
47
  olderThanMs?: number;
48
+ /** Restrict the sweep to one terminal status. Retention needs are tiered —
49
+ * succeeded rows are spent once their result is consumed, failed ones are
50
+ * worth keeping for diagnosis — and without this the shortest-lived tier
51
+ * sets the retention for every row. Absent means all terminal statuses. */
52
+ status?: TaskStatus;
53
+ /** Restrict the sweep to one task name. Absent means all names. */
54
+ name?: string;
44
55
  limit?: number;
45
56
  }
46
57
  export type Params = Record<string, unknown>;
@@ -72,6 +83,32 @@ export declare function statementParams(sql: string): readonly string[];
72
83
  * them in one place is what stops SQLite and Postgres from drifting apart in
73
84
  * behavior; the shared SQL already stops them from drifting in wording.
74
85
  */
86
+ /**
87
+ * Why `watch` is calling back.
88
+ *
89
+ * `queued` / `done` come from the store's push channel and name what moved;
90
+ * `poll` is the timer saying the watch cannot rule out a change. None of them
91
+ * carries state — the row is the truth.
92
+ */
93
+ export interface WatchSignal {
94
+ reason: "queued" | "done" | "poll";
95
+ /** The queue a task was queued on. Only on `queued`. */
96
+ queue?: string;
97
+ /** The task that reached a terminal status. Only on `done`. */
98
+ taskId?: string;
99
+ }
100
+ export interface WatchOptions {
101
+ /** Restrict `queued` signals to these queues. Unset watches every queue. */
102
+ queues?: string[];
103
+ /**
104
+ * How often to signal in the absence of a push channel — and, where there is
105
+ * one, how long a dropped listener can go unnoticed. The default trades a
106
+ * dashboard's idle query rate against how stale it may look.
107
+ */
108
+ pollMs?: number;
109
+ }
110
+ /** See WatchOptions.pollMs. */
111
+ export declare const DEFAULT_WATCH_POLL_MS = 2000;
75
112
  export declare abstract class TaskStore {
76
113
  /** Set by useBackpressure; null means submit is ungated. */
77
114
  private gate;
@@ -89,6 +126,34 @@ export declare abstract class TaskStore {
89
126
  * build ids and payloads before opening the transaction, not within it.
90
127
  */
91
128
  protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
129
+ /**
130
+ * `tx`, but also handing `fn` the driver's own session so the CALLER can run
131
+ * their statements in the same transaction as the protocol's.
132
+ *
133
+ * This is what lets a task's settlement and the rows that task produced commit
134
+ * together. Without it the two are separate transactions and there is a window
135
+ * where the work is durable but the task still reads as unfinished — a crash
136
+ * there costs a full recomputation on retry, and for non-idempotent work costs
137
+ * more than that.
138
+ *
139
+ * Optional, because the session type is the driver's, not the protocol's: a
140
+ * store that has no session worth handing out simply does not implement it and
141
+ * `completeIn` reports that. Postgres implements it; SQLite does not.
142
+ */
143
+ protected txWithSession?<T>(fn: (fetch: Fetch, session: unknown) => Promise<T>): Promise<T>;
144
+ /**
145
+ * Register for this store's push channel, if it has one; returns an
146
+ * unsubscribe. A store without a push channel does not implement this, and
147
+ * `watch` degrades to its timer alone.
148
+ */
149
+ protected subscribePush?(onSignal: (signal: WatchSignal) => void): () => void;
150
+ /**
151
+ * Nudge the push channel back up if it has dropped. Called from `watch`'s
152
+ * timer, which is the only thing keeping a client-side subscriber alive: a
153
+ * process that never claims never calls claimWake, so without this a listener
154
+ * that died once would never come back there.
155
+ */
156
+ protected warmPush?(): void;
92
157
  /**
93
158
  * Whether it is worth opening the claim transaction at all. SQLite gates its
94
159
  * single write lock behind a read-only probe; Postgres readers don't block
@@ -109,6 +174,7 @@ export declare abstract class TaskStore {
109
174
  */
110
175
  private ownedWrite;
111
176
  private static one;
177
+ private static oneRef;
112
178
  /**
113
179
  * Bound how deep a queue may get before `submit` blocks. Off unless set.
114
180
  *
@@ -121,6 +187,12 @@ export declare abstract class TaskStore {
121
187
  submit(input: SubmitInput): Promise<Task>;
122
188
  get(taskId: string): Promise<Task | null>;
123
189
  getByKey(key: string): Promise<Task | null>;
190
+ /** The wait loop's probe: id + status alone, so polling a task with a large
191
+ * payload does not re-read and re-parse that payload on every beat. */
192
+ getStatus(taskId: string): Promise<TaskRef | null>;
193
+ /** getStatus, following a key instead of an id — re-resolved per call, so a
194
+ * `replace` moves the probe onto the new task. */
195
+ getStatusByKey(key: string): Promise<TaskRef | null>;
124
196
  list(input?: ListInput): Promise<Task[]>;
125
197
  cancel(taskId: string): Promise<Task | null>;
126
198
  retry(taskId: string, opts?: {
@@ -151,6 +223,28 @@ export declare abstract class TaskStore {
151
223
  * them.
152
224
  */
153
225
  stats(): Promise<Record<string, Record<TaskStatus, number>>>;
226
+ /**
227
+ * Call `onSignal` when the tasks on `queues` may have changed — something was
228
+ * queued, or something finished.
229
+ *
230
+ * This is notify-ACCELERATED POLLING, not an event log, and the difference is
231
+ * the whole contract. Where a push channel is available (Postgres LISTEN) an
232
+ * idle watch costs nothing and a signal arrives within milliseconds of the
233
+ * event. Where it is not — a transaction-mode pooler refuses LISTEN, SQLite has
234
+ * no channel at all — the timer alone still delivers `poll` signals, so a
235
+ * consumer that re-reads on every signal is correct in both cases and merely
236
+ * less prompt in one.
237
+ *
238
+ * What it will NOT do is promise that a signal means something happened, or
239
+ * that every event produces its own signal. Treat a signal as "re-read now"
240
+ * and take the truth from `stats()` / `list()` / `get()`, which is where it
241
+ * lives. `reason` is a hint for reading less: a `done` signal names the task,
242
+ * so a dashboard can refresh that row instead of the list.
243
+ *
244
+ * Returns an unsubscribe. The timer is unref'd — watching does not hold a
245
+ * process open.
246
+ */
247
+ watch(opts: WatchOptions, onSignal: (signal: WatchSignal) => void): () => void;
154
248
  /**
155
249
  * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
156
250
  *
@@ -244,6 +338,29 @@ export declare abstract class TaskStore {
244
338
  workerId: string;
245
339
  result: unknown;
246
340
  }): Promise<Task>;
341
+ /**
342
+ * `complete`, with the caller's own writes committed in the same transaction.
343
+ *
344
+ * `fn` runs first and whatever it returns becomes the task's result; the
345
+ * settlement is the last statement in the transaction. So a lost lease — the
346
+ * settlement matching no row — rolls the caller's writes back with it, and
347
+ * there is no ordering in which the work is recorded but the task is not.
348
+ *
349
+ * The settlement runs LAST rather than checking ownership up front on purpose:
350
+ * the ownership predicate lives in the protocol's complete.sql, and a
351
+ * fail-fast pre-check here would be a second copy of it, free to drift. The
352
+ * cost of that choice is that a doomed attempt does its work before finding
353
+ * out, which is the rare path.
354
+ *
355
+ * `fn` must be replayable for the same reason `tx`'s callback must be.
356
+ */
357
+ completeIn<S, T>(input: {
358
+ taskId: string;
359
+ workerId: string;
360
+ }, fn: (session: S) => Promise<T>): Promise<{
361
+ task: Task;
362
+ value: T;
363
+ }>;
247
364
  fail(input: {
248
365
  taskId: string;
249
366
  workerId: string;
@@ -1,6 +1,6 @@
1
1
  import { newId } from "../ids.js";
2
2
  import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch, SerializationError, } from "../errors.js";
3
- import { rowToTask, STATUSES, TERMINAL } from "../models.js";
3
+ import { isTerminalStatus, rowToRef, rowToTask, STATUSES, } from "../models.js";
4
4
  import { QueueDepthGate } from "../backpressure.js";
5
5
  const rejectMangled = function (_key, v) {
6
6
  if (typeof v === "number" && !Number.isFinite(v)) {
@@ -65,13 +65,29 @@ const CONFLICTS = ["reuse", "reuse-succeeded", "reject", "replace"];
65
65
  function reusable(conflict, status) {
66
66
  if (conflict === "replace")
67
67
  return false;
68
- if (!TERMINAL.includes(status))
68
+ if (!isTerminalStatus(status))
69
69
  return true;
70
70
  return conflict === "reuse-succeeded" && status === "succeeded";
71
71
  }
72
72
  /** The queue a submit lands on when it names none. Owned here, where the
73
73
  * default is applied, so nothing above has to re-derive it. */
74
74
  export const DEFAULT_QUEUE = "default";
75
+ /** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
76
+ * construction on the same rules an hourly sweep would otherwise only surface
77
+ * through its onError hook — one statement of the rules, two callers. */
78
+ export function validatePurgeInput(input) {
79
+ if (input.olderThanMs != null && (!Number.isFinite(input.olderThanMs) || input.olderThanMs < 0)) {
80
+ throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
81
+ }
82
+ if (input.limit != null && input.limit < 1) {
83
+ throw new Error(`limit must be >= 1, got ${input.limit}`);
84
+ }
85
+ // Terminal only: purge never deletes live work, so accepting `queued` here
86
+ // would be accepting a filter that silently matches nothing.
87
+ if (input.status != null && !isTerminalStatus(input.status)) {
88
+ throw new Error(`status must be terminal, got ${input.status}`);
89
+ }
90
+ }
75
91
  export const LEASE_EXPIRED_ERROR_JSON = dumpJson(errorEnvelope({
76
92
  type: "LeaseExpired",
77
93
  code: "lease_expired",
@@ -106,17 +122,8 @@ export function statementParams(sql) {
106
122
  }
107
123
  return names;
108
124
  }
109
- /**
110
- * The storage seam.
111
- *
112
- * A backend supplies three things: how to run one protocol statement, how to run
113
- * several inside a transaction, and how its dialect binds parameters. Everything
114
- * above that — the submit conflict branches, the *_by_key lookups, the
115
- * recover-then-claim sequence, the ownership-checked writes — lives here once,
116
- * because those are protocol decisions rather than storage decisions. Keeping
117
- * them in one place is what stops SQLite and Postgres from drifting apart in
118
- * behavior; the shared SQL already stops them from drifting in wording.
119
- */
125
+ /** See WatchOptions.pollMs. */
126
+ export const DEFAULT_WATCH_POLL_MS = 2_000;
120
127
  export class TaskStore {
121
128
  /** Set by useBackpressure; null means submit is ungated. */
122
129
  gate = null;
@@ -159,6 +166,9 @@ export class TaskStore {
159
166
  static one(rows) {
160
167
  return rows.length ? rowToTask(rows[0]) : null;
161
168
  }
169
+ static oneRef(rows) {
170
+ return rows.length ? rowToRef(rows[0]) : null;
171
+ }
162
172
  // ------------------------------------------------------------- client side
163
173
  /**
164
174
  * Bound how deep a queue may get before `submit` blocks. Off unless set.
@@ -232,7 +242,7 @@ export class TaskStore {
232
242
  // fresh one inserted below. Cancel only what is still live: a terminal
233
243
  // task has nothing to stop, and cancelling it would rewrite a settled
234
244
  // row (and hand a `canceled` back to whoever is waiting on it).
235
- if (!TERMINAL.includes(current.status)) {
245
+ if (!isTerminalStatus(current.status)) {
236
246
  await fetch("cancel", { id: existing[0].task_id });
237
247
  }
238
248
  }
@@ -248,6 +258,16 @@ export class TaskStore {
248
258
  async getByKey(key) {
249
259
  return TaskStore.one(await this.fetch("get_by_key", { key }));
250
260
  }
261
+ /** The wait loop's probe: id + status alone, so polling a task with a large
262
+ * payload does not re-read and re-parse that payload on every beat. */
263
+ async getStatus(taskId) {
264
+ return TaskStore.oneRef(await this.fetch("get_status", { id: taskId }));
265
+ }
266
+ /** getStatus, following a key instead of an id — re-resolved per call, so a
267
+ * `replace` moves the probe onto the new task. */
268
+ async getStatusByKey(key) {
269
+ return TaskStore.oneRef(await this.fetch("get_status_by_key", { key }));
270
+ }
251
271
  async list(input = {}) {
252
272
  // Validate up front, like submit's conflict guard: a typo'd status otherwise
253
273
  // matches nothing and returns [] indistinguishably from "no such tasks".
@@ -302,14 +322,11 @@ export class TaskStore {
302
322
  * call it in a loop until it returns fewer than `limit`.
303
323
  */
304
324
  async purge(input = {}) {
305
- if (input.olderThanMs != null && input.olderThanMs < 0) {
306
- throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
307
- }
308
- if (input.limit != null && input.limit < 1) {
309
- throw new Error(`limit must be >= 1, got ${input.limit}`);
310
- }
325
+ validatePurgeInput(input);
311
326
  const rows = await this.fetch("purge", {
312
327
  older_than_ms: input.olderThanMs ?? 0,
328
+ status: input.status ?? null,
329
+ name: input.name ?? null,
313
330
  limit: input.limit ?? 1_000,
314
331
  });
315
332
  return rows.map((r) => r.id);
@@ -328,6 +345,57 @@ export class TaskStore {
328
345
  }
329
346
  return out;
330
347
  }
348
+ /**
349
+ * Call `onSignal` when the tasks on `queues` may have changed — something was
350
+ * queued, or something finished.
351
+ *
352
+ * This is notify-ACCELERATED POLLING, not an event log, and the difference is
353
+ * the whole contract. Where a push channel is available (Postgres LISTEN) an
354
+ * idle watch costs nothing and a signal arrives within milliseconds of the
355
+ * event. Where it is not — a transaction-mode pooler refuses LISTEN, SQLite has
356
+ * no channel at all — the timer alone still delivers `poll` signals, so a
357
+ * consumer that re-reads on every signal is correct in both cases and merely
358
+ * less prompt in one.
359
+ *
360
+ * What it will NOT do is promise that a signal means something happened, or
361
+ * that every event produces its own signal. Treat a signal as "re-read now"
362
+ * and take the truth from `stats()` / `list()` / `get()`, which is where it
363
+ * lives. `reason` is a hint for reading less: a `done` signal names the task,
364
+ * so a dashboard can refresh that row instead of the list.
365
+ *
366
+ * Returns an unsubscribe. The timer is unref'd — watching does not hold a
367
+ * process open.
368
+ */
369
+ watch(opts, onSignal) {
370
+ const pollMs = Math.max(1, opts.pollMs ?? DEFAULT_WATCH_POLL_MS);
371
+ const queues = opts.queues ?? null;
372
+ let live = true;
373
+ const emit = (signal) => {
374
+ // A signal delivered after unsubscribe would have the consumer re-reading
375
+ // a store it has stopped caring about, possibly a closed one.
376
+ if (live)
377
+ onSignal(signal);
378
+ };
379
+ const unsubscribe = this.subscribePush?.((signal) => {
380
+ // A queued signal names its queue, so a watch scoped to some queues can
381
+ // drop the rest. A done signal names only the task — which queue it was on
382
+ // is not in the notification, so it is never filtered out.
383
+ if (signal.reason === "queued" && queues && signal.queue && !queues.includes(signal.queue)) {
384
+ return;
385
+ }
386
+ emit(signal);
387
+ });
388
+ const timer = setInterval(() => {
389
+ this.warmPush?.();
390
+ emit({ reason: "poll" });
391
+ }, pollMs);
392
+ timer.unref?.();
393
+ return () => {
394
+ live = false;
395
+ unsubscribe?.();
396
+ clearInterval(timer);
397
+ };
398
+ }
331
399
  /**
332
400
  * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
333
401
  *
@@ -478,6 +546,40 @@ export class TaskStore {
478
546
  result: input.result == null ? null : dumpJson(input.result),
479
547
  });
480
548
  }
549
+ /**
550
+ * `complete`, with the caller's own writes committed in the same transaction.
551
+ *
552
+ * `fn` runs first and whatever it returns becomes the task's result; the
553
+ * settlement is the last statement in the transaction. So a lost lease — the
554
+ * settlement matching no row — rolls the caller's writes back with it, and
555
+ * there is no ordering in which the work is recorded but the task is not.
556
+ *
557
+ * The settlement runs LAST rather than checking ownership up front on purpose:
558
+ * the ownership predicate lives in the protocol's complete.sql, and a
559
+ * fail-fast pre-check here would be a second copy of it, free to drift. The
560
+ * cost of that choice is that a doomed attempt does its work before finding
561
+ * out, which is the rare path.
562
+ *
563
+ * `fn` must be replayable for the same reason `tx`'s callback must be.
564
+ */
565
+ async completeIn(input, fn) {
566
+ if (!this.txWithSession) {
567
+ throw new Error("this store cannot share a transaction with the caller — " +
568
+ "completeIn requires a Postgres store (see PgExecutor)");
569
+ }
570
+ return this.txWithSession(async (fetch, session) => {
571
+ const value = await fn(session);
572
+ const rows = await fetch("complete", {
573
+ id: input.taskId,
574
+ worker_id: input.workerId,
575
+ result: value == null ? null : dumpJson(value),
576
+ });
577
+ // Rolls back `fn`'s writes along with the settlement that did not land.
578
+ if (!rows.length)
579
+ throw new LostLease(input.taskId);
580
+ return { task: rowToTask(rows[0]), value };
581
+ });
582
+ }
481
583
  async fail(input) {
482
584
  let error;
483
585
  try {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The seam between PostgresStore and whatever actually talks to Postgres.
3
+ *
4
+ * PostgresStore owns the *dialect* — the protocol's named-parameter SQL rewritten
5
+ * to `$n`, the migration ledger, the LISTEN policy. It does not own the
6
+ * *connection*. Applications that already run a Postgres driver (an ORM, a pool
7
+ * they size themselves) pass their own executor and cairnq joins that session
8
+ * instead of opening a second one, which is what makes a task's settlement
9
+ * commit in the same transaction as the rows the task produced.
10
+ *
11
+ * Implementing one is small — see `createPoolExecutor` below for the reference
12
+ * implementation over `pg`, and PROTOCOL.md for the two things an adapter must
13
+ * get right that are easy to miss: int8 must come back as a JS number (every
14
+ * cairnq bigint is an epoch-ms or a counter, all inside the safe range), and
15
+ * jsonb must come back as an object, not a string.
16
+ */
17
+ /** A row as the driver hands it back: column name -> value. */
18
+ export type Row = Record<string, unknown>;
19
+ /**
20
+ * Somewhere statements can run. The same shape whether it is a pool (each call
21
+ * on some connection) or one transaction's dedicated connection — the store's
22
+ * statements do not care, and this is what lets `tx` hand the same interface to
23
+ * its callback.
24
+ */
25
+ export interface PgSession {
26
+ /**
27
+ * One parameterised statement, `$1`-style. `values` is positional and may
28
+ * legitimately contain nulls — a null parameter is "this filter is off" in
29
+ * several protocol statements, not a missing argument.
30
+ */
31
+ query(text: string, values: readonly unknown[]): Promise<Row[]>;
32
+ /**
33
+ * Parameterless SQL that may hold several statements, for migration DDL.
34
+ * Separate from `query` because it must go over the simple query protocol:
35
+ * the extended protocol a parameterised call uses accepts only one statement,
36
+ * and every migration is a script.
37
+ */
38
+ exec(sql: string): Promise<void>;
39
+ }
40
+ /** A session that can also open transactions, listen, and be shut down. */
41
+ export interface PgExecutor extends PgSession {
42
+ /**
43
+ * Run `fn` inside one transaction on one dedicated connection, committing if
44
+ * it returns and rolling back if it throws. The store relies on both halves:
45
+ * a claim that cannot see its own `recover_leases` is a double-dispatch, and a
46
+ * keyed submit that commits half way poisons the key.
47
+ */
48
+ tx<T>(fn: (session: PgSession) => Promise<T>): Promise<T>;
49
+ /**
50
+ * Subscribe a dedicated connection to `channels`. Optional: an executor that
51
+ * omits it (or a Postgres that refuses LISTEN) costs latency, never
52
+ * correctness — the store falls back to plain polling, which is the contract
53
+ * PROTOCOL.md gives for push wakeups.
54
+ *
55
+ * Resolves with a function that stops listening. `onClose` reports a
56
+ * connection that dropped on its own, so the store can degrade and retry.
57
+ *
58
+ * Throw `ListenUnavailable` when this Postgres will never accept LISTEN — a
59
+ * transaction-mode pooler, say. Any other rejection is read as transient and
60
+ * retried with backoff, so a permanent condition raised as a plain Error
61
+ * becomes a reconnect loop that cannot succeed.
62
+ */
63
+ listen?(channels: readonly string[], onNotify: (channel: string, payload: string | undefined) => void, onClose: () => void): Promise<() => void>;
64
+ /**
65
+ * Release this executor's resources. Called by PostgresStore.close() ONLY for
66
+ * an executor the store created itself: an injected one belongs to the caller,
67
+ * whose other work would not survive cairnq closing it.
68
+ */
69
+ close(): Promise<void>;
70
+ }
71
+ /**
72
+ * LISTEN will not work on this connection, and retrying cannot change that.
73
+ * See `PgExecutor.listen`.
74
+ */
75
+ export declare class ListenUnavailable extends Error {
76
+ constructor(message?: string);
77
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The seam between PostgresStore and whatever actually talks to Postgres.
3
+ *
4
+ * PostgresStore owns the *dialect* — the protocol's named-parameter SQL rewritten
5
+ * to `$n`, the migration ledger, the LISTEN policy. It does not own the
6
+ * *connection*. Applications that already run a Postgres driver (an ORM, a pool
7
+ * they size themselves) pass their own executor and cairnq joins that session
8
+ * instead of opening a second one, which is what makes a task's settlement
9
+ * commit in the same transaction as the rows the task produced.
10
+ *
11
+ * Implementing one is small — see `createPoolExecutor` below for the reference
12
+ * implementation over `pg`, and PROTOCOL.md for the two things an adapter must
13
+ * get right that are easy to miss: int8 must come back as a JS number (every
14
+ * cairnq bigint is an epoch-ms or a counter, all inside the safe range), and
15
+ * jsonb must come back as an object, not a string.
16
+ */
17
+ /**
18
+ * LISTEN will not work on this connection, and retrying cannot change that.
19
+ * See `PgExecutor.listen`.
20
+ */
21
+ export class ListenUnavailable extends Error {
22
+ constructor(message = "LISTEN is not available on this connection") {
23
+ super(message);
24
+ this.name = "ListenUnavailable";
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ import { type PgExecutor } from "./pg-executor.js";
2
+ /**
3
+ * The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
4
+ * uses, and the reference for what an adapter over another driver must do.
5
+ *
6
+ * Creating it does not connect — `pg.Pool` is lazy, and the store's first
7
+ * statement (the migration ledger) is what proves the database is reachable.
8
+ *
9
+ * `schema` puts cairnq's tables in a schema of their own rather than in whatever
10
+ * the connection's search_path leads with. The protocol's SQL names no schema, so
11
+ * this is a per-connection `search_path` and not one statement changes.
12
+ */
13
+ export declare function createPoolExecutor(dsn: string, opts?: {
14
+ max?: number;
15
+ schema?: string;
16
+ }): Promise<PgExecutor>;