@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 +8 -0
- package/dist/db.js +88 -0
- package/dist/email.d.ts +16 -1
- package/dist/email.js +31 -4
- package/package.json +1 -1
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: {
|
|
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