@steve31415/baselib 3.49.0 → 3.51.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 +19 -4
- package/dist/db.js +52 -15
- package/dist/email.d.ts +16 -1
- package/dist/email.js +31 -4
- package/package.json +1 -1
package/dist/db.d.ts
CHANGED
|
@@ -115,8 +115,16 @@ export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
|
|
|
115
115
|
* - `connect`: a new connection failed to establish (handshake dropped,
|
|
116
116
|
* credential rejected, `connectionTimeoutMillis` hit mid-handshake).
|
|
117
117
|
* No statement ran, so the operation is safe to retry.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
118
|
+
* Also a checkout that timed out while the pool was still opening a
|
|
119
|
+
* connection (`pool.connecting` > 0, message "a new connection did not
|
|
120
|
+
* finish within the checkout timeout"): pg-pool queues a checkout that
|
|
121
|
+
* finds an idle client, and if another checkout takes that client
|
|
122
|
+
* first it hands the queued one a new connection, so a stalled
|
|
123
|
+
* handshake surfaces with pg-pool's starvation error (coder
|
|
124
|
+
* 2026-11-02: total 4 of 6, 3 idle, a 15 s Cloud SQL handshake).
|
|
125
|
+
* - `wait`: the checkout timed out waiting for a free client with no
|
|
126
|
+
* connection being opened. Pool starvation, not loss: the pool's
|
|
127
|
+
* `max` or a client held too long.
|
|
120
128
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
121
129
|
* next checkout opens a fresh one. Harmless by construction. With
|
|
122
130
|
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
@@ -127,7 +135,8 @@ export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
|
|
|
127
135
|
* any transaction or advisory lock it held is gone.
|
|
128
136
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
129
137
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
130
|
-
* pool's counts
|
|
138
|
+
* pool's counts (`total`, `idle`, `waiting`, `connecting`, `max`) and,
|
|
139
|
+
* given a Cloud SQL credential reader,
|
|
131
140
|
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
132
141
|
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
133
142
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
@@ -169,7 +178,13 @@ export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
|
|
|
169
178
|
* drops the database `with (force)` can kill a backend mid-goodbye, and the
|
|
170
179
|
* FATAL it sends back surfaces as an unhandled pool 'error' (it took down a
|
|
171
180
|
* todo2 deploy with every test green, 2026-08-20). pg-pool emits 'remove' for
|
|
172
|
-
* each client once its socket has closed; wait for all of them.
|
|
181
|
+
* each client once its socket has closed; wait for all of them.
|
|
182
|
+
*
|
|
183
|
+
* Only a client that finished connecting gets a 'remove': one still being
|
|
184
|
+
* opened that then fails leaves the pool silently, so counting it (as
|
|
185
|
+
* `totalCount` does) waited forever for an event that never came (found
|
|
186
|
+
* 2026-11-02 with a stalled handshake). Count the connected clients, and
|
|
187
|
+
* any that finish connecting while the pool ends. */
|
|
173
188
|
export declare function endPool(pool: pg.Pool): Promise<void>;
|
|
174
189
|
export declare function migrate(pool: pg.Pool, migrationsDir: string, logger?: Logger): Promise<{
|
|
175
190
|
applied: string[];
|
package/dist/db.js
CHANGED
|
@@ -112,7 +112,20 @@ const CONNECTION_LOSS_KIND = 'db-connection-loss';
|
|
|
112
112
|
const ACQUIRE_FAILED_MESSAGE = {
|
|
113
113
|
connect: 'database pool: new connection failed',
|
|
114
114
|
wait: 'database pool: checkout timed out waiting for a free client',
|
|
115
|
+
stalled: 'database pool: a new connection did not finish within the checkout timeout',
|
|
115
116
|
};
|
|
117
|
+
/** pg-pool's client list, read for diagnostics only (pg-pool 3.x
|
|
118
|
+
* `_clients`, pg 8.x `_connecting` / `_connected`). Undefined when a
|
|
119
|
+
* version moves them; callers then fall back to the public counts. */
|
|
120
|
+
function poolClients(pool) {
|
|
121
|
+
const clients = pool._clients;
|
|
122
|
+
return Array.isArray(clients) ? clients : undefined;
|
|
123
|
+
}
|
|
124
|
+
/** Clients the pool is still opening: counted in `totalCount`, neither idle
|
|
125
|
+
* nor lent out. */
|
|
126
|
+
function connectingCount(pool) {
|
|
127
|
+
return poolClients(pool)?.filter((c) => c._connecting === true).length;
|
|
128
|
+
}
|
|
116
129
|
const instrumented = new WeakSet();
|
|
117
130
|
// Clients under an idle probe: their death is reported once, by the probe,
|
|
118
131
|
// as an idle loss rather than a checked-out one.
|
|
@@ -127,7 +140,7 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
127
140
|
...extra,
|
|
128
141
|
...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
|
|
129
142
|
code: err.code,
|
|
130
|
-
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
|
143
|
+
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, connecting: connectingCount(pool), max: pool.options.max },
|
|
131
144
|
...(credentialExpiry
|
|
132
145
|
? expiry === undefined
|
|
133
146
|
? { credentialUnknown: true }
|
|
@@ -148,8 +161,16 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
148
161
|
* - `connect`: a new connection failed to establish (handshake dropped,
|
|
149
162
|
* credential rejected, `connectionTimeoutMillis` hit mid-handshake).
|
|
150
163
|
* No statement ran, so the operation is safe to retry.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
164
|
+
* Also a checkout that timed out while the pool was still opening a
|
|
165
|
+
* connection (`pool.connecting` > 0, message "a new connection did not
|
|
166
|
+
* finish within the checkout timeout"): pg-pool queues a checkout that
|
|
167
|
+
* finds an idle client, and if another checkout takes that client
|
|
168
|
+
* first it hands the queued one a new connection, so a stalled
|
|
169
|
+
* handshake surfaces with pg-pool's starvation error (coder
|
|
170
|
+
* 2026-11-02: total 4 of 6, 3 idle, a 15 s Cloud SQL handshake).
|
|
171
|
+
* - `wait`: the checkout timed out waiting for a free client with no
|
|
172
|
+
* connection being opened. Pool starvation, not loss: the pool's
|
|
173
|
+
* `max` or a client held too long.
|
|
153
174
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
154
175
|
* next checkout opens a fresh one. Harmless by construction. With
|
|
155
176
|
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
@@ -160,7 +181,8 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
160
181
|
* any transaction or advisory lock it held is gone.
|
|
161
182
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
162
183
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
163
|
-
* pool's counts
|
|
184
|
+
* pool's counts (`total`, `idle`, `waiting`, `connecting`, `max`) and,
|
|
185
|
+
* given a Cloud SQL credential reader,
|
|
164
186
|
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
165
187
|
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
166
188
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
@@ -224,8 +246,11 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
224
246
|
// A checkout after end() is the caller's shutdown bug, not a lost connection.
|
|
225
247
|
if (message.startsWith('Cannot use a pool after calling end'))
|
|
226
248
|
return;
|
|
227
|
-
|
|
228
|
-
|
|
249
|
+
if (message !== 'timeout exceeded when trying to connect')
|
|
250
|
+
return warn('connect', ACQUIRE_FAILED_MESSAGE.connect, err, started);
|
|
251
|
+
// The pool's own timeout: starvation only if nothing was being opened.
|
|
252
|
+
const opening = (connectingCount(pool) ?? 0) > 0;
|
|
253
|
+
warn(opening ? 'connect' : 'wait', opening ? ACQUIRE_FAILED_MESSAGE.stalled : ACQUIRE_FAILED_MESSAGE.wait, err, started);
|
|
229
254
|
};
|
|
230
255
|
pool.connect = ((cb) => {
|
|
231
256
|
const started = Date.now();
|
|
@@ -353,18 +378,30 @@ export async function createPool(opts = {}) {
|
|
|
353
378
|
* drops the database `with (force)` can kill a backend mid-goodbye, and the
|
|
354
379
|
* FATAL it sends back surfaces as an unhandled pool 'error' (it took down a
|
|
355
380
|
* todo2 deploy with every test green, 2026-08-20). pg-pool emits 'remove' for
|
|
356
|
-
* each client once its socket has closed; wait for all of them.
|
|
381
|
+
* each client once its socket has closed; wait for all of them.
|
|
382
|
+
*
|
|
383
|
+
* Only a client that finished connecting gets a 'remove': one still being
|
|
384
|
+
* opened that then fails leaves the pool silently, so counting it (as
|
|
385
|
+
* `totalCount` does) waited forever for an event that never came (found
|
|
386
|
+
* 2026-11-02 with a stalled handshake). Count the connected clients, and
|
|
387
|
+
* any that finish connecting while the pool ends. */
|
|
357
388
|
export async function endPool(pool) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
const clients = poolClients(pool);
|
|
390
|
+
let open = clients ? clients.filter((c) => c._connected === true).length : pool.totalCount;
|
|
391
|
+
let resolveAll;
|
|
392
|
+
const allClosed = new Promise((resolve) => (resolveAll = resolve));
|
|
393
|
+
const settle = () => {
|
|
394
|
+
if (open <= 0)
|
|
395
|
+
resolveAll();
|
|
396
|
+
};
|
|
397
|
+
pool.on('connect', () => open++);
|
|
398
|
+
pool.on('remove', () => {
|
|
399
|
+
open--;
|
|
400
|
+
settle();
|
|
366
401
|
});
|
|
367
402
|
await pool.end();
|
|
403
|
+
// No client can start connecting once end() has resolved.
|
|
404
|
+
settle();
|
|
368
405
|
await allClosed;
|
|
369
406
|
}
|
|
370
407
|
// Plain-SQL migrations: numbered files (001-*.sql, 002-*.sql, …) applied in
|
package/dist/email.d.ts
CHANGED
|
@@ -36,10 +36,25 @@ export interface EmailDeps {
|
|
|
36
36
|
fetchImpl?: typeof fetch;
|
|
37
37
|
/** Merged into every log line so callers can attribute the send. */
|
|
38
38
|
context?: Record<string, unknown>;
|
|
39
|
+
/** Whole-request deadline (connect, response and body), default
|
|
40
|
+
* `DEFAULT_EMAIL_TIMEOUT_MS`. A timed-out send is an ordinary failure,
|
|
41
|
+
* but Resend may have accepted it and delivered the email: retry with
|
|
42
|
+
* the same `idempotencyKey` to be safe. */
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
/** Sent as Resend's `Idempotency-Key` HTTP request header (not an email
|
|
45
|
+
* header — those are `EmailMessage.headers`). Up to 256 characters; for
|
|
46
|
+
* 24 hours a retry with the same key and the same message returns the
|
|
47
|
+
* original send's id without sending again. Derive it from what makes
|
|
48
|
+
* the email unique (e.g. `digest:<user>:<date>`), never from the
|
|
49
|
+
* attempt. See the header of this file for the verified semantics. */
|
|
50
|
+
idempotencyKey?: string;
|
|
39
51
|
}
|
|
52
|
+
export declare const DEFAULT_EMAIL_TIMEOUT_MS = 30000;
|
|
40
53
|
/**
|
|
41
54
|
* Send one email through Resend. Never throws: every failure is an ERROR
|
|
42
55
|
* log (with enough of the message to redo the send by hand) and an
|
|
43
|
-
* `{ ok: false, error }` result the caller surfaces where it matters.
|
|
56
|
+
* `{ ok: false, error }` result the caller surfaces where it matters. That
|
|
57
|
+
* includes a timeout (`deps.timeoutMs`), after which the email may
|
|
58
|
+
* nevertheless have been delivered.
|
|
44
59
|
*/
|
|
45
60
|
export declare function sendEmail(msg: EmailMessage, deps: EmailDeps): Promise<SendEmailResult>;
|
package/dist/email.js
CHANGED
|
@@ -14,22 +14,38 @@
|
|
|
14
14
|
// about external systems"): `POST https://api.resend.com/emails` with
|
|
15
15
|
// `{from, to: [to], subject, text?, html?}` answers 200 `{id}`; failures are
|
|
16
16
|
// 4xx/5xx with a JSON body.
|
|
17
|
+
//
|
|
18
|
+
// Idempotency (verified 2026-11-02 against
|
|
19
|
+
// https://resend.com/docs/dashboard/emails/idempotency-keys and a live probe
|
|
20
|
+
// to the `delivered@resend.dev` test sink): the HTTP request header
|
|
21
|
+
// `Idempotency-Key`, 1–256 characters, kept 24 hours. A repeat with the same
|
|
22
|
+
// key and the same body sends nothing and answers 200 with the original
|
|
23
|
+
// `{id}`, so a replayed send is an ordinary success here. A repeat with a
|
|
24
|
+
// different body is 409 `invalid_idempotent_request`; one that arrives while
|
|
25
|
+
// the first is still in flight is 409 `concurrent_idempotent_requests`
|
|
26
|
+
// (retry later); a key over 256 characters is 400 `invalid_idempotency_key`.
|
|
27
|
+
// All three are failures: none confirms that this message was accepted.
|
|
17
28
|
import { serializeError, truncate } from './log-core.js';
|
|
18
29
|
export const RESEND_API_URL = 'https://api.resend.com/emails';
|
|
30
|
+
export const DEFAULT_EMAIL_TIMEOUT_MS = 30_000;
|
|
19
31
|
/**
|
|
20
32
|
* Send one email through Resend. Never throws: every failure is an ERROR
|
|
21
33
|
* log (with enough of the message to redo the send by hand) and an
|
|
22
|
-
* `{ ok: false, error }` result the caller surfaces where it matters.
|
|
34
|
+
* `{ ok: false, error }` result the caller surfaces where it matters. That
|
|
35
|
+
* includes a timeout (`deps.timeoutMs`), after which the email may
|
|
36
|
+
* nevertheless have been delivered.
|
|
23
37
|
*/
|
|
24
38
|
export async function sendEmail(msg, deps) {
|
|
25
39
|
const { logger } = deps;
|
|
26
40
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
41
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS;
|
|
27
42
|
const base = {
|
|
28
43
|
to: msg.to,
|
|
29
44
|
subject: truncate(msg.subject, 200),
|
|
30
45
|
textLength: msg.text?.length ?? 0,
|
|
31
46
|
htmlLength: msg.html?.length ?? 0,
|
|
32
47
|
attachmentCount: msg.attachments?.length ?? 0,
|
|
48
|
+
...(deps.idempotencyKey !== undefined ? { idempotencyKey: deps.idempotencyKey } : {}),
|
|
33
49
|
...deps.context,
|
|
34
50
|
};
|
|
35
51
|
const preview = { text: truncate(msg.text ?? '', 500), html: truncate(msg.html ?? '', 500) };
|
|
@@ -47,7 +63,12 @@ export async function sendEmail(msg, deps) {
|
|
|
47
63
|
try {
|
|
48
64
|
const res = await fetchImpl(RESEND_API_URL, {
|
|
49
65
|
method: 'POST',
|
|
50
|
-
headers: {
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Authorization: `Bearer ${deps.apiKey}`,
|
|
69
|
+
...(deps.idempotencyKey !== undefined ? { 'Idempotency-Key': deps.idempotencyKey } : {}),
|
|
70
|
+
},
|
|
71
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
51
72
|
body: JSON.stringify({
|
|
52
73
|
from: msg.from,
|
|
53
74
|
to: [msg.to],
|
|
@@ -85,7 +106,13 @@ export async function sendEmail(msg, deps) {
|
|
|
85
106
|
}
|
|
86
107
|
catch (err) {
|
|
87
108
|
const elapsedMs = Date.now() - start;
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
|
110
|
+
logger.error('email_send_error', { ...base, error: serializeError(err), timedOut, timeoutMs, elapsedMs, ...preview });
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: timedOut
|
|
114
|
+
? `Timed out after ${timeoutMs} ms waiting for Resend; the email may still have been delivered`
|
|
115
|
+
: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
116
|
+
};
|
|
90
117
|
}
|
|
91
118
|
}
|
package/package.json
CHANGED