@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 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
- * - `wait`: the checkout timed out waiting for a free client. Pool
119
- * starvation, not loss: the pool's `max` or a client held too long.
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 and, given a Cloud SQL credential reader,
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
- * - `wait`: the checkout timed out waiting for a free client. Pool
152
- * starvation, not loss: the pool's `max` or a client held too long.
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 and, given a Cloud SQL credential reader,
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
- const phase = message === 'timeout exceeded when trying to connect' ? 'wait' : 'connect';
228
- warn(phase, ACQUIRE_FAILED_MESSAGE[phase], err, started);
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
- let open = pool.totalCount;
359
- const allClosed = new Promise((resolve) => {
360
- if (open === 0)
361
- return resolve();
362
- pool.on('remove', () => {
363
- if (--open === 0)
364
- resolve();
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: { 'Content-Type': 'application/json', Authorization: `Bearer ${deps.apiKey}` },
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
- logger.error('email_send_error', { ...base, error: serializeError(err), elapsedMs, ...preview });
89
- return { ok: false, error: `Network error: ${err instanceof Error ? err.message : String(err)}` };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.49.0",
3
+ "version": "3.51.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",