@steve31415/baselib 3.9.0 → 3.11.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.
package/dist/db.d.ts CHANGED
@@ -41,13 +41,42 @@ export interface PoolOptions {
41
41
  * `set local statement_timeout = 0` — migrate() runs each file in its own
42
42
  * transaction, so the override scopes to that migration. */
43
43
  export declare const DEFAULT_STATEMENT_TIMEOUT_MS: number;
44
- /** pg-pool re-emits an idle client's socket error as its own 'error' event —
45
- * in production "Connection terminated unexpectedly" when Cloud SQL closes an
46
- * idle connection. Unhandled, that event throws and takes the process down
47
- * (todo 2026-08-20, notes 2026-08-27 ×2). The failed client has already been
48
- * removed from the pool and the next checkout opens a fresh connection, so
49
- * the right response is to log it and carry on. createPool attaches this;
50
- * exported for apps that build their own pg.Pool. */
44
+ /** Keep a pg connection's death from killing the process, on both paths a
45
+ * client can die:
46
+ *
47
+ * - **Idle in the pool** — pg-pool re-emits the socket error as its own
48
+ * 'error' event (in production "Connection terminated unexpectedly" when
49
+ * Cloud SQL closes an idle connection). Unhandled, that event throws and
50
+ * takes the process down (todo 2026-08-20, notes 2026-08-27 ×2). The
51
+ * failed client has already been removed from the pool and the next
52
+ * checkout opens a fresh connection, so logging it is the whole response.
53
+ * - **Checked out** (`pool.connect()`) — pg-pool removes its listener at
54
+ * checkout, so the client's own 'error' event is the borrower's problem;
55
+ * with no listener it throws inside the socket callback and the process
56
+ * dies (reader2's LISTEN bridge, 2026-09-10). Every checkout gets a WARN
57
+ * listener here, attached on pg-pool's 'acquire' and removed on 'release'
58
+ * (both fire while pg-pool's own listener is still attached, so there is
59
+ * no listenerless instant). pg fails the in-flight and queued queries and
60
+ * marks the client unqueryable *before* emitting, so a transaction on the
61
+ * dead client still fails at its next await and rolls back server-side:
62
+ * nothing is silently lost on the connection itself.
63
+ *
64
+ * **What this does NOT do — read before holding an advisory lock.** A
65
+ * session-level advisory lock, or a transaction-scoped one on a client kept
66
+ * idle in `begin` while the work runs elsewhere, evaporates the moment the
67
+ * connection dies. The listener only logs; the work that believed itself
68
+ * exclusive keeps running, unaware, until it next touches the dead client.
69
+ * Another instance can take the lock and run the same work concurrently in
70
+ * the meantime. Fleet policy (Steve, 2026-09-10; Plasticine Way
71
+ * `docs/DATABASE.md`): our advisory locks limit *duplicated effort*, they
72
+ * do not guarantee mutual exclusion, and code under one must be safe to
73
+ * overlap. Long-running or expensive work under a lock should check the
74
+ * lock connection periodically (a `select 1` on it) and stop on failure —
75
+ * mirror2's ingestion loop is the reference. Do not rely on the crash that
76
+ * used to happen here as a safety mechanism.
77
+ *
78
+ * createPool attaches all of this; exported for apps that build their own
79
+ * pg.Pool. */
51
80
  export declare function logPoolErrors(pool: pg.Pool, logger?: Logger): void;
52
81
  export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
53
82
  /** End a pool and resolve only once every client's connection has actually
package/dist/db.js CHANGED
@@ -44,19 +44,70 @@ export async function cloudSqlClientOptions(instance, auth) {
44
44
  * `set local statement_timeout = 0` — migrate() runs each file in its own
45
45
  * transaction, so the override scopes to that migration. */
46
46
  export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
47
- /** pg-pool re-emits an idle client's socket error as its own 'error' event —
48
- * in production "Connection terminated unexpectedly" when Cloud SQL closes an
49
- * idle connection. Unhandled, that event throws and takes the process down
50
- * (todo 2026-08-20, notes 2026-08-27 ×2). The failed client has already been
51
- * removed from the pool and the next checkout opens a fresh connection, so
52
- * the right response is to log it and carry on. createPool attaches this;
53
- * exported for apps that build their own pg.Pool. */
47
+ /** Keep a pg connection's death from killing the process, on both paths a
48
+ * client can die:
49
+ *
50
+ * - **Idle in the pool** — pg-pool re-emits the socket error as its own
51
+ * 'error' event (in production "Connection terminated unexpectedly" when
52
+ * Cloud SQL closes an idle connection). Unhandled, that event throws and
53
+ * takes the process down (todo 2026-08-20, notes 2026-08-27 ×2). The
54
+ * failed client has already been removed from the pool and the next
55
+ * checkout opens a fresh connection, so logging it is the whole response.
56
+ * - **Checked out** (`pool.connect()`) — pg-pool removes its listener at
57
+ * checkout, so the client's own 'error' event is the borrower's problem;
58
+ * with no listener it throws inside the socket callback and the process
59
+ * dies (reader2's LISTEN bridge, 2026-09-10). Every checkout gets a WARN
60
+ * listener here, attached on pg-pool's 'acquire' and removed on 'release'
61
+ * (both fire while pg-pool's own listener is still attached, so there is
62
+ * no listenerless instant). pg fails the in-flight and queued queries and
63
+ * marks the client unqueryable *before* emitting, so a transaction on the
64
+ * dead client still fails at its next await and rolls back server-side:
65
+ * nothing is silently lost on the connection itself.
66
+ *
67
+ * **What this does NOT do — read before holding an advisory lock.** A
68
+ * session-level advisory lock, or a transaction-scoped one on a client kept
69
+ * idle in `begin` while the work runs elsewhere, evaporates the moment the
70
+ * connection dies. The listener only logs; the work that believed itself
71
+ * exclusive keeps running, unaware, until it next touches the dead client.
72
+ * Another instance can take the lock and run the same work concurrently in
73
+ * the meantime. Fleet policy (Steve, 2026-09-10; Plasticine Way
74
+ * `docs/DATABASE.md`): our advisory locks limit *duplicated effort*, they
75
+ * do not guarantee mutual exclusion, and code under one must be safe to
76
+ * overlap. Long-running or expensive work under a lock should check the
77
+ * lock connection periodically (a `select 1` on it) and stop on failure —
78
+ * mirror2's ingestion loop is the reference. Do not rely on the crash that
79
+ * used to happen here as a safety mechanism.
80
+ *
81
+ * createPool attaches all of this; exported for apps that build their own
82
+ * pg.Pool. */
54
83
  export function logPoolErrors(pool, logger) {
55
- pool.on('error', (err) => {
84
+ const warn = (message, err) => {
56
85
  if (logger)
57
- logger.warn('database pool: idle client error', { error: err });
86
+ logger.warn(message, { error: err });
58
87
  else
59
- console.warn('database pool: idle client error', serializeError(err));
88
+ console.warn(message, serializeError(err));
89
+ };
90
+ pool.on('error', (err) => warn('database pool: idle client error', err));
91
+ const listeners = new WeakMap();
92
+ pool.on('acquire', (client) => {
93
+ // pg emits twice for one death (the server's FATAL, then the socket
94
+ // close); one line per checkout is enough.
95
+ let logged = false;
96
+ const listener = (err) => {
97
+ if (logged)
98
+ return;
99
+ logged = true;
100
+ warn('database pool: checked-out client lost its connection (any transaction or advisory lock it held is gone; its next query fails)', err);
101
+ };
102
+ listeners.set(client, listener);
103
+ client.on('error', listener);
104
+ });
105
+ pool.on('release', (_err, client) => {
106
+ const listener = listeners.get(client);
107
+ if (!listener)
108
+ return;
109
+ listeners.delete(client);
110
+ client.removeListener('error', listener);
60
111
  });
61
112
  }
62
113
  export async function createPool(opts = {}) {
@@ -114,6 +165,9 @@ export async function endPool(pool) {
114
165
  // Plain-SQL migrations: numbered files (001-*.sql, 002-*.sql, …) applied in
115
166
  // order, recorded in schema_migrations, each inside a transaction, guarded by
116
167
  // an advisory lock so concurrent boots can't race. Applied at service boot.
168
+ // Lock loss (see logPoolErrors): all the work runs on the lock's own client,
169
+ // so a dead connection fails the next statement and the boot aborts — no
170
+ // window in which a migration runs unlocked.
117
171
  export async function migrate(pool, migrationsDir, logger) {
118
172
  const files = (await readdir(migrationsDir)).filter((f) => f.endsWith('.sql')).sort();
119
173
  const client = await pool.connect();
package/dist/http.d.ts CHANGED
@@ -16,7 +16,8 @@ export interface StartServerOptions {
16
16
  logger: Logger;
17
17
  port?: number;
18
18
  }
19
- /** Standard entry point: safety check, serve, graceful SIGTERM shutdown
19
+ /** Standard entry point: safety check, crash logging (logUncaughtErrors),
20
+ * serve, graceful SIGTERM shutdown
20
21
  * (stop accepting, then flush all loggers, then exit). Returns the Node
21
22
  * server so callers can attach protocol upgrades (e.g. @hono/node-ws's
22
23
  * injectWebSocket). */
package/dist/http.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { createMiddleware } from 'hono/factory';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { assertBootSafety } from './config.js';
6
- import { flushAllLoggers } from './log.js';
6
+ import { flushAllLoggers, logUncaughtErrors } from './log.js';
7
7
  import { truncate } from './log-core.js';
8
8
  /** One INFO line per request on exit (method, path, status, elapsed,
9
9
  * identity); /health excluded. Entry-point detail beyond this (key
@@ -45,12 +45,14 @@ export function errorHandler(logger) {
45
45
  : c.text(`Internal error: ${String(err.message ?? err)}`, 500);
46
46
  };
47
47
  }
48
- /** Standard entry point: safety check, serve, graceful SIGTERM shutdown
48
+ /** Standard entry point: safety check, crash logging (logUncaughtErrors),
49
+ * serve, graceful SIGTERM shutdown
49
50
  * (stop accepting, then flush all loggers, then exit). Returns the Node
50
51
  * server so callers can attach protocol upgrades (e.g. @hono/node-ws's
51
52
  * injectWebSocket). */
52
53
  export function startServer(opts) {
53
54
  assertBootSafety();
55
+ logUncaughtErrors(opts.logger);
54
56
  const port = opts.port ?? Number(process.env.PORT ?? 8080);
55
57
  const server = serve({ fetch: opts.app.fetch, port }, (info) => {
56
58
  opts.logger.info('server started', { port: info.port });
package/dist/log.d.ts CHANGED
@@ -24,6 +24,21 @@ export declare function registerShipper(shipper: LogShipper): void;
24
24
  export declare function flushAllLoggers(opts?: {
25
25
  final?: boolean;
26
26
  }): Promise<void>;
27
+ /** Make a crash visible where triage looks. Node's default for an uncaught
28
+ * exception (and, under the default `--unhandled-rejections=throw`, an
29
+ * unhandled rejection) is a stack on stderr and exit: on the Cloud Logging
30
+ * backstop, but never an Axiom ERROR — an app could crash-loop with nothing
31
+ * in the dataset the monitors watch (the fleet's pg connection-loss crashes
32
+ * were found by hand, 2026-09-10). This logs the crash at ERROR, flushes
33
+ * every registered shipper (time-boxed), then exits 1. It never keeps the
34
+ * process alive: a failure while flushing exits at once. startServer
35
+ * installs it; jobs and scripts call it themselves after createLogger. */
36
+ export declare function logUncaughtErrors(logger: Logger, opts?: {
37
+ exit?: (code: number) => void;
38
+ }): void;
39
+ /** The listener logUncaughtErrors installs, separate for testing without
40
+ * touching the real process. */
41
+ export declare function uncaughtErrorHandler(logger: Logger, exit: (code: number) => void): (err: unknown, origin: string) => void;
27
42
  /** Ship-failure marker callbacks, writing one JSON line per event to stdout.
28
43
  * writeSync, not process.stdout.write: the markers must survive an
29
44
  * immediately following process.exit(), which can truncate async pipe
package/dist/log.js CHANGED
@@ -26,6 +26,34 @@ export function registerShipper(shipper) {
26
26
  export async function flushAllLoggers(opts = {}) {
27
27
  await Promise.all([...allShippers].map((s) => (opts.final ? s.finalFlush() : s.flush())));
28
28
  }
29
+ /** Make a crash visible where triage looks. Node's default for an uncaught
30
+ * exception (and, under the default `--unhandled-rejections=throw`, an
31
+ * unhandled rejection) is a stack on stderr and exit: on the Cloud Logging
32
+ * backstop, but never an Axiom ERROR — an app could crash-loop with nothing
33
+ * in the dataset the monitors watch (the fleet's pg connection-loss crashes
34
+ * were found by hand, 2026-09-10). This logs the crash at ERROR, flushes
35
+ * every registered shipper (time-boxed), then exits 1. It never keeps the
36
+ * process alive: a failure while flushing exits at once. startServer
37
+ * installs it; jobs and scripts call it themselves after createLogger. */
38
+ export function logUncaughtErrors(logger, opts = {}) {
39
+ process.on('uncaughtException', uncaughtErrorHandler(logger, opts.exit ?? ((code) => process.exit(code))));
40
+ }
41
+ const CRASH_FLUSH_DEADLINE_MS = 3_000;
42
+ /** The listener logUncaughtErrors installs, separate for testing without
43
+ * touching the real process. */
44
+ export function uncaughtErrorHandler(logger, exit) {
45
+ let crashing = false;
46
+ return (err, origin) => {
47
+ if (crashing)
48
+ return exit(1);
49
+ crashing = true;
50
+ console.error(err); // the stack on stderr, as Node would have printed it
51
+ logger.error('uncaught exception, exiting', { error: err, origin });
52
+ const done = () => exit(1);
53
+ setTimeout(done, CRASH_FLUSH_DEADLINE_MS);
54
+ flushAllLoggers({ final: true }).then(done, done);
55
+ };
56
+ }
29
57
  /** Ship-failure marker callbacks, writing one JSON line per event to stdout.
30
58
  * writeSync, not process.stdout.write: the markers must survive an
31
59
  * immediately following process.exit(), which can truncate async pipe
@@ -3,7 +3,11 @@ const PING_INTERVAL_MS = 30_000;
3
3
  const CONNECT_WATCHDOG_MS = 10_000;
4
4
  const DEFAULT_RECONNECT_BEFORE_MS = 55 * 60 * 1000;
5
5
  const FAILURE_TIMEOUT_MS = 60_000;
6
- const OUTAGE_ESCALATE_MS = 30_000;
6
+ // ERROR only after a sustained foreground outage. The fleet is single-user
7
+ // and triage runs daily, so detection latency is cheap; short client-network
8
+ // blips (Wi-Fi roam, VPN re-auth — observed 30-40 s) must not page as ERROR
9
+ // (Steve, 2026-09-11; raised from 30 s, which flagged those blips).
10
+ const OUTAGE_ESCALATE_MS = 120_000;
7
11
  const CLOSE_NO_USER = 4403;
8
12
  const PROBE_AFTER_ATTEMPTS = 2;
9
13
  const SOCKET_OPEN = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",