@effectstream/db 0.100.22 → 0.100.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/db",
3
- "version": "0.100.22",
3
+ "version": "0.100.24",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -21,11 +21,11 @@
21
21
  "./version": "./migrations/system-version.ts"
22
22
  },
23
23
  "dependencies": {
24
- "@effectstream/coroutine": "0.100.22",
25
- "@effectstream/log": "0.100.22",
26
- "@effectstream/runtime": "0.100.22",
27
- "@effectstream/sync": "0.100.22",
28
- "@effectstream/utils": "0.100.22",
24
+ "@effectstream/coroutine": "0.100.24",
25
+ "@effectstream/log": "0.100.24",
26
+ "@effectstream/runtime": "0.100.24",
27
+ "@effectstream/sync": "0.100.24",
28
+ "@effectstream/utils": "0.100.24",
29
29
  "@electric-sql/pglite": "^0.3.14",
30
30
  "@pgtyped/runtime": "2.4.2",
31
31
  "@pgtyped/parser": "2.4.2",
@@ -31,6 +31,10 @@ export async function startPglite(port = 5432): Promise<PgliteHandle> {
31
31
  },
32
32
  );
33
33
 
34
+ // Serialize every client's execProtocolRaw through one promise chain
35
+ // PGlite allows only one in-flight query, and per-process db_mutex can't coordinate across separate TCP clients.
36
+ let queue: Promise<unknown> = Promise.resolve();
37
+
34
38
  // TODO: consider switching to pglite-socket once it works
35
39
  // https://discord.com/channels/933657521581858818/1371976702674075780/1371992712076595250
36
40
  const server = net.createServer(async (socket) => {
@@ -54,8 +58,11 @@ export async function startPglite(port = 5432): Promise<PgliteHandle> {
54
58
  return;
55
59
  }
56
60
 
57
- // Forward raw message to PGlite and send response to client
58
- return await db.execProtocolRaw(data);
61
+ // Forward raw message to PGlite and send response to client, serialized
62
+ // through the queue so the single WASM backend handles one at a time.
63
+ const run = queue.then(() => db.execProtocolRaw(data));
64
+ queue = run.catch(() => {}); // keep the chain alive even if a query errors
65
+ return await run;
59
66
  },
60
67
  });
61
68
  });
package/src/mod.ts CHANGED
@@ -54,6 +54,7 @@ export * from "./sql/tables.queries.ts";
54
54
 
55
55
  export * from "./event-indexing.ts";
56
56
  export * from "./register-events.ts";
57
+ export * from "./transient-pg-errors.ts";
57
58
  export * from "./pg-connection.ts";
58
59
  export * from "./scheduled-constructors.ts";
59
60
  export * from "./system.ts";
@@ -1,10 +1,16 @@
1
1
  import type { Client, PoolClient, PoolConfig } from "pg";
2
2
  import pg from "pg";
3
- import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
3
+ import { ComponentNames, installUnhandledRejectionLogger } from "@effectstream/log";
4
4
  import { type Operation, run, sleep } from "effection";
5
5
  import { ENV } from "@effectstream/utils/node-env";
6
+ import { isTransientPgError } from "./transient-pg-errors.ts";
7
+ import { poolErrors } from "./pool-error-tracker.ts";
8
+
9
+ export { isTransientPgError } from "./transient-pg-errors.ts";
10
+ export { poolErrors, PoolErrorTracker } from "./pool-error-tracker.ts";
6
11
 
7
12
  let readonlyDBConn: pg.Pool | null;
13
+ let unhandledRejectionLoggerInstalled = false;
8
14
 
9
15
  // PGLite does not support multiple connections, so we need to use a mutex to ensure that only one query is executed at a time.
10
16
  // * For transactions use yield* acquireDBMutex(); ...Your Operations... releaseDBMutex();
@@ -138,6 +144,8 @@ export async function runPreparedQuery<T>(
138
144
  });
139
145
  result = await Promise.race([p, timeout]);
140
146
  clearTimeout(t);
147
+ // A successful query proves the DB is reachable end-to-end
148
+ poolErrors.markHealthy();
141
149
  } finally {
142
150
  releaseDBMutex(`pq:${name}`);
143
151
  }
@@ -148,6 +156,14 @@ export const getConnection = (
148
156
  creds?: PoolConfig,
149
157
  readonly = false,
150
158
  ): pg.Pool => {
159
+ if (!unhandledRejectionLoggerInstalled) {
160
+ unhandledRejectionLoggerInstalled = true;
161
+ installUnhandledRejectionLogger(
162
+ ComponentNames.EFFECTSTREAM_PGLITE,
163
+ isTransientPgError,
164
+ );
165
+ }
166
+
151
167
  if (!creds) {
152
168
  creds = {
153
169
  host: ENV.DB_HOST,
@@ -163,30 +179,21 @@ export const getConnection = (
163
179
  const MAX_CONNECTIONS = ENV.PGLITE ? 1 : 10;
164
180
 
165
181
  const pool = new pg.Pool({ ...creds, max: MAX_CONNECTIONS });
166
- pool.on("error", (err: unknown) =>
167
- log.remote(
168
- ComponentNames.EFFECTSTREAM_PGLITE,
169
- ["query"],
170
- SeverityNumber.ERROR,
171
- (log) => log(err),
172
- ));
182
+ pool.on("error", (err: unknown) => poolErrors.log(err, ["query"]));
173
183
  pool.on("connect", (_client: PoolClient) => {
174
- // On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
184
+ // Successful connect proves the DB is reachable — reset the failure clock.
185
+ poolErrors.markHealthy();
186
+ // Register a per-client error handler; pg can emit errors on idle clients.
175
187
  // https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
176
- _client.on("error", (err: Error) => {
177
- log.remote(
178
- ComponentNames.EFFECTSTREAM_PGLITE,
179
- ["connect"],
180
- SeverityNumber.ERROR,
181
- (log) => log(err),
182
- );
183
- });
188
+ _client.on("error", (err: Error) => poolErrors.log(err, ["connect"]));
184
189
  });
185
190
 
186
191
  if (readonly) {
187
192
  const ensureReadOnly =
188
193
  `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`;
189
- void pool.query(ensureReadOnly); // note: this query modifies the DB state
194
+ void pool.query(ensureReadOnly).catch((err: unknown) =>
195
+ poolErrors.log(err, ["readonly-setup"]),
196
+ );
190
197
  readonlyDBConn = pool;
191
198
  }
192
199
 
@@ -198,14 +205,6 @@ export const getPersistentConnection = (creds: PoolConfig): Client => {
198
205
  const client = new pg.Client(creds);
199
206
  client.connect(() => {});
200
207
  // https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
201
- // On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
202
- client.on("error", (err: Error) => {
203
- log.remote(
204
- ComponentNames.EFFECTSTREAM_PGLITE,
205
- ["query"],
206
- SeverityNumber.ERROR,
207
- (log) => log(err),
208
- );
209
- });
208
+ client.on("error", (err: Error) => poolErrors.log(err, ["persistent"]));
210
209
  return client;
211
210
  };
@@ -0,0 +1,65 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+
3
+ // Thresholds are read from env at module load (and `envMs` rejects 0, so use
4
+ // small positive values). SUSTAINED=5ms → a failure older than 5ms is
5
+ // "sustained"; FATAL=30ms → older than 30ms triggers the process.exit
6
+ // escalation. Dynamic import so these are picked up before the module
7
+ // computes its constants.
8
+ process.env.POOL_SUSTAINED_THRESHOLD_MS = "5";
9
+ process.env.POOL_FATAL_THRESHOLD_MS = "30";
10
+ const { PoolErrorTracker } = await import("./pool-error-tracker.ts");
11
+
12
+ const transient = () => ({
13
+ code: "ECONNRESET",
14
+ message: "Client network socket disconnected before secure TLS connection",
15
+ stack: " at node_modules/pg-pool/index.js:45:11",
16
+ });
17
+
18
+ const realExit = process.exit;
19
+ afterEach(() => {
20
+ process.exit = realExit;
21
+ });
22
+
23
+ test("ignores non-transient errors (auth/schema bugs)", () => {
24
+ const t = new PoolErrorTracker();
25
+ t.log(new Error("column foo does not exist"), ["sync-write"]);
26
+ expect(t.state().consecutive).toBe(0);
27
+ expect(t.state().sustained).toBe(false);
28
+ });
29
+
30
+ test("counts consecutive transient failures and reports sustained", async () => {
31
+ const t = new PoolErrorTracker();
32
+ t.log(transient(), ["sync-write"]);
33
+ expect(t.state().sustained).toBe(false); // fresh failure, < 5ms old
34
+ await Bun.sleep(15); // exceed SUSTAINED_THRESHOLD_MS=5, stay under FATAL=30
35
+ t.log(transient(), ["sync-write"]);
36
+ const s = t.state();
37
+ expect(s.consecutive).toBe(2);
38
+ expect(s.sustained).toBe(true);
39
+ });
40
+
41
+ test("markHealthy resets the failure clock", () => {
42
+ const t = new PoolErrorTracker();
43
+ t.log(transient(), ["sync-write"]);
44
+ t.markHealthy();
45
+ const s = t.state();
46
+ expect(s.consecutive).toBe(0);
47
+ expect(s.firstFailureAt).toBe(0);
48
+ expect(s.sustained).toBe(false);
49
+ });
50
+
51
+ test("exits the process once failures persist past the fatal threshold", async () => {
52
+ let exitCode: number | undefined;
53
+ process.exit = ((code?: number) => {
54
+ exitCode = code;
55
+ }) as typeof process.exit;
56
+
57
+ const t = new PoolErrorTracker();
58
+ t.log(transient(), ["sync-write"]); // starts the clock; not yet fatal
59
+ await Bun.sleep(40); // exceed FATAL_THRESHOLD_MS=20
60
+ t.log(transient(), ["sync-write"]); // now duration >= fatal → schedules exit
61
+
62
+ // exit is deferred via setImmediate so the fatal log line can flush first.
63
+ await new Promise((resolve) => setImmediate(resolve));
64
+ expect(exitCode).toBe(1);
65
+ });
@@ -0,0 +1,87 @@
1
+ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
2
+ import { isTransientPgError } from "./transient-pg-errors.ts";
3
+
4
+ function envMs(name: string, fallback: number): number {
5
+ const raw = process.env[name];
6
+ const parsed = raw != null ? Number(raw) : NaN;
7
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
8
+ }
9
+
10
+ /** After this long, treat continuous transient failures as a real outage. */
11
+ const SUSTAINED_THRESHOLD_MS = envMs("POOL_SUSTAINED_THRESHOLD_MS", 60_000);
12
+ /** After this long, exit so the orchestrator restarts the process. */
13
+ const FATAL_THRESHOLD_MS = envMs("POOL_FATAL_THRESHOLD_MS", 5 * 60_000);
14
+
15
+ /**
16
+ * Tracks consecutive transient pool errors. Severity escalates WARN → ERROR
17
+ * once failures persist past `SUSTAINED_THRESHOLD_MS`, and the process exits
18
+ * past `FATAL_THRESHOLD_MS` (relying on the orchestrator's restart policy).
19
+ * Call `markHealthy()` on any positive signal to reset.
20
+ */
21
+ export class PoolErrorTracker {
22
+ private consecutive = 0;
23
+ private firstFailureAt = 0;
24
+
25
+ log(err: unknown, tags: string[]): void {
26
+ // Non-transient errors are a different failure mode (auth, schema, etc.).
27
+ if (!isTransientPgError(err)) {
28
+ log.remote(
29
+ ComponentNames.EFFECTSTREAM_PGLITE,
30
+ tags,
31
+ SeverityNumber.ERROR,
32
+ (l) => l(err),
33
+ );
34
+ return;
35
+ }
36
+
37
+ if (this.consecutive === 0) this.firstFailureAt = Date.now();
38
+ this.consecutive++;
39
+
40
+ const durationMs = Date.now() - this.firstFailureAt;
41
+ const sustained = durationMs >= SUSTAINED_THRESHOLD_MS;
42
+ const fatal = durationMs >= FATAL_THRESHOLD_MS;
43
+
44
+ log.remote(
45
+ ComponentNames.EFFECTSTREAM_PGLITE,
46
+ fatal
47
+ ? [...tags, "sustained-failure", "fatal"]
48
+ : sustained
49
+ ? [...tags, "sustained-failure"]
50
+ : tags,
51
+ sustained ? SeverityNumber.ERROR : SeverityNumber.WARN,
52
+ (l) => l({ err, consecutive: this.consecutive, durationMs }),
53
+ );
54
+
55
+ if (fatal) {
56
+ // Defer to the next tick so the log line above has a chance to flush.
57
+ setImmediate(() => process.exit(1));
58
+ }
59
+ }
60
+
61
+ /** Call when the DB has proven reachable (e.g. successful pool connect). */
62
+ markHealthy(): void {
63
+ this.consecutive = 0;
64
+ this.firstFailureAt = 0;
65
+ }
66
+
67
+ /** Read-only snapshot for health checks, heartbeats, or external probes. */
68
+ state(): {
69
+ consecutive: number;
70
+ firstFailureAt: number;
71
+ sustainedDurationMs: number;
72
+ sustained: boolean;
73
+ } {
74
+ const durationMs = this.firstFailureAt === 0
75
+ ? 0
76
+ : Date.now() - this.firstFailureAt;
77
+ return {
78
+ consecutive: this.consecutive,
79
+ firstFailureAt: this.firstFailureAt,
80
+ sustainedDurationMs: durationMs,
81
+ sustained: durationMs >= SUSTAINED_THRESHOLD_MS,
82
+ };
83
+ }
84
+ }
85
+
86
+ /** Process-wide singleton — failures aggregate at DB-health granularity. */
87
+ export const poolErrors = new PoolErrorTracker();
@@ -383,6 +383,61 @@ const getFutureGameInputByMaxTimestampIR: any = {"usedParamSet":{"max_timestamp"
383
383
  export const getFutureGameInputByMaxTimestamp = new PreparedQuery<IGetFutureGameInputByMaxTimestampParams,IGetFutureGameInputByMaxTimestampResult>(getFutureGameInputByMaxTimestampIR);
384
384
 
385
385
 
386
+ /** 'GetEarliestScheduledBlockHeight' parameters type */
387
+ export type IGetEarliestScheduledBlockHeightParams = void;
388
+
389
+ /** 'GetEarliestScheduledBlockHeight' return type */
390
+ export interface IGetEarliestScheduledBlockHeightResult {
391
+ min_block_height: number | null;
392
+ }
393
+
394
+ /** 'GetEarliestScheduledBlockHeight' query type */
395
+ export interface IGetEarliestScheduledBlockHeightQuery {
396
+ params: IGetEarliestScheduledBlockHeightParams;
397
+ result: IGetEarliestScheduledBlockHeightResult;
398
+ }
399
+
400
+ const getEarliestScheduledBlockHeightIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT MIN(future_block_height) AS min_block_height\nFROM effectstream.rollup_input_future_block"};
401
+
402
+ /**
403
+ * Query generated from SQL:
404
+ * ```
405
+ * SELECT MIN(future_block_height) AS min_block_height
406
+ * FROM effectstream.rollup_input_future_block
407
+ * ```
408
+ */
409
+ export const getEarliestScheduledBlockHeight = new PreparedQuery<IGetEarliestScheduledBlockHeightParams,IGetEarliestScheduledBlockHeightResult>(getEarliestScheduledBlockHeightIR);
410
+
411
+
412
+ /** 'GetEarliestScheduledTimestamp' parameters type */
413
+ export type IGetEarliestScheduledTimestampParams = void;
414
+
415
+ /** 'GetEarliestScheduledTimestamp' return type */
416
+ export interface IGetEarliestScheduledTimestampResult {
417
+ min_timestamp: Date | null;
418
+ }
419
+
420
+ /** 'GetEarliestScheduledTimestamp' query type */
421
+ export interface IGetEarliestScheduledTimestampQuery {
422
+ params: IGetEarliestScheduledTimestampParams;
423
+ result: IGetEarliestScheduledTimestampResult;
424
+ }
425
+
426
+ const getEarliestScheduledTimestampIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp\nFROM effectstream.rollup_input_future_timestamp\nLEFT OUTER JOIN effectstream.rollup_input_result\n ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)\nWHERE effectstream.rollup_input_result.id IS NULL"};
427
+
428
+ /**
429
+ * Query generated from SQL:
430
+ * ```
431
+ * SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp
432
+ * FROM effectstream.rollup_input_future_timestamp
433
+ * LEFT OUTER JOIN effectstream.rollup_input_result
434
+ * ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)
435
+ * WHERE effectstream.rollup_input_result.id IS NULL
436
+ * ```
437
+ */
438
+ export const getEarliestScheduledTimestamp = new PreparedQuery<IGetEarliestScheduledTimestampParams,IGetEarliestScheduledTimestampResult>(getEarliestScheduledTimestampIR);
439
+
440
+
386
441
  /** 'GetGameInputResultByBlockHeight' parameters type */
387
442
  export interface IGetGameInputResultByBlockHeightParams {
388
443
  block_height: number;
@@ -144,6 +144,17 @@ WHERE rollup_input_future_timestamp.future_ms_timestamp <= :max_timestamp! AND
144
144
  effectstream.rollup_input_result.id IS NULL
145
145
  ORDER BY rollup_input_future_timestamp.future_ms_timestamp ASC;
146
146
 
147
+ /* @name getEarliestScheduledBlockHeight */
148
+ SELECT MIN(future_block_height) AS min_block_height
149
+ FROM effectstream.rollup_input_future_block;
150
+
151
+ /* @name getEarliestScheduledTimestamp */
152
+ SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp
153
+ FROM effectstream.rollup_input_future_timestamp
154
+ LEFT OUTER JOIN effectstream.rollup_input_result
155
+ ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)
156
+ WHERE effectstream.rollup_input_result.id IS NULL;
157
+
147
158
  /* @name getGameInputResultByBlockHeight */
148
159
  SELECT
149
160
  rollup_inputs.id,
@@ -0,0 +1,47 @@
1
+ import { test, expect } from "bun:test";
2
+ import { isTransientPgError } from "./transient-pg-errors.ts";
3
+
4
+ test("isTransientPgError - Neon pg-pool TLS reset", () => {
5
+ expect(
6
+ isTransientPgError({
7
+ code: "ECONNRESET",
8
+ message:
9
+ "Client network socket disconnected before secure TLS connection was established",
10
+ stack: " at /app/node_modules/.bun/pg-pool@3.14.0/node_modules/pg-pool/index.js:45:11",
11
+ }),
12
+ ).toBe(true);
13
+ });
14
+
15
+ test("isTransientPgError - rejects non-pg ETIMEDOUT", () => {
16
+ expect(
17
+ isTransientPgError({
18
+ code: "ETIMEDOUT",
19
+ message: "fetch failed",
20
+ stack: " at node:internal/deps/undici/undici.js:1234:5",
21
+ }),
22
+ ).toBe(false);
23
+ });
24
+
25
+ test("isTransientPgError - walks cause chain", () => {
26
+ const inner = {
27
+ code: "ECONNRESET",
28
+ message:
29
+ "Client network socket disconnected before secure TLS connection was established",
30
+ stack: " at node_modules/pg-pool/index.js:45:11",
31
+ };
32
+ expect(isTransientPgError({ message: "wrapper", cause: inner })).toBe(true);
33
+ });
34
+
35
+ test("isTransientPgError - rejects logic errors", () => {
36
+ expect(isTransientPgError(new Error("column foo does not exist"))).toBe(
37
+ false,
38
+ );
39
+ });
40
+
41
+ test("isTransientPgError - idle pool message without stack", () => {
42
+ expect(
43
+ isTransientPgError({
44
+ message: "Connection terminated unexpectedly",
45
+ }),
46
+ ).toBe(true);
47
+ });
@@ -0,0 +1,56 @@
1
+ const PG_STACK_RE = /node_modules\/(pg-pool|pg)\//;
2
+
3
+ const TRANSIENT_PG_MESSAGES = [
4
+ "Connection terminated unexpectedly",
5
+ "Client has encountered a connection error",
6
+ "Client network socket disconnected before secure TLS connection",
7
+ "Query read timeout",
8
+ ] as const;
9
+
10
+ function* walkErrors(reason: unknown): Generator<unknown> {
11
+ let current: unknown = reason;
12
+ const seen = new Set<unknown>();
13
+ while (current != null && typeof current === "object" && !seen.has(current)) {
14
+ seen.add(current);
15
+ yield current;
16
+ current = (current as { cause?: unknown }).cause;
17
+ }
18
+ }
19
+
20
+ function isFromPgStack(err: unknown): boolean {
21
+ if (err == null || typeof err !== "object" || !("stack" in err)) {
22
+ return false;
23
+ }
24
+ return PG_STACK_RE.test(String((err as Error).stack));
25
+ }
26
+
27
+ function hasTransientPgMessage(err: unknown): boolean {
28
+ if (err == null || typeof err !== "object") return false;
29
+ const msg = (err as { message?: string }).message;
30
+ if (typeof msg !== "string") return false;
31
+ return TRANSIENT_PG_MESSAGES.some((fragment) => msg.includes(fragment));
32
+ }
33
+
34
+ function isTransientPgErrorSingle(err: unknown): boolean {
35
+ if (err == null || typeof err !== "object") return false;
36
+ if (hasTransientPgMessage(err)) return true;
37
+ if (!isFromPgStack(err)) return false;
38
+ const code = (err as { code?: string }).code;
39
+ return code === "ECONNRESET" || code === "EPIPE" ||
40
+ code === "ECONNREFUSED";
41
+ }
42
+
43
+ /**
44
+ * Returns true for pg / network errors that are normal on serverless Postgres
45
+ * (Neon, etc.): cold-start RSTs, idle-socket evictions, transient TLS blips.
46
+ * The pool recovers on the next query — use with `installUnhandledRejectionLogger`
47
+ * from `@effectstream/log` so these do not crash the process.
48
+ *
49
+ * Intentionally narrow: known-recoverable conditions only; logic/SQL bugs still crash.
50
+ */
51
+ export function isTransientPgError(reason: unknown): boolean {
52
+ for (const err of walkErrors(reason)) {
53
+ if (isTransientPgErrorSingle(err)) return true;
54
+ }
55
+ return false;
56
+ }
@@ -0,0 +1,63 @@
1
+ // Pins the PRODUCTION wiring used in pg-connection.ts:
2
+ // installUnhandledRejectionLogger(EFFECTSTREAM_PGLITE, isTransientPgError)
3
+ // i.e. the live `unhandledRejection` handler treats `isTransientPgError` as its
4
+ // survive predicate. This is the test @acedward asked for: prove that when
5
+ // something other than a known-transient pg error escapes to the global
6
+ // handler (e.g. a generic error torn out of the effection root), the process is
7
+ // actually told to exit — it does NOT silently log-and-survive into a zombie
8
+ // whose effection tree is dead but whose process is still up.
9
+
10
+ import { test, expect } from "bun:test";
11
+ import { buildUnhandledRejectionHandler } from "@effectstream/log";
12
+ import { isTransientPgError } from "./transient-pg-errors.ts";
13
+
14
+ const wireProductionHandler = () => {
15
+ const exits: number[] = [];
16
+ const handle = buildUnhandledRejectionHandler(
17
+ "pglite",
18
+ isTransientPgError,
19
+ (code) => exits.push(code),
20
+ );
21
+ return { handle, exits };
22
+ };
23
+
24
+ test("generic error escaping the effection root is fatal (no zombie)", () => {
25
+ const { handle, exits } = wireProductionHandler();
26
+
27
+ // What a halted effection scope surfaces: an ordinary Error, not a pg error.
28
+ handle(new Error("effection root task died"));
29
+
30
+ expect(exits).toEqual([1]);
31
+ });
32
+
33
+ test("a logic/SQL bug is fatal", () => {
34
+ const { handle, exits } = wireProductionHandler();
35
+ handle(new Error("column foo does not exist"));
36
+ expect(exits).toEqual([1]);
37
+ });
38
+
39
+ test("a genuine transient pg blip survives (the intended exemption)", () => {
40
+ const { handle, exits } = wireProductionHandler();
41
+
42
+ handle({
43
+ code: "ECONNRESET",
44
+ message:
45
+ "Client network socket disconnected before secure TLS connection was established",
46
+ stack: " at node_modules/pg-pool/index.js:45:11",
47
+ });
48
+
49
+ expect(exits).toEqual([]);
50
+ });
51
+
52
+ test("a non-pg ECONNRESET-shaped rejection is still fatal", () => {
53
+ const { handle, exits } = wireProductionHandler();
54
+
55
+ // Same code, but not from the pg stack => isTransientPgError returns false.
56
+ handle({
57
+ code: "ETIMEDOUT",
58
+ message: "fetch failed",
59
+ stack: " at node:internal/deps/undici/undici.js:1234:5",
60
+ });
61
+
62
+ expect(exits).toEqual([1]);
63
+ });