@app-vitals/agent-lib 0.0.1 → 0.1.1
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/admin-types.ts +4097 -0
- package/chat-notify.ts +41 -0
- package/chat-notify.unit.test.ts +88 -0
- package/claim-ttl.ts +12 -0
- package/claim-ttl.unit.test.ts +26 -0
- package/claude-timeout.ts +10 -0
- package/claude-timeout.unit.test.ts +8 -0
- package/cli-flags.ts +83 -0
- package/cli-flags.unit.test.ts +137 -0
- package/clone-plan.ts +73 -0
- package/clone-plan.unit.test.ts +69 -0
- package/default-account.ts +5 -0
- package/default-account.unit.test.ts +8 -0
- package/default-agent-env.ts +15 -0
- package/default-agent-env.unit.test.ts +19 -0
- package/github-login.ts +20 -0
- package/github-login.unit.test.ts +78 -0
- package/graceful-shutdown.ts +143 -0
- package/graceful-shutdown.unit.test.ts +236 -0
- package/http.ts +14 -0
- package/http.unit.test.ts +42 -0
- package/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package-exports.unit.test.ts +21 -0
- package/package.json +36 -4
- package/pg-sslmode.ts +66 -0
- package/pg-sslmode.unit.test.ts +74 -0
- package/pool-observability.ts +209 -0
- package/pool-observability.unit.test.ts +233 -0
- package/pricing.ts +98 -0
- package/pricing.unit.test.ts +214 -0
- package/progress-phases.ts +58 -0
- package/progress-phases.unit.test.ts +50 -0
- package/request-context.ts +23 -0
- package/request-context.unit.test.ts +51 -0
- package/secret-env-vars.ts +41 -0
- package/secret-env-vars.unit.test.ts +62 -0
- package/sentry.ts +201 -0
- package/sentry.unit.test.ts +294 -0
- package/task-store-types.ts +3256 -0
- package/task-store-types.unit.test.ts +24 -0
- package/web/toolbar.ts +244 -0
- package/web/toolbar.unit.test.ts +243 -0
- package/README.md +0 -3
package/pg-sslmode.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pg-sslmode.ts
|
|
3
|
+
*
|
|
4
|
+
* Pins node-postgres's TLS behavior for a Postgres connection string.
|
|
5
|
+
*
|
|
6
|
+
* `pg` 8.x (via pg-connection-string 2.x) treats `sslmode=prefer`, `require`,
|
|
7
|
+
* and `verify-ca` as aliases for `verify-full` — the server certificate chain
|
|
8
|
+
* and hostname are both verified — and logs a SECURITY WARNING saying the next
|
|
9
|
+
* major (pg 9 / pg-connection-string 3) will switch them to libpq semantics,
|
|
10
|
+
* where `require` means "encrypt, verify nothing". Upgrading `pg` would then
|
|
11
|
+
* silently drop certificate verification for every deployment whose URL says
|
|
12
|
+
* `sslmode=require`.
|
|
13
|
+
*
|
|
14
|
+
* Rewriting those modes to `verify-full` in the string handed to `pg` keeps
|
|
15
|
+
* today's (verifying) behavior across that upgrade and silences the warning.
|
|
16
|
+
* It is applied only to the `pg.Pool` connection string — never to the env var
|
|
17
|
+
* itself — because the same URL is also read by the Prisma CLI
|
|
18
|
+
* (`prisma migrate deploy` via prisma.config.ts), whose engine recognises only
|
|
19
|
+
* `disable|prefer|require` and would read `verify-full` as `prefer`.
|
|
20
|
+
*
|
|
21
|
+
* Left untouched:
|
|
22
|
+
* - URLs with no `sslmode`, or `disable` / `verify-full` / `no-verify`
|
|
23
|
+
* (none of them warn, and none change meaning in pg 9);
|
|
24
|
+
* - URLs that set `uselibpqcompat=true`: that is an explicit opt-in to libpq
|
|
25
|
+
* semantics, which this must not override;
|
|
26
|
+
* - strings that are not parseable as a URL (pg accepts other forms; pass
|
|
27
|
+
* them through for pg to handle or reject).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The sslmode values pg 8 aliases to `verify-full` (and warns about). */
|
|
31
|
+
const LEGACY_VERIFY_FULL_ALIASES = new Set(["prefer", "require", "verify-ca"]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns `connectionString` with a legacy `sslmode` alias replaced by
|
|
35
|
+
* `verify-full`, or the input unchanged when no rewrite applies.
|
|
36
|
+
*/
|
|
37
|
+
export function pinPgSslMode(connectionString: string): string {
|
|
38
|
+
let url: URL;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(connectionString);
|
|
41
|
+
} catch {
|
|
42
|
+
return connectionString;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve the effective `sslmode` the same way pg-connection-string's
|
|
46
|
+
// parse() does: iterate every query param in document order and let later
|
|
47
|
+
// entries overwrite earlier ones (`config[entry[0]] = entry[1]`). A
|
|
48
|
+
// duplicate `sslmode` therefore resolves to its LAST occurrence — not the
|
|
49
|
+
// first, which is what `URLSearchParams#get()` would return — so this must
|
|
50
|
+
// not use `.get()` here.
|
|
51
|
+
let sslmode: string | null = null;
|
|
52
|
+
for (const [key, value] of url.searchParams) {
|
|
53
|
+
if (key === "sslmode") {
|
|
54
|
+
sslmode = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (sslmode === null || !LEGACY_VERIFY_FULL_ALIASES.has(sslmode)) {
|
|
58
|
+
return connectionString;
|
|
59
|
+
}
|
|
60
|
+
if (url.searchParams.get("uselibpqcompat") === "true") {
|
|
61
|
+
return connectionString;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
url.searchParams.set("sslmode", "verify-full");
|
|
65
|
+
return url.toString();
|
|
66
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pinPgSslMode } from "./pg-sslmode.ts";
|
|
3
|
+
|
|
4
|
+
const BASE = "postgresql://user:pass@db.example.test:5432/app";
|
|
5
|
+
|
|
6
|
+
describe("pinPgSslMode — legacy aliases are pinned to verify-full", () => {
|
|
7
|
+
for (const mode of ["prefer", "require", "verify-ca"]) {
|
|
8
|
+
test(`rewrites sslmode=${mode} to sslmode=verify-full`, () => {
|
|
9
|
+
const pinned = new URL(pinPgSslMode(`${BASE}?sslmode=${mode}`));
|
|
10
|
+
expect(pinned.searchParams.get("sslmode")).toBe("verify-full");
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("preserves credentials, host, database, and other params", () => {
|
|
15
|
+
const pinned = new URL(
|
|
16
|
+
pinPgSslMode(`${BASE}?connection_limit=5&sslmode=require&schema=public`),
|
|
17
|
+
);
|
|
18
|
+
expect(pinned.username).toBe("user");
|
|
19
|
+
expect(pinned.password).toBe("pass");
|
|
20
|
+
expect(pinned.host).toBe("db.example.test:5432");
|
|
21
|
+
expect(pinned.pathname).toBe("/app");
|
|
22
|
+
expect(pinned.searchParams.get("connection_limit")).toBe("5");
|
|
23
|
+
expect(pinned.searchParams.get("schema")).toBe("public");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("keeps a URL-encoded password byte-for-byte when rewriting", () => {
|
|
27
|
+
const pinned = pinPgSslMode(
|
|
28
|
+
"postgresql://user:p%40ss%2Fw@localhost:5432/app?sslmode=require",
|
|
29
|
+
);
|
|
30
|
+
expect(pinned).toBe(
|
|
31
|
+
"postgresql://user:p%40ss%2Fw@localhost:5432/app?sslmode=verify-full",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a duplicate sslmode resolves to the LAST occurrence, like pg-connection-string", () => {
|
|
36
|
+
// pg-connection-string's parse() iterates every query param and lets
|
|
37
|
+
// later entries overwrite earlier ones, so `pg` itself connects using
|
|
38
|
+
// the second `sslmode` here ("require"), not the first ("verify-full").
|
|
39
|
+
// Using URLSearchParams#get() (first-match) would wrongly see
|
|
40
|
+
// "verify-full" and skip the rewrite, leaving the string's final
|
|
41
|
+
// sslmode as the unrewritten legacy "require".
|
|
42
|
+
const pinned = new URL(
|
|
43
|
+
pinPgSslMode(`${BASE}?sslmode=verify-full&sslmode=require`),
|
|
44
|
+
);
|
|
45
|
+
expect(pinned.searchParams.getAll("sslmode")).toEqual(["verify-full"]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("pinPgSslMode — inputs left untouched", () => {
|
|
50
|
+
test("a URL with no sslmode", () => {
|
|
51
|
+
expect(pinPgSslMode(BASE)).toBe(BASE);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const mode of ["disable", "verify-full", "no-verify"]) {
|
|
55
|
+
test(`sslmode=${mode}`, () => {
|
|
56
|
+
const input = `${BASE}?sslmode=${mode}`;
|
|
57
|
+
expect(pinPgSslMode(input)).toBe(input);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("an explicit uselibpqcompat=true opt-in", () => {
|
|
62
|
+
const input = `${BASE}?uselibpqcompat=true&sslmode=require`;
|
|
63
|
+
expect(pinPgSslMode(input)).toBe(input);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a string that is not a URL", () => {
|
|
67
|
+
expect(pinPgSslMode("not a url")).toBe("not a url");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a URL-encoded password survives unchanged when no rewrite applies", () => {
|
|
71
|
+
const input = "postgresql://user:p%40ss%2Fw@localhost:5432/app";
|
|
72
|
+
expect(pinPgSslMode(input)).toBe(input);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pool-observability.ts
|
|
3
|
+
* Observability helpers for diagnosing pg pool starvation (Sentry SENTRY-5T).
|
|
4
|
+
*
|
|
5
|
+
* Observability only: nothing here changes pool or client behavior. Types are
|
|
6
|
+
* structural so this module needs no direct dependency on `pg` or `hono`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Minimal Clock (matches the Clock interface used across services). */
|
|
14
|
+
export interface Clock {
|
|
15
|
+
now(): Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PoolLike {
|
|
19
|
+
totalCount: number;
|
|
20
|
+
idleCount: number;
|
|
21
|
+
waitingCount: number;
|
|
22
|
+
options?: { max?: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PoolSnapshot {
|
|
26
|
+
total: number;
|
|
27
|
+
idle: number;
|
|
28
|
+
waiting: number;
|
|
29
|
+
max: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PoolLog = (message: string, data?: Record<string, unknown>) => void;
|
|
33
|
+
|
|
34
|
+
type ReleaseFn = (err?: Error | boolean) => void;
|
|
35
|
+
|
|
36
|
+
export interface CheckoutPool extends PoolLike {
|
|
37
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches pg's overloaded connect()
|
|
38
|
+
connect: (...args: any[]) => any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const systemClock: Clock = { now: () => new Date() };
|
|
42
|
+
|
|
43
|
+
// ─── Snapshot / error detection ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function snapshotPool(pool: PoolLike): PoolSnapshot {
|
|
46
|
+
return {
|
|
47
|
+
total: pool.totalCount,
|
|
48
|
+
idle: pool.idleCount,
|
|
49
|
+
waiting: pool.waitingCount,
|
|
50
|
+
max: pool.options?.max ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True for Prisma P2028, "Unable to start a transaction", or pg connection timeouts. */
|
|
55
|
+
export function isPoolTimeoutError(err: unknown): boolean {
|
|
56
|
+
if (!err || typeof err !== "object") return false;
|
|
57
|
+
const { code, message } = err as { code?: unknown; message?: unknown };
|
|
58
|
+
if (code === "P2028") return true;
|
|
59
|
+
if (typeof message !== "string") return false;
|
|
60
|
+
return (
|
|
61
|
+
message.includes("Unable to start a transaction") ||
|
|
62
|
+
/timeout exceeded when trying to connect/i.test(message) ||
|
|
63
|
+
/connection timeout/i.test(message)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Monitor ──────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface PoolMonitorOptions {
|
|
70
|
+
intervalMs?: number;
|
|
71
|
+
log?: PoolLog;
|
|
72
|
+
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
73
|
+
clearInterval?: (handle: unknown) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Periodically logs the pool snapshot, only while clients are waiting. Returns a stop fn. */
|
|
77
|
+
export function startPoolMonitor(
|
|
78
|
+
pool: PoolLike,
|
|
79
|
+
opts: PoolMonitorOptions = {},
|
|
80
|
+
): () => void {
|
|
81
|
+
const {
|
|
82
|
+
intervalMs = 30_000,
|
|
83
|
+
log = (m, d) => console.warn(m, d),
|
|
84
|
+
setInterval: setIntervalFn = (fn, ms) => setInterval(fn, ms),
|
|
85
|
+
clearInterval: clearIntervalFn = (h) =>
|
|
86
|
+
clearInterval(h as ReturnType<typeof setInterval>),
|
|
87
|
+
} = opts;
|
|
88
|
+
const handle = setIntervalFn(() => {
|
|
89
|
+
const snap = snapshotPool(pool);
|
|
90
|
+
if (snap.waiting > 0) log("pg pool has waiting clients", { ...snap });
|
|
91
|
+
}, intervalMs);
|
|
92
|
+
return () => clearIntervalFn(handle);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Route context ────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface RouteContext {
|
|
98
|
+
method: string;
|
|
99
|
+
route: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const routeStorage = new AsyncLocalStorage<RouteContext>();
|
|
103
|
+
|
|
104
|
+
export function runWithRouteContext<T>(ctx: RouteContext, fn: () => T): T {
|
|
105
|
+
return routeStorage.run(ctx, fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function currentRouteContext(): RouteContext | undefined {
|
|
109
|
+
return routeStorage.getStore();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface HonoLikeContext {
|
|
113
|
+
req: {
|
|
114
|
+
method: string;
|
|
115
|
+
path: string;
|
|
116
|
+
matchedRoutes?: { path: string }[];
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Hono middleware factory recording the request method and matched route
|
|
122
|
+
* pattern. The pattern is resolved lazily (at read time) so it reflects the
|
|
123
|
+
* handler route rather than the wildcard this middleware is mounted on.
|
|
124
|
+
*/
|
|
125
|
+
export function routeContextMiddleware() {
|
|
126
|
+
return async (c: HonoLikeContext, next: () => Promise<void>) => {
|
|
127
|
+
const ctx: RouteContext = {
|
|
128
|
+
method: c.req.method,
|
|
129
|
+
get route() {
|
|
130
|
+
const matched = c.req.matchedRoutes;
|
|
131
|
+
return matched?.length
|
|
132
|
+
? (matched[matched.length - 1] as { path: string }).path
|
|
133
|
+
: c.req.path;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
await runWithRouteContext(ctx, next);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Connection hold tracking ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export interface TrackHoldOptions {
|
|
143
|
+
thresholdMs?: number;
|
|
144
|
+
log?: PoolLog;
|
|
145
|
+
clock?: Clock;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wraps `pool.connect()` so each checked-out client's `release` logs when the
|
|
150
|
+
* connection was held longer than `thresholdMs`. Release semantics are
|
|
151
|
+
* preserved exactly: args are forwarded untouched and the underlying release
|
|
152
|
+
* is invoked on every call (so pg's own double-release error still surfaces).
|
|
153
|
+
* Returns a function restoring the original `connect`.
|
|
154
|
+
*/
|
|
155
|
+
export function trackConnectionHold(
|
|
156
|
+
pool: CheckoutPool,
|
|
157
|
+
opts: TrackHoldOptions = {},
|
|
158
|
+
): () => void {
|
|
159
|
+
const {
|
|
160
|
+
thresholdMs = 2000,
|
|
161
|
+
log = (m, d) => console.warn(m, d),
|
|
162
|
+
clock = systemClock,
|
|
163
|
+
} = opts;
|
|
164
|
+
const originalConnect = pool.connect;
|
|
165
|
+
|
|
166
|
+
const wrap = (client: { release: ReleaseFn }) => {
|
|
167
|
+
const checkedOutAt = clock.now().getTime();
|
|
168
|
+
const route = currentRouteContext();
|
|
169
|
+
const originalRelease = client.release;
|
|
170
|
+
let reported = false;
|
|
171
|
+
client.release = function (this: unknown, ...args: [(Error | boolean)?]) {
|
|
172
|
+
if (!reported) {
|
|
173
|
+
reported = true;
|
|
174
|
+
const heldMs = clock.now().getTime() - checkedOutAt;
|
|
175
|
+
if (heldMs > thresholdMs) {
|
|
176
|
+
log("pg connection held past threshold", {
|
|
177
|
+
route: route ? `${route.method} ${route.route}` : null,
|
|
178
|
+
heldMs,
|
|
179
|
+
pool: snapshotPool(pool),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return originalRelease.apply(client, args);
|
|
184
|
+
} as ReleaseFn;
|
|
185
|
+
return client;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
pool.connect = function (this: unknown, ...args: unknown[]) {
|
|
189
|
+
const result = originalConnect.apply(pool, args);
|
|
190
|
+
// Callback-style connect(cb) is passed through untracked.
|
|
191
|
+
if (typeof args[0] === "function") return result;
|
|
192
|
+
return (result as Promise<{ release: ReleaseFn }>).then(wrap);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
pool.connect = originalConnect;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Sentry context ───────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/** Object to attach to Sentry events (e.g. `scope.setContext("pg_pool", ...)`). */
|
|
203
|
+
export function poolErrorContext(pool: PoolLike): Record<string, unknown> {
|
|
204
|
+
const route = currentRouteContext();
|
|
205
|
+
return {
|
|
206
|
+
...snapshotPool(pool),
|
|
207
|
+
route: route ? `${route.method} ${route.route}` : null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type CheckoutPool,
|
|
4
|
+
type Clock,
|
|
5
|
+
isPoolTimeoutError,
|
|
6
|
+
poolErrorContext,
|
|
7
|
+
routeContextMiddleware,
|
|
8
|
+
runWithRouteContext,
|
|
9
|
+
snapshotPool,
|
|
10
|
+
startPoolMonitor,
|
|
11
|
+
trackConnectionHold,
|
|
12
|
+
} from "./pool-observability.ts";
|
|
13
|
+
|
|
14
|
+
function makePool(over: Partial<CheckoutPool> = {}) {
|
|
15
|
+
const released: unknown[][] = [];
|
|
16
|
+
const pool: CheckoutPool = {
|
|
17
|
+
totalCount: 3,
|
|
18
|
+
idleCount: 1,
|
|
19
|
+
waitingCount: 0,
|
|
20
|
+
options: { max: 10 },
|
|
21
|
+
connect: async () => ({
|
|
22
|
+
release: (...args: unknown[]) => {
|
|
23
|
+
released.push(args);
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
return { pool, released };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeClock(startMs = 0) {
|
|
32
|
+
let t = startMs;
|
|
33
|
+
const clock: Clock = { now: () => new Date(t) };
|
|
34
|
+
return {
|
|
35
|
+
clock,
|
|
36
|
+
advance: (ms: number) => {
|
|
37
|
+
t += ms;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("snapshotPool", () => {
|
|
43
|
+
test("maps pool counts and max", () => {
|
|
44
|
+
expect(snapshotPool(makePool().pool)).toEqual({
|
|
45
|
+
total: 3,
|
|
46
|
+
idle: 1,
|
|
47
|
+
waiting: 0,
|
|
48
|
+
max: 10,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isPoolTimeoutError", () => {
|
|
54
|
+
test("matches Prisma P2028", () => {
|
|
55
|
+
expect(isPoolTimeoutError({ code: "P2028", message: "x" })).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
test("matches message variants", () => {
|
|
58
|
+
expect(
|
|
59
|
+
isPoolTimeoutError(
|
|
60
|
+
new Error("Unable to start a transaction in the given time."),
|
|
61
|
+
),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
expect(
|
|
64
|
+
isPoolTimeoutError(new Error("timeout exceeded when trying to connect")),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
expect(
|
|
67
|
+
isPoolTimeoutError(
|
|
68
|
+
new Error("Connection terminated due to connection timeout"),
|
|
69
|
+
),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
test("rejects unrelated errors", () => {
|
|
73
|
+
expect(isPoolTimeoutError(new Error("syntax error"))).toBe(false);
|
|
74
|
+
expect(isPoolTimeoutError({ code: "P2002" })).toBe(false);
|
|
75
|
+
expect(isPoolTimeoutError(null)).toBe(false);
|
|
76
|
+
expect(isPoolTimeoutError("timeout")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("startPoolMonitor", () => {
|
|
81
|
+
function harness(pool: CheckoutPool) {
|
|
82
|
+
let tick: () => void = () => {};
|
|
83
|
+
let cleared: unknown;
|
|
84
|
+
const logs: unknown[][] = [];
|
|
85
|
+
const stop = startPoolMonitor(pool, {
|
|
86
|
+
intervalMs: 5,
|
|
87
|
+
log: (...a) => void logs.push(a),
|
|
88
|
+
setInterval: (fn, ms) => {
|
|
89
|
+
expect(ms).toBe(5);
|
|
90
|
+
tick = fn;
|
|
91
|
+
return "h";
|
|
92
|
+
},
|
|
93
|
+
clearInterval: (h) => {
|
|
94
|
+
cleared = h;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return { tick: () => tick(), stop, logs, cleared: () => cleared };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("does not log when nobody is waiting", () => {
|
|
101
|
+
const h = harness(makePool().pool);
|
|
102
|
+
h.tick();
|
|
103
|
+
expect(h.logs).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("logs a snapshot when waiting > 0", () => {
|
|
107
|
+
const h = harness(makePool({ waitingCount: 4 }).pool);
|
|
108
|
+
h.tick();
|
|
109
|
+
expect(h.logs).toHaveLength(1);
|
|
110
|
+
expect(h.logs[0]?.[1]).toMatchObject({ waiting: 4, total: 3, max: 10 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("stop fn clears the interval", () => {
|
|
114
|
+
const h = harness(makePool().pool);
|
|
115
|
+
h.stop();
|
|
116
|
+
expect(h.cleared()).toBe("h");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("trackConnectionHold", () => {
|
|
121
|
+
async function run(
|
|
122
|
+
holdMs: number,
|
|
123
|
+
route?: { method: string; route: string },
|
|
124
|
+
) {
|
|
125
|
+
const { pool, released } = makePool({ waitingCount: 2 });
|
|
126
|
+
const { clock, advance } = makeClock();
|
|
127
|
+
const logs: unknown[][] = [];
|
|
128
|
+
trackConnectionHold(pool, {
|
|
129
|
+
thresholdMs: 2000,
|
|
130
|
+
clock,
|
|
131
|
+
log: (...a) => void logs.push(a),
|
|
132
|
+
});
|
|
133
|
+
const exec = async () => {
|
|
134
|
+
const client = await pool.connect();
|
|
135
|
+
advance(holdMs);
|
|
136
|
+
client.release();
|
|
137
|
+
return client;
|
|
138
|
+
};
|
|
139
|
+
const client = route
|
|
140
|
+
? await runWithRouteContext(route, exec)
|
|
141
|
+
: await exec();
|
|
142
|
+
return { client, logs, released };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test("does not log under the threshold", async () => {
|
|
146
|
+
const { logs, released } = await run(1500);
|
|
147
|
+
expect(logs).toHaveLength(0);
|
|
148
|
+
expect(released).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("logs route, heldMs and pool snapshot over the threshold", async () => {
|
|
152
|
+
const { logs } = await run(2500, { method: "GET", route: "/tasks/:id" });
|
|
153
|
+
expect(logs).toHaveLength(1);
|
|
154
|
+
expect(logs[0]?.[1]).toMatchObject({
|
|
155
|
+
route: "GET /tasks/:id",
|
|
156
|
+
heldMs: 2500,
|
|
157
|
+
pool: { total: 3, waiting: 2 },
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("route is null outside a request", async () => {
|
|
162
|
+
const { logs } = await run(2500);
|
|
163
|
+
expect(logs[0]?.[1]).toMatchObject({ route: null });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("release is called once per call, error arg forwarded", async () => {
|
|
167
|
+
const { pool, released } = makePool();
|
|
168
|
+
trackConnectionHold(pool);
|
|
169
|
+
const client = await pool.connect();
|
|
170
|
+
const err = new Error("boom");
|
|
171
|
+
client.release(err);
|
|
172
|
+
expect(released).toEqual([[err]]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("double release behaves as unwrapped (underlying called each time)", async () => {
|
|
176
|
+
const { pool, released } = makePool();
|
|
177
|
+
const { clock, advance } = makeClock();
|
|
178
|
+
const logs: unknown[][] = [];
|
|
179
|
+
trackConnectionHold(pool, { clock, log: (...a) => void logs.push(a) });
|
|
180
|
+
const client = await pool.connect();
|
|
181
|
+
advance(5000);
|
|
182
|
+
client.release();
|
|
183
|
+
client.release(true);
|
|
184
|
+
expect(released).toEqual([[], [true]]);
|
|
185
|
+
expect(logs).toHaveLength(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("restore fn reinstates original connect", async () => {
|
|
189
|
+
const { pool } = makePool();
|
|
190
|
+
const original = pool.connect;
|
|
191
|
+
const restore = trackConnectionHold(pool);
|
|
192
|
+
expect(pool.connect).not.toBe(original);
|
|
193
|
+
restore();
|
|
194
|
+
expect(pool.connect).toBe(original);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("routeContextMiddleware", () => {
|
|
199
|
+
test("records method and matched route pattern for poolErrorContext", async () => {
|
|
200
|
+
const { pool } = makePool();
|
|
201
|
+
let seen: Record<string, unknown> | undefined;
|
|
202
|
+
const c = {
|
|
203
|
+
req: {
|
|
204
|
+
method: "GET",
|
|
205
|
+
path: "/tasks/42",
|
|
206
|
+
matchedRoutes: [{ path: "/*" }, { path: "/tasks/:id" }],
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
await routeContextMiddleware()(c, async () => {
|
|
210
|
+
seen = poolErrorContext(pool);
|
|
211
|
+
});
|
|
212
|
+
expect(seen).toEqual({
|
|
213
|
+
total: 3,
|
|
214
|
+
idle: 1,
|
|
215
|
+
waiting: 0,
|
|
216
|
+
max: 10,
|
|
217
|
+
route: "GET /tasks/:id",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("falls back to the request path without matchedRoutes", async () => {
|
|
222
|
+
let seen: Record<string, unknown> | undefined;
|
|
223
|
+
const c = { req: { method: "POST", path: "/x" } };
|
|
224
|
+
await routeContextMiddleware()(c, async () => {
|
|
225
|
+
seen = poolErrorContext(makePool().pool);
|
|
226
|
+
});
|
|
227
|
+
expect(seen?.route).toBe("POST /x");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("poolErrorContext has null route outside a request", () => {
|
|
231
|
+
expect(poolErrorContext(makePool().pool).route).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
package/pricing.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface TokenUsage {
|
|
2
|
+
input_tokens: number;
|
|
3
|
+
output_tokens: number;
|
|
4
|
+
cache_read_input_tokens: number;
|
|
5
|
+
cache_creation_input_tokens: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ModelRates {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const OPUS_MODEL = "claude-opus-4-8";
|
|
14
|
+
|
|
15
|
+
export const RATES: Record<string, ModelRates> = {
|
|
16
|
+
"claude-fable-5-1": { input: 10.0, output: 50.0 },
|
|
17
|
+
"claude-fable-5": { input: 10.0, output: 50.0 },
|
|
18
|
+
"claude-opus-5-5": { input: 4.0, output: 20.0 },
|
|
19
|
+
"claude-opus-4-8": { input: 5.0, output: 25.0 },
|
|
20
|
+
"claude-opus-4-7": { input: 5.0, output: 25.0 },
|
|
21
|
+
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
|
22
|
+
"claude-sonnet-5-5": { input: 2.0, output: 10.0 },
|
|
23
|
+
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
24
|
+
"claude-haiku-5-5": { input: 0.1, output: 0.5 },
|
|
25
|
+
"claude-haiku-4-5": { input: 1.0, output: 5.0 },
|
|
26
|
+
"claude-haiku-4-6": { input: 1.0, output: 5.0 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Context window (input tokens) per rate key. Used by the prompt-audit
|
|
31
|
+
* tooling to express always-loaded context as a share of the window and to
|
|
32
|
+
* size the skill-listing budget (Claude Code reserves 1% of the window for
|
|
33
|
+
* the skill listing). Keys mirror RATES.
|
|
34
|
+
*/
|
|
35
|
+
export const CONTEXT_WINDOW: Record<string, number> = {
|
|
36
|
+
"claude-fable-5-1": 1_000_000,
|
|
37
|
+
"claude-fable-5": 1_000_000,
|
|
38
|
+
"claude-opus-5-5": 1_000_000,
|
|
39
|
+
"claude-opus-4-8": 1_000_000,
|
|
40
|
+
"claude-opus-4-7": 1_000_000,
|
|
41
|
+
"claude-opus-4-6": 1_000_000,
|
|
42
|
+
"claude-sonnet-5-5": 1_000_000,
|
|
43
|
+
"claude-sonnet-4-6": 1_000_000,
|
|
44
|
+
"claude-haiku-5-5": 1_000_000,
|
|
45
|
+
"claude-haiku-4-5": 200_000,
|
|
46
|
+
"claude-haiku-4-6": 200_000,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function calculateCost(usage: TokenUsage, model: string): number {
|
|
50
|
+
const rates = RATES[model];
|
|
51
|
+
if (!rates) return 0;
|
|
52
|
+
|
|
53
|
+
const { input, output } = rates;
|
|
54
|
+
const cost =
|
|
55
|
+
usage.input_tokens * input +
|
|
56
|
+
usage.output_tokens * output +
|
|
57
|
+
usage.cache_creation_input_tokens * input * 1.25 +
|
|
58
|
+
usage.cache_read_input_tokens * input * 0.1;
|
|
59
|
+
|
|
60
|
+
return cost / 1_000_000;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeModelToRateKey(model: string): string | null {
|
|
64
|
+
const bareModel = model.replace(/-\d{8}$/, "");
|
|
65
|
+
// Current-generation ids price as themselves. The bare "sonnet"/"opus"/
|
|
66
|
+
// "haiku" aliases resolve to the 5.5 keys; 4.x ids keep their own mapping.
|
|
67
|
+
if (
|
|
68
|
+
bareModel === "claude-fable-5-1" ||
|
|
69
|
+
bareModel === "claude-opus-5-5" ||
|
|
70
|
+
bareModel === "claude-sonnet-5-5" ||
|
|
71
|
+
bareModel === "claude-haiku-5-5"
|
|
72
|
+
) {
|
|
73
|
+
return bareModel;
|
|
74
|
+
}
|
|
75
|
+
if (bareModel === "haiku") return "claude-haiku-5-5";
|
|
76
|
+
if (bareModel === "opus") return "claude-opus-5-5";
|
|
77
|
+
if (bareModel === "sonnet") return "claude-sonnet-5-5";
|
|
78
|
+
if (
|
|
79
|
+
bareModel === "claude-haiku-4-5" ||
|
|
80
|
+
bareModel === "claude-haiku-4-6"
|
|
81
|
+
) {
|
|
82
|
+
return "claude-haiku-4-5";
|
|
83
|
+
}
|
|
84
|
+
if (bareModel === "claude-sonnet-4-6") {
|
|
85
|
+
return "claude-sonnet-4-6";
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
bareModel === "claude-opus-4-8" ||
|
|
89
|
+
bareModel === "claude-opus-4-7" ||
|
|
90
|
+
bareModel === "claude-opus-4-6"
|
|
91
|
+
) {
|
|
92
|
+
return "claude-opus-4-8";
|
|
93
|
+
}
|
|
94
|
+
if (bareModel === "fable" || bareModel === "claude-fable-5") {
|
|
95
|
+
return "claude-fable-5";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|