cairnq 0.8.0 → 0.10.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.
@@ -0,0 +1,157 @@
1
+ import type * as PG from "pg";
2
+
3
+ import { ListenUnavailable, type PgExecutor, type PgSession, type Row } from "./pg-executor.js";
4
+
5
+ // `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
6
+ // the first time a pool-backed executor is built. Absent -> a clear install hint.
7
+ // An application that brings its own executor never reaches this.
8
+ let pgModule: typeof import("pg") | null = null;
9
+ async function loadPg(): Promise<typeof import("pg")> {
10
+ if (pgModule) return pgModule;
11
+ let mod: { default?: typeof import("pg") } & typeof import("pg");
12
+ try {
13
+ mod = (await import("pg")) as never;
14
+ } catch {
15
+ throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
16
+ }
17
+ const pg = (mod.default ?? mod) as typeof import("pg");
18
+ // Deliberately NOT setting a global int8 type parser here. It would be the
19
+ // shortest fix for Postgres sending bigint as text, and it is what this file
20
+ // used to do — but pg's type parsers are process-global, so a library that
21
+ // installs one silently changes how the APPLICATION's own bigint columns come
22
+ // back. rowToTask normalizes instead, which costs nothing and leaves the
23
+ // caller's driver as they configured it.
24
+ pgModule = pg;
25
+ return pg;
26
+ }
27
+
28
+ /**
29
+ * Roll back on the way out of a failed transaction, without letting the rollback
30
+ * become the error the caller sees. A dropped connection fails both the statement
31
+ * and the rollback, and it is the first one that says what went wrong.
32
+ */
33
+ async function rollbackQuietly(client: PG.PoolClient): Promise<void> {
34
+ try {
35
+ await client.query("rollback");
36
+ } catch {
37
+ // Already rolled back, or the connection is gone. Either way the original
38
+ // error is the one worth propagating.
39
+ }
40
+ }
41
+
42
+ /** The PgSession face of one pg client or pool. */
43
+ function session(q: Pick<PG.PoolClient, "query">): PgSession {
44
+ return {
45
+ async query(text: string, values: readonly unknown[]): Promise<Row[]> {
46
+ return (await q.query(text, values as unknown[])).rows;
47
+ },
48
+ async exec(sql: string): Promise<void> {
49
+ // No values: pg sends this over the simple query protocol, which is what
50
+ // makes a multi-statement migration script legal here and not in query().
51
+ await q.query(sql);
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * A Postgres identifier that is safe to interpolate — cairnq quotes the schema
58
+ * name, and a name that could close that quote could rewrite the statement.
59
+ * Deliberately narrower than what Postgres accepts: a schema cairnq is asked to
60
+ * live in is a deployment decision, not a place to be clever.
61
+ */
62
+ const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
63
+
64
+ /**
65
+ * The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
66
+ * uses, and the reference for what an adapter over another driver must do.
67
+ *
68
+ * Creating it does not connect — `pg.Pool` is lazy, and the store's first
69
+ * statement (the migration ledger) is what proves the database is reachable.
70
+ *
71
+ * `schema` puts cairnq's tables in a schema of their own rather than in whatever
72
+ * the connection's search_path leads with. The protocol's SQL names no schema, so
73
+ * this is a per-connection `search_path` and not one statement changes.
74
+ */
75
+ export async function createPoolExecutor(
76
+ dsn: string,
77
+ opts: { max?: number; schema?: string } = {},
78
+ ): Promise<PgExecutor> {
79
+ const pg = await loadPg();
80
+ const schema = opts.schema;
81
+ if (schema !== undefined && !PLAIN_IDENT.test(schema)) {
82
+ throw new Error(
83
+ `schema must be a plain identifier (letters, digits, _ and $, not starting ` +
84
+ `with a digit), got ${JSON.stringify(schema)}`,
85
+ );
86
+ }
87
+ const pool = new pg.Pool({ connectionString: dsn, max: opts.max });
88
+ if (schema) {
89
+ // Queued on the connection before it is handed out, so every statement this
90
+ // pool ever runs — migrations included — resolves in the right schema. Pooled
91
+ // connections come and go, which is why this is per-connection rather than a
92
+ // one-off at startup.
93
+ pool.on("connect", (client) => {
94
+ void client.query(`set search_path to "${schema}"`);
95
+ });
96
+ }
97
+ if (schema) {
98
+ // Created once, here, rather than from the connect handler (which would ask
99
+ // for CREATE privilege on every new connection) or from the migrations
100
+ // (which name no schema, by design). Without it the first `create table`
101
+ // fails with "no schema has been selected to create in", which says nothing
102
+ // about the cause. This is the one thing that makes building the executor
103
+ // connect; without `schema` it stays lazy.
104
+ try {
105
+ await pool.query(`create schema if not exists "${schema}"`);
106
+ } catch (e) {
107
+ await pool.end();
108
+ throw e;
109
+ }
110
+ }
111
+ const poolSession = session(pool);
112
+
113
+ return {
114
+ query: poolSession.query,
115
+ exec: poolSession.exec,
116
+
117
+ async tx<T>(fn: (s: PgSession) => Promise<T>): Promise<T> {
118
+ const client = await pool.connect();
119
+ try {
120
+ await client.query("begin");
121
+ const out = await fn(session(client));
122
+ await client.query("commit");
123
+ return out;
124
+ } catch (e) {
125
+ await rollbackQuietly(client);
126
+ throw e;
127
+ } finally {
128
+ client.release();
129
+ }
130
+ },
131
+
132
+ async listen(channels, onNotify, onClose): Promise<() => void> {
133
+ // Built from the raw DSN rather than taken from the pool: a listener holds
134
+ // its connection for its whole life, and a pooled one would be a slot the
135
+ // store never gives back. If `opts` ever grows connection-level settings
136
+ // (ssl, application_name), the listener must receive them too.
137
+ const client = new pg.Client({ connectionString: dsn });
138
+ await client.connect(); // failure here is transient: caller retries with backoff
139
+ client.on("notification", (msg) => onNotify(msg.channel, msg.payload));
140
+ // A dropped listener degrades to polling; the store reconnects on the next wake.
141
+ client.on("error", () => onClose());
142
+ try {
143
+ await client.query(channels.map((c) => `listen ${c}`).join("; "));
144
+ } catch (e) {
145
+ void client.end().catch(() => {});
146
+ // Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
147
+ // deterministic, so tell the store not to retry.
148
+ throw new ListenUnavailable(e instanceof Error ? e.message : undefined);
149
+ }
150
+ return () => void client.end().catch(() => {});
151
+ },
152
+
153
+ async close(): Promise<void> {
154
+ await pool.end();
155
+ },
156
+ };
157
+ }
@@ -1,5 +1,4 @@
1
- import type * as PG from "pg";
2
-
1
+ import { SchemaMismatch } from "../errors.js";
3
2
  import { loadMigrations, loadStatements } from "../sql.js";
4
3
  import {
5
4
  checkProtocolVersion,
@@ -9,48 +8,16 @@ import {
9
8
  type Params,
10
9
  statementParams,
11
10
  TaskStore,
11
+ type WatchSignal,
12
12
  } from "./base.js";
13
-
14
- // `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
15
- // the first time a PostgresStore connects. Absent -> a clear install hint.
16
- let pgModule: typeof import("pg") | null = null;
17
- async function loadPg(): Promise<typeof import("pg")> {
18
- if (pgModule) return pgModule;
19
- let mod: { default?: typeof import("pg") } & typeof import("pg");
20
- try {
21
- mod = (await import("pg")) as never;
22
- } catch {
23
- throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
24
- }
25
- const pg = (mod.default ?? mod) as typeof import("pg");
26
- // Postgres returns bigint (int8, OID 20) as a string to avoid precision loss.
27
- // Every cairnq bigint is an epoch-ms or a counter, all within Number's safe
28
- // integer range, so parse to number once (globally) to match the Task model
29
- // (*_ms typed as number, same as the SQLite SDK). Set before any query runs.
30
- pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => (v == null ? null : Number(v)));
31
- pgModule = pg;
32
- return pg;
33
- }
34
-
35
- /**
36
- * Roll back on the way out of a failed transaction, without letting the rollback
37
- * become the error the caller sees. A dropped connection fails both the statement
38
- * and the rollback, and it is the first one that says what went wrong.
39
- */
40
- async function rollbackQuietly(client: PG.PoolClient): Promise<void> {
41
- try {
42
- await client.query("rollback");
43
- } catch {
44
- // Already rolled back, or the connection is gone. Either way the original
45
- // error is the one worth propagating.
46
- }
47
- }
13
+ import { ListenUnavailable, type PgExecutor, type PgSession } from "./pg-executor.js";
14
+ import { createPoolExecutor } from "./pg-pool.js";
48
15
 
49
16
  // Notification channels, emitted by the 0003_notify trigger.
50
17
  const QUEUED_CHANNEL = "cairnq_queued";
51
18
  const DONE_CHANNEL = "cairnq_done";
52
19
 
53
- // Backoff between attempts to (re)connect the LISTEN connection after a
20
+ // Backoff between attempts to (re)establish the LISTEN subscription after a
54
21
  // transient failure. Doubles per failure up to the cap; polling covers the gap.
55
22
  const LISTENER_RETRY_MS = 1_000;
56
23
  const LISTENER_RETRY_MAX_MS = 30_000;
@@ -100,26 +67,38 @@ export function toPositional(
100
67
  * PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
101
68
  *
102
69
  * Everything protocol-shaped lives in TaskStore; this file is only what Postgres
103
- * does differently: a `pg` Pool, `:name` -> `$n` translation, and time taken from
104
- * the DB clock (`now()`) instead of from the SDK, which is what makes this backend
105
- * multi-host — unlike SQLite it coordinates API and worker processes across
106
- * machines, with no shared clock to agree on. claim uses FOR UPDATE SKIP LOCKED
107
- * and needs no claimable_probe, because PG readers don't block writers. JSON
108
- * columns are jsonb (bound as JSON text, read back as objects by rowToTask).
70
+ * does differently: `:name` -> `$n` translation, the migration ledger, LISTEN
71
+ * policy, and time taken from the DB clock (`now()`) instead of from the SDK,
72
+ * which is what makes this backend multi-host — unlike SQLite it coordinates API
73
+ * and worker processes across machines, with no shared clock to agree on. claim
74
+ * uses FOR UPDATE SKIP LOCKED and needs no claimable_probe, because PG readers
75
+ * don't block writers. JSON columns are jsonb (bound as JSON text, read back as
76
+ * objects by rowToTask).
77
+ *
78
+ * What it deliberately does NOT own is the connection. Given a DSN it builds a
79
+ * `pg` pool (see pg-pool.ts); given a PgExecutor it runs inside the caller's
80
+ * session instead — no second driver, no second pool, and the caller's writes and
81
+ * cairnq's can share one transaction.
109
82
  */
110
83
  export class PostgresStore extends TaskStore {
111
- private pool: PG.Pool | null = null;
84
+ private readonly dsn: string | null;
85
+ /** The caller's executor, if one was injected — never closed by this store. */
86
+ private readonly provided: PgExecutor | null;
87
+ /** Set once migrations have run and the protocol version checked out. */
88
+ private executor: PgExecutor | null = null;
112
89
  private connecting: Promise<void> | null = null;
113
90
  private readonly statements: Record<string, string>;
114
91
 
115
92
  // ------------------------------------------------------- LISTEN/NOTIFY state
116
- // One dedicated connection LISTENs on both channels (see 0003_notify.sql and
117
- // the claimWake/taskDoneWake contract on TaskStore). Failure to establish or
118
- // keep it silently degrades the store to the base class's plain polling.
119
- private listener: PG.Client | null = null;
93
+ // The executor subscribes one dedicated connection to both channels (see
94
+ // 0003_notify.sql and the claimWake/taskDoneWake contract on TaskStore).
95
+ // Failure to establish or keep it silently degrades the store to the base
96
+ // class's plain polling. Held as the unsubscribe function, not the connection:
97
+ // whose connection it is, is the executor's business.
98
+ private listener: (() => void) | null = null;
120
99
  private listenerConnecting: Promise<void> | null = null;
121
- /** LISTEN is off for good: the store was closed, or the server accepted a
122
- * connection but refused LISTEN (e.g. a transaction-mode pooler) —
100
+ /** LISTEN is off for good: the store was closed, the executor does not support
101
+ * it, or the server refused it (e.g. a transaction-mode pooler) —
123
102
  * deterministic, so retrying would fail the same way every time. */
124
103
  private listenerUnavailable = false;
125
104
  /** A failure to even connect is transient (network blip, server restarting):
@@ -132,12 +111,22 @@ export class PostgresStore extends TaskStore {
132
111
  private readonly pendingQueues = new Set<string>();
133
112
  /** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
134
113
  private readonly waiters = new Map<string, Set<() => void>>();
114
+ /** watch() subscribers. Separate from `waiters`: a waiter is one-shot and
115
+ * consumes the notification, a subscriber is standing and only observes. */
116
+ private readonly subscribers = new Set<(signal: WatchSignal) => void>();
135
117
 
118
+ /**
119
+ * `source` is either a libpq connection string — this store then owns a `pg`
120
+ * pool and requires the optional `pg` package — or a PgExecutor the caller
121
+ * already has, which this store uses and never closes.
122
+ */
136
123
  constructor(
137
- private readonly dsn: string,
138
- private readonly opts: { max?: number } = {},
124
+ source: string | PgExecutor,
125
+ private readonly opts: { max?: number; schema?: string } = {},
139
126
  ) {
140
127
  super();
128
+ this.dsn = typeof source === "string" ? source : null;
129
+ this.provided = typeof source === "string" ? null : source;
141
130
  this.statements = loadStatements("postgres");
142
131
  }
143
132
 
@@ -148,18 +137,19 @@ export class PostgresStore extends TaskStore {
148
137
  async close(): Promise<void> {
149
138
  this.listenerUnavailable = true; // no revival after close
150
139
  this.dropListener();
151
- if (this.pool) {
152
- const p = this.pool;
153
- this.pool = null;
154
- this.connecting = null;
155
- await p.end();
156
- }
140
+ const executor = this.executor;
141
+ this.executor = null;
142
+ this.connecting = null;
143
+ // An injected executor belongs to the caller, whose other work would not
144
+ // survive cairnq closing it.
145
+ if (executor && !this.provided) await executor.close();
157
146
  }
158
147
 
159
148
  private async ensure(): Promise<void> {
160
- if (this.pool) return;
161
- // Cache the in-flight connect so concurrent calls share one pool. On failure,
162
- // clear it so a later call retries instead of re-awaiting a rejected promise.
149
+ if (this.executor) return;
150
+ // Cache the in-flight connect so concurrent calls share one executor. On
151
+ // failure, clear it so a later call retries instead of re-awaiting a
152
+ // rejected promise.
163
153
  if (!this.connecting) {
164
154
  this.connecting = this.doConnect().catch((e) => {
165
155
  this.connecting = null;
@@ -170,28 +160,85 @@ export class PostgresStore extends TaskStore {
170
160
  }
171
161
 
172
162
  private async doConnect(): Promise<void> {
173
- const pg = await loadPg();
174
- const pool = new pg.Pool({ connectionString: this.dsn, max: this.opts.max });
163
+ const executor =
164
+ this.provided ??
165
+ (await createPoolExecutor(this.dsn!, { max: this.opts.max, schema: this.opts.schema }));
175
166
  try {
176
- const client = await pool.connect();
177
- try {
178
- await this.applyMigrations(client);
179
- checkProtocolVersion(await this.readProtocolVersion(client));
180
- } finally {
181
- client.release();
182
- }
167
+ // Before migrations, which would otherwise create the very installation
168
+ // this is trying to warn about.
169
+ await this.checkSchema(executor);
170
+ await this.applyMigrations(executor);
171
+ checkProtocolVersion(await this.readProtocolVersion(executor));
183
172
  } catch (e) {
184
- await pool.end(); // never leak a pool when connect fails
173
+ // Never leak an executor we created; never close one we were handed.
174
+ if (!this.provided) await executor.close().catch(() => {});
185
175
  throw e;
186
176
  }
187
- this.pool = pool; // publish only a fully-migrated, version-checked pool
188
- // Warm the LISTEN connection in the background so the first idle sleep is
177
+ this.executor = executor; // publish only a fully-migrated, version-checked executor
178
+ // Warm the LISTEN subscription in the background so the first idle sleep is
189
179
  // already wakeable. Fire-and-forget: failure just means polling.
190
180
  this.listenerReady();
191
181
  }
192
182
 
193
- private async applyMigrations(client: PG.PoolClient): Promise<void> {
194
- await client.query(
183
+ /**
184
+ * Refuse a connection pointed somewhere other than the deployment's cairnq.
185
+ *
186
+ * Two shapes, because `schema` means "the schema cairnq's tables live in" and
187
+ * cairnq can either arrange that (it built the connection) or only check it
188
+ * (the caller's executor did):
189
+ *
190
+ * - `schema` configured -> assert the connection actually resolves there. On
191
+ * the DSN path this is a cheap self-check; on an injected executor it is the
192
+ * only way to state the expectation at all.
193
+ * - `schema` not configured -> the dangerous case is being about to create a
194
+ * SECOND installation while one already exists elsewhere in this database,
195
+ * which is exactly what a mismatched pair of SDKs does. Joining an existing
196
+ * installation is fine no matter what else is around, so the check is
197
+ * deliberately narrow: it fires only when this schema has no cairnq and some
198
+ * other schema does.
199
+ *
200
+ * That narrowness is what keeps it from crying wolf. Two applications each
201
+ * running their own cairnq in their own schema are legitimate; the second one
202
+ * to be set up trips this once, and saying `schema` explicitly — which such a
203
+ * deployment should be doing anyway — is both the fix and the confirmation.
204
+ */
205
+ private async checkSchema(executor: PgExecutor): Promise<void> {
206
+ // One row per installation; the statement's LEFT JOIN guarantees at least
207
+ // one, so current_schema is readable even where cairnq lives nowhere yet.
208
+ const rows = await executor.query(this.statements.installations, []);
209
+ const current = (rows[0]?.current_schema as string | null) ?? null;
210
+ const installations = rows
211
+ .map((r) => r.schema as string | null)
212
+ .filter((s): s is string => s != null);
213
+ const wanted = this.opts.schema;
214
+
215
+ if (wanted != null) {
216
+ if (current !== wanted) {
217
+ throw new SchemaMismatch(
218
+ `cairnq is configured for schema ${JSON.stringify(wanted)} but this ` +
219
+ `connection resolves to ${JSON.stringify(current)} — check the ` +
220
+ `connection's search_path`,
221
+ );
222
+ }
223
+ return;
224
+ }
225
+ // A search_path naming nothing that exists: there is no "here" to compare
226
+ // against, and the migrations are about to fail with a clearer message.
227
+ if (current === null) return;
228
+ if (installations.length === 0 || installations.includes(current)) return;
229
+
230
+ throw new SchemaMismatch(
231
+ `cairnq tables already exist in schema ${installations.map((s) => JSON.stringify(s)).join(", ")} ` +
232
+ `of this database, but this connection resolves to ${JSON.stringify(current)}, where there are ` +
233
+ `none. Connecting would create a second, parallel installation that the other one can never see ` +
234
+ `— an API and a worker split this way agree about everything except where, and no task crosses. ` +
235
+ `Point this process at the same schema (\`schema\` option, or \`options=-c search_path=...\` in the ` +
236
+ `DSN), or pass \`schema\` explicitly to confirm a separate installation is what you meant.`,
237
+ );
238
+ }
239
+
240
+ private async applyMigrations(executor: PgExecutor): Promise<void> {
241
+ await executor.exec(
195
242
  "create table if not exists cairnq_migrations " +
196
243
  "(name text primary key, applied_at_ms bigint not null)",
197
244
  );
@@ -199,43 +246,31 @@ export class PostgresStore extends TaskStore {
199
246
  // Check and apply inside one transaction, with the table lock taken up
200
247
  // front: two processes cold-starting together would otherwise both see a
201
248
  // migration as unapplied and both run it.
202
- try {
203
- await client.query("begin");
204
- await client.query("lock table cairnq_migrations in exclusive mode");
205
- const applied = await client.query("select 1 from cairnq_migrations where name = $1", [
206
- name,
207
- ]);
208
- if (applied.rowCount === 0) {
209
- await client.query(sql); // multi-statement DDL (simple-query, no params)
210
- await client.query(
249
+ await executor.tx(async (s) => {
250
+ await s.exec("lock table cairnq_migrations in exclusive mode");
251
+ const applied = await s.query("select 1 from cairnq_migrations where name = $1", [name]);
252
+ if (applied.length === 0) {
253
+ await s.exec(sql); // multi-statement DDL (simple-query, no params)
254
+ await s.query(
211
255
  "insert into cairnq_migrations (name, applied_at_ms) values " +
212
256
  "($1, (extract(epoch from now()) * 1000)::bigint)",
213
257
  [name],
214
258
  );
215
259
  }
216
- await client.query("commit");
217
- } catch (e) {
218
- await rollbackQuietly(client);
219
- throw e;
220
- }
260
+ });
221
261
  }
222
262
  }
223
263
 
224
- // Takes an explicit client: during doConnect the pool is not published yet, so
225
- // this cannot go through fetch(). The statement binds nothing, so it runs as-is.
226
- private async readProtocolVersion(client: PG.PoolClient): Promise<number> {
227
- const res = await client.query(this.statements.protocol_version);
228
- return res.rows.length ? Number(res.rows[0].value) : 0;
264
+ // Takes an explicit session: during doConnect the executor is not published
265
+ // yet, so this cannot go through fetch(). The statement binds nothing.
266
+ private async readProtocolVersion(s: PgSession): Promise<number> {
267
+ const rows = await s.query(this.statements.protocol_version, []);
268
+ return rows.length ? Number(rows[0].value) : 0;
229
269
  }
230
270
 
231
271
  async protocolVersion(): Promise<number> {
232
272
  await this.ensure();
233
- const client = await this.pool!.connect();
234
- try {
235
- return await this.readProtocolVersion(client);
236
- } finally {
237
- client.release();
238
- }
273
+ return this.readProtocolVersion(this.executor!);
239
274
  }
240
275
 
241
276
  // ------------------------------------------------------------ wake channel
@@ -255,6 +290,16 @@ export class PostgresStore extends TaskStore {
255
290
  }
256
291
  }
257
292
 
293
+ protected subscribePush(onSignal: (signal: WatchSignal) => void): () => void {
294
+ this.subscribers.add(onSignal);
295
+ this.listenerReady(); // an API-side watcher is often the only thing asking
296
+ return () => void this.subscribers.delete(onSignal);
297
+ }
298
+
299
+ protected warmPush(): void {
300
+ if (this.subscribers.size > 0) this.listenerReady();
301
+ }
302
+
258
303
  taskDoneWake(taskId: string, timeoutMs: number): Promise<void> {
259
304
  if (!this.listenerReady()) return super.taskDoneWake(taskId, timeoutMs);
260
305
  return this.wakeOn(`done:${taskId}`, timeoutMs);
@@ -280,7 +325,7 @@ export class PostgresStore extends TaskStore {
280
325
  });
281
326
  }
282
327
 
283
- /** True once the LISTEN connection is up; starts connecting it otherwise
328
+ /** True once the LISTEN subscription is up; starts establishing it otherwise
284
329
  * (respecting the transient-failure backoff). Callers fall back to plain
285
330
  * polling until it is ready (or forever, if it can't be established) —
286
331
  * correctness never depends on it. */
@@ -294,36 +339,39 @@ export class PostgresStore extends TaskStore {
294
339
 
295
340
  private async startListener(): Promise<void> {
296
341
  try {
297
- const pg = await loadPg();
298
- // Built from the raw DSN: if `opts` ever grows connection-level settings
299
- // (ssl, application_name), the listener must receive them too.
300
- const client = new pg.Client({ connectionString: this.dsn });
342
+ const executor = this.executor ?? this.provided;
343
+ // Not connected yet: transient by definition — doConnect calls back in.
344
+ if (!executor) return;
345
+ if (!executor.listen) {
346
+ this.listenerUnavailable = true; // this executor will never push
347
+ return;
348
+ }
349
+ let stop: () => void;
301
350
  try {
302
- await client.connect();
303
- } catch {
304
- // Could not even connect — transient. Schedule a backed-off retry;
351
+ stop = await executor.listen(
352
+ [QUEUED_CHANNEL, DONE_CHANNEL],
353
+ (channel, payload) => this.onNotification(channel, payload),
354
+ // A dropped listener degrades to polling; the next wake reconnects.
355
+ () => this.dropListener(),
356
+ );
357
+ } catch (e) {
358
+ if (e instanceof ListenUnavailable) {
359
+ // Deterministic (e.g. a transaction-mode pooler): off for good rather
360
+ // than a reconnect loop that cannot succeed. Polling covers it.
361
+ this.listenerUnavailable = true;
362
+ return;
363
+ }
364
+ // Could not establish it — transient. Schedule a backed-off retry;
305
365
  // polling covers the gap.
306
366
  this.listenerRetryAt = Date.now() + this.listenerBackoffMs;
307
367
  this.listenerBackoffMs = Math.min(LISTENER_RETRY_MAX_MS, this.listenerBackoffMs * 2);
308
368
  return;
309
369
  }
310
- client.on("notification", (msg) => this.onNotification(msg.channel, msg.payload));
311
- // A dropped listener degrades to polling; the next wake call reconnects.
312
- client.on("error", () => this.dropListener());
313
- try {
314
- await client.query(`listen ${QUEUED_CHANNEL}; listen ${DONE_CHANNEL}`);
315
- } catch {
316
- // Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
317
- // deterministic, so off for good. Polling covers it.
318
- this.listenerUnavailable = true;
319
- void client.end().catch(() => {});
320
- return;
321
- }
322
370
  if (this.listenerUnavailable) {
323
- void client.end().catch(() => {}); // closed while we were connecting
371
+ stop(); // closed while we were subscribing
324
372
  return;
325
373
  }
326
- this.listener = client;
374
+ this.listener = stop;
327
375
  this.listenerBackoffMs = LISTENER_RETRY_MS;
328
376
  } catch {
329
377
  // Anything unexpected (e.g. `pg` failed to load): off for good rather
@@ -339,8 +387,10 @@ export class PostgresStore extends TaskStore {
339
387
  if (channel === QUEUED_CHANNEL && payload) {
340
388
  this.pendingQueues.add(payload);
341
389
  key = "queued";
390
+ this.publish({ reason: "queued", queue: payload });
342
391
  } else if (channel === DONE_CHANNEL && payload) {
343
392
  key = `done:${payload}`;
393
+ this.publish({ reason: "done", taskId: payload });
344
394
  } else {
345
395
  return;
346
396
  }
@@ -349,10 +399,23 @@ export class PostgresStore extends TaskStore {
349
399
  if (set) for (const w of set) w();
350
400
  }
351
401
 
402
+ /** Hand a notification to every watch() subscriber. A throwing subscriber is
403
+ * its own problem: it must not cost the others their signal, nor take down the
404
+ * listener connection that delivered it. */
405
+ private publish(signal: WatchSignal): void {
406
+ for (const s of [...this.subscribers]) {
407
+ try {
408
+ s(signal);
409
+ } catch {
410
+ // Deliberately swallowed — see above.
411
+ }
412
+ }
413
+ }
414
+
352
415
  private dropListener(): void {
353
- const client = this.listener;
416
+ const stop = this.listener;
354
417
  this.listener = null;
355
- if (client) void client.end().catch(() => {});
418
+ stop?.();
356
419
  // Release everyone promptly; their fallback poll takes over.
357
420
  for (const set of [...this.waiters.values()]) for (const w of [...set]) w();
358
421
  }
@@ -361,25 +424,26 @@ export class PostgresStore extends TaskStore {
361
424
  protected async fetch(name: string, params: Params): Promise<any[]> {
362
425
  await this.ensure();
363
426
  const { text, values } = toPositional(this.statements[name], params);
364
- return (await this.pool!.query(text, values)).rows;
427
+ return this.executor!.query(text, values);
365
428
  }
366
429
 
367
430
  protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
368
431
  await this.ensure();
369
- const client = await this.pool!.connect();
370
- try {
371
- await client.query("begin");
372
- const out = await fn(async (name, params) => {
373
- const { text, values } = toPositional(this.statements[name], params);
374
- return (await client.query(text, values)).rows;
375
- });
376
- await client.query("commit");
377
- return out;
378
- } catch (e) {
379
- await rollbackQuietly(client);
380
- throw e;
381
- } finally {
382
- client.release();
383
- }
432
+ return this.executor!.tx((s) => fn(this.boundFetch(s)));
433
+ }
434
+
435
+ protected async txWithSession<T>(
436
+ fn: (fetch: Fetch, session: PgSession) => Promise<T>,
437
+ ): Promise<T> {
438
+ await this.ensure();
439
+ return this.executor!.tx((s) => fn(this.boundFetch(s), s));
440
+ }
441
+
442
+ /** A Fetch that runs the protocol's statements on one particular session. */
443
+ private boundFetch(s: PgSession): Fetch {
444
+ return async (name, params) => {
445
+ const { text, values } = toPositional(this.statements[name], params);
446
+ return s.query(text, values);
447
+ };
384
448
  }
385
449
  }