@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 +36 -7
- package/dist/db.js +64 -10
- package/dist/http.d.ts +2 -1
- package/dist/http.js +4 -2
- package/dist/log.d.ts +15 -0
- package/dist/log.js +28 -0
- package/dist/sync-browser/client.js +5 -1
- package/package.json +1 -1
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
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
84
|
+
const warn = (message, err) => {
|
|
56
85
|
if (logger)
|
|
57
|
-
logger.warn(
|
|
86
|
+
logger.warn(message, { error: err });
|
|
58
87
|
else
|
|
59
|
-
console.warn(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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