@steve31415/baselib 3.50.0 → 3.52.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
@@ -72,7 +72,15 @@ export interface PoolLoggingOptions {
72
72
  /** Epoch ms when the connector's cached Cloud SQL credential expires
73
73
  * (`cloudSqlCredentialExpiry`); logged as `credentialRemainingMs`. */
74
74
  credentialExpiry?: () => number | undefined;
75
+ /** A new connection that takes at least this long, succeeding or
76
+ * failing, logs its handshake timings (`timeNewConnections`). Default
77
+ * `SLOW_CONNECT_MS`. */
78
+ slowConnectMs?: number;
75
79
  }
80
+ /** A healthy Cloud SQL connect takes ~80 ms (2026-10-30); 2 s is well
81
+ * outside that and well inside the 15 s checkout timeout, so a stall that
82
+ * the timeout later kills has already been seen to be slow. */
83
+ export declare const SLOW_CONNECT_MS = 2000;
76
84
  /** Pool settings every fleet pool carries; `createPool` applies them and
77
85
  * is the way to get them (a hand-built pool spreads this, but prefer
78
86
  * `createPool` with `user` / `sessionOptions` over building one). Decided
package/dist/db.js CHANGED
@@ -71,6 +71,10 @@ export function cloudSqlCredentialExpiry(connector, instanceOptions) {
71
71
  * `set local statement_timeout = 0` — migrate() runs each file in its own
72
72
  * transaction, so the override scopes to that migration. */
73
73
  export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
74
+ /** A healthy Cloud SQL connect takes ~80 ms (2026-10-30); 2 s is well
75
+ * outside that and well inside the 15 s checkout timeout, so a stall that
76
+ * the timeout later kills has already been seen to be slow. */
77
+ export const SLOW_CONNECT_MS = 2_000;
74
78
  /** Pool settings every fleet pool carries; `createPool` applies them and
75
79
  * is the way to get them (a hand-built pool spreads this, but prefer
76
80
  * `createPool` with `user` / `sessionOptions` over building one). Decided
@@ -203,6 +207,7 @@ export function logPoolErrors(pool, logger, options = {}) {
203
207
  return;
204
208
  instrumented.add(pool);
205
209
  const warn = connectionLossWarner(pool, logger, options.credentialExpiry);
210
+ timeNewConnections(pool, logger, options);
206
211
  // Per-client bookkeeping across one checkout: the borrower-side error
207
212
  // listener (pg-pool drops its own at checkout) and when the client was
208
213
  // taken and given back.
@@ -267,6 +272,89 @@ export function logPoolErrors(pool, logger, options = {}) {
267
272
  });
268
273
  });
269
274
  }
275
+ const CONNECT_TIMING_MESSAGE = 'database pool: new connection was slow or failed; handshake timings';
276
+ /** Time every new connection's handshake and log any that took
277
+ * `slowConnectMs` or longer, whether it then succeeded or failed (INFO,
278
+ * `kind=db-connect-timing`): how far it got (`reached`) and when
279
+ * each step finished, in ms from the start — `tcpMs` (socket connected),
280
+ * `tlsMs` (TLS done; the Cloud SQL connector's socket only), `authMs`
281
+ * (the server accepted the login; with IAM authentication Cloud SQL checks
282
+ * the token here). The point is the step a stall sits in: before `tcp` is
283
+ * the network path, between `tcp` and `tls` the Cloud SQL front end or the
284
+ * certificate, between `tls`/`tcp` and `authenticated` the database login.
285
+ * pg-pool's own events cannot say this, and a handshake whose checkout
286
+ * already timed out ends silently there (coder 2026-11-02 19:07Z: a 15 s
287
+ * Cloud SQL handshake left only the waiter's timeout behind). The
288
+ * waiter's WARN (`db-connection-loss`) stays the alarm; this is its
289
+ * detail, hence INFO. Works by subclassing the pool's client class
290
+ * (pg-pool reads `pool.Client` for each new connection; pg 8.x creates
291
+ * the socket in the client's constructor). */
292
+ function timeNewConnections(pool, logger, options) {
293
+ const slowMs = options.slowConnectMs ?? SLOW_CONNECT_MS;
294
+ const holder = pool;
295
+ const Base = holder.Client;
296
+ if (typeof Base !== 'function')
297
+ return;
298
+ const log = logger?.with({ kind: 'db-connect-timing' });
299
+ holder.Client = class TimedClient extends Base {
300
+ #started = Date.now();
301
+ #marks = {};
302
+ #reached = 'started';
303
+ constructor(...args) {
304
+ super(...args);
305
+ const connection = this.connection;
306
+ const mark = (step) => () => {
307
+ this.#marks[step] ??= Date.now() - this.#started;
308
+ this.#reached = step;
309
+ };
310
+ connection?.stream?.once?.('connect', mark('tcp'));
311
+ connection?.stream?.once?.('secureConnect', mark('tls'));
312
+ connection?.once?.('authenticationOk', mark('authenticated'));
313
+ }
314
+ #report(err) {
315
+ const elapsedMs = Date.now() - this.#started;
316
+ // A quick failure (refused, rejected) has the waiter's WARN and no
317
+ // stall to locate.
318
+ if (elapsedMs < slowMs)
319
+ return;
320
+ if (!err)
321
+ this.#reached = 'ready';
322
+ const expiry = options.credentialExpiry?.();
323
+ const meta = {
324
+ outcome: err ? 'failed' : 'slow',
325
+ reached: this.#reached,
326
+ elapsedMs,
327
+ tcpMs: this.#marks.tcp,
328
+ tlsMs: this.#marks.tls,
329
+ authMs: this.#marks.authenticated,
330
+ pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, max: pool.options.max },
331
+ ...(options.credentialExpiry ? (expiry === undefined ? { credentialUnknown: true } : { credentialRemainingMs: expiry - Date.now() }) : {}),
332
+ ...(err ? { error: err } : {}),
333
+ };
334
+ if (log)
335
+ log.info(CONNECT_TIMING_MESSAGE, meta);
336
+ else
337
+ console.info(CONNECT_TIMING_MESSAGE, { kind: 'db-connect-timing', ...processMeta(), ...meta, ...(err ? { error: serializeError(err) } : {}) });
338
+ }
339
+ connect(callback) {
340
+ if (typeof callback === 'function') {
341
+ // pg calls back (null, client) on success; pass every argument on.
342
+ const done = callback;
343
+ return super.connect(((err, ...rest) => {
344
+ this.#report(err ?? undefined);
345
+ done(err, ...rest);
346
+ }));
347
+ }
348
+ return super.connect().then((client) => {
349
+ this.#report(undefined);
350
+ return client;
351
+ }, (err) => {
352
+ this.#report(err);
353
+ throw err;
354
+ });
355
+ }
356
+ };
357
+ }
270
358
  const probed = new WeakSet();
271
359
  /** Hand out no pooled client that died while idle. A client idle for
272
360
  * `afterIdleMs` or more runs `select 1` (bounded by `timeoutMs`) before
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.50.0",
3
+ "version": "3.52.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",