@app-vitals/agent-lib 0.0.0-stage → 0.1.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/admin-types.ts +4097 -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-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/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package.json +39 -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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { registerGracefulShutdown } from "./graceful-shutdown.ts";
|
|
3
|
+
|
|
4
|
+
/** Records signal listeners without touching the real `process` object. */
|
|
5
|
+
function fakeSignalSource() {
|
|
6
|
+
const listeners: Record<string, () => void> = {};
|
|
7
|
+
return {
|
|
8
|
+
on(event: "SIGTERM" | "SIGINT", listener: () => void) {
|
|
9
|
+
listeners[event] = listener;
|
|
10
|
+
},
|
|
11
|
+
listeners,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fake server.stop() — records the `closeActiveConnections` arg per call and resolves immediately. */
|
|
16
|
+
function fakeServer(calls: string[]) {
|
|
17
|
+
return {
|
|
18
|
+
stop(closeActiveConnections?: boolean) {
|
|
19
|
+
calls.push(closeActiveConnections ? "stop(true)" : "stop()");
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fake setTimeout — never fires on its own; the test triggers it manually via `fire()`. */
|
|
26
|
+
function fakeTimeout() {
|
|
27
|
+
let scheduled: { fn: () => void; ms: number } | undefined;
|
|
28
|
+
return {
|
|
29
|
+
setTimeoutFn: (fn: () => void, ms: number) => {
|
|
30
|
+
scheduled = { fn, ms };
|
|
31
|
+
return "handle";
|
|
32
|
+
},
|
|
33
|
+
clearTimeoutFn: () => {
|
|
34
|
+
scheduled = undefined;
|
|
35
|
+
},
|
|
36
|
+
fire: () => scheduled?.fn(),
|
|
37
|
+
ms: () => scheduled?.ms,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fake process.exit() that also resolves a promise so tests can await shutdown completion. */
|
|
42
|
+
function fakeExit() {
|
|
43
|
+
const calls: number[] = [];
|
|
44
|
+
let resolve!: () => void;
|
|
45
|
+
const settled = new Promise<void>((r) => {
|
|
46
|
+
resolve = r;
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
exit: (code: number) => {
|
|
50
|
+
calls.push(code);
|
|
51
|
+
resolve();
|
|
52
|
+
},
|
|
53
|
+
calls,
|
|
54
|
+
settled,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NOOP_LOGGER = { log: () => {}, error: () => {} };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds a fresh set of fakes and registers a shutdown handler against them.
|
|
62
|
+
* Every test shares this same wiring — only `cleanup` and `hardDeadlineMs`
|
|
63
|
+
* vary — so the fixture construction lives here once instead of being
|
|
64
|
+
* repeated per test.
|
|
65
|
+
*/
|
|
66
|
+
function harness(
|
|
67
|
+
overrides: {
|
|
68
|
+
cleanup?: Array<() => Promise<void>>;
|
|
69
|
+
hardDeadlineMs?: number;
|
|
70
|
+
} = {},
|
|
71
|
+
) {
|
|
72
|
+
const calls: string[] = [];
|
|
73
|
+
const server = fakeServer(calls);
|
|
74
|
+
const signal = fakeSignalSource();
|
|
75
|
+
const timeout = fakeTimeout();
|
|
76
|
+
const exit = fakeExit();
|
|
77
|
+
|
|
78
|
+
const handle = registerGracefulShutdown({
|
|
79
|
+
server,
|
|
80
|
+
cleanup: overrides.cleanup ?? [async () => {}],
|
|
81
|
+
...(overrides.hardDeadlineMs !== undefined
|
|
82
|
+
? { hardDeadlineMs: overrides.hardDeadlineMs }
|
|
83
|
+
: {}),
|
|
84
|
+
process: signal,
|
|
85
|
+
setTimeoutFn: timeout.setTimeoutFn,
|
|
86
|
+
clearTimeoutFn: timeout.clearTimeoutFn,
|
|
87
|
+
exit: exit.exit,
|
|
88
|
+
logger: NOOP_LOGGER,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return { calls, signal, timeout, exit, handle };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("registerGracefulShutdown", () => {
|
|
95
|
+
test("calls server.stop() before running cleanup fns", async () => {
|
|
96
|
+
const { calls, signal, exit } = harness({
|
|
97
|
+
cleanup: [
|
|
98
|
+
async () => {
|
|
99
|
+
calls.push("cleanup:0");
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
signal.listeners.SIGTERM();
|
|
105
|
+
await exit.settled;
|
|
106
|
+
|
|
107
|
+
expect(calls).toEqual(["stop()", "cleanup:0"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("runs cleanup fns in the given order", async () => {
|
|
111
|
+
const { calls, signal, exit } = harness({
|
|
112
|
+
cleanup: [
|
|
113
|
+
async () => {
|
|
114
|
+
calls.push("cleanup:a");
|
|
115
|
+
},
|
|
116
|
+
async () => {
|
|
117
|
+
calls.push("cleanup:b");
|
|
118
|
+
},
|
|
119
|
+
async () => {
|
|
120
|
+
calls.push("cleanup:c");
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
signal.listeners.SIGTERM();
|
|
126
|
+
await exit.settled;
|
|
127
|
+
|
|
128
|
+
expect(calls).toEqual(["stop()", "cleanup:a", "cleanup:b", "cleanup:c"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("exits with code 0 on clean successful shutdown", async () => {
|
|
132
|
+
const { signal, exit } = harness();
|
|
133
|
+
|
|
134
|
+
signal.listeners.SIGTERM();
|
|
135
|
+
await exit.settled;
|
|
136
|
+
|
|
137
|
+
expect(exit.calls).toEqual([0]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("hard deadline forces server.stop(true) and exit(1) when cleanup never resolves", async () => {
|
|
141
|
+
const { calls, signal, timeout, exit } = harness({
|
|
142
|
+
cleanup: [
|
|
143
|
+
() =>
|
|
144
|
+
new Promise<void>(() => {
|
|
145
|
+
// never resolves — simulates a stuck cleanup fn
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
hardDeadlineMs: 20_000,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
signal.listeners.SIGTERM();
|
|
152
|
+
|
|
153
|
+
// Let the graceful stop() + cleanup kick-off settle onto the stuck promise
|
|
154
|
+
// before manually firing the deadline (no real waiting involved).
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
await Promise.resolve();
|
|
157
|
+
|
|
158
|
+
expect(timeout.ms()).toBe(20_000);
|
|
159
|
+
timeout.fire();
|
|
160
|
+
await exit.settled;
|
|
161
|
+
|
|
162
|
+
expect(calls).toContain("stop(true)");
|
|
163
|
+
expect(exit.calls).toEqual([1]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a second SIGTERM after the first is a no-op", async () => {
|
|
167
|
+
const { calls, signal, exit } = harness({
|
|
168
|
+
cleanup: [
|
|
169
|
+
async () => {
|
|
170
|
+
calls.push("cleanup:0");
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
signal.listeners.SIGTERM();
|
|
176
|
+
await exit.settled;
|
|
177
|
+
|
|
178
|
+
signal.listeners.SIGTERM();
|
|
179
|
+
// Flush microtasks so a (bugged) second invocation would have had a chance to run.
|
|
180
|
+
await Promise.resolve();
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
|
|
183
|
+
expect(calls).toEqual(["stop()", "cleanup:0"]);
|
|
184
|
+
expect(exit.calls).toEqual([0]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("SIGINT triggers the same shutdown sequence as SIGTERM", async () => {
|
|
188
|
+
const { calls, signal, exit } = harness({
|
|
189
|
+
cleanup: [
|
|
190
|
+
async () => {
|
|
191
|
+
calls.push("cleanup:0");
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
signal.listeners.SIGINT();
|
|
197
|
+
await exit.settled;
|
|
198
|
+
|
|
199
|
+
expect(calls).toEqual(["stop()", "cleanup:0"]);
|
|
200
|
+
expect(exit.calls).toEqual([0]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("exposes a readable shuttingDown flag, set synchronously on signal", async () => {
|
|
204
|
+
const { signal, exit, handle } = harness();
|
|
205
|
+
|
|
206
|
+
expect(handle.isShuttingDown()).toBe(false);
|
|
207
|
+
|
|
208
|
+
signal.listeners.SIGTERM();
|
|
209
|
+
|
|
210
|
+
// True immediately, before the async stop/cleanup chain has settled.
|
|
211
|
+
expect(handle.isShuttingDown()).toBe(true);
|
|
212
|
+
|
|
213
|
+
await exit.settled;
|
|
214
|
+
expect(handle.isShuttingDown()).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a cleanup fn that throws does not prevent later cleanup fns or process.exit", async () => {
|
|
218
|
+
const { calls, signal, exit } = harness({
|
|
219
|
+
cleanup: [
|
|
220
|
+
async () => {
|
|
221
|
+
calls.push("cleanup:a");
|
|
222
|
+
throw new Error("boom");
|
|
223
|
+
},
|
|
224
|
+
async () => {
|
|
225
|
+
calls.push("cleanup:b");
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
signal.listeners.SIGTERM();
|
|
231
|
+
await exit.settled;
|
|
232
|
+
|
|
233
|
+
expect(calls).toEqual(["stop()", "cleanup:a", "cleanup:b"]);
|
|
234
|
+
expect(exit.calls).toEqual([0]);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/org-repo.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Returns true when `s` is a valid "org/repo" string with non-empty org and repo parts. */
|
|
2
|
+
export function isOrgRepo(s: string): boolean {
|
|
3
|
+
const parts = s.split("/");
|
|
4
|
+
return (
|
|
5
|
+
parts.length === 2 &&
|
|
6
|
+
parts[0].trim().length > 0 &&
|
|
7
|
+
parts[1].trim().length > 0
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isOrgRepo } from "./org-repo.ts";
|
|
3
|
+
|
|
4
|
+
describe("isOrgRepo — valid strings", () => {
|
|
5
|
+
test("returns true for a simple org/repo string", () => {
|
|
6
|
+
expect(isOrgRepo("app-vitals/shipwright")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns true when org and repo contain hyphens and numbers", () => {
|
|
10
|
+
expect(isOrgRepo("my-org-1/my-repo-2")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("isOrgRepo — invalid strings", () => {
|
|
15
|
+
test("returns false for a string with no slash", () => {
|
|
16
|
+
expect(isOrgRepo("shipwright")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns false for a string with more than one slash", () => {
|
|
20
|
+
expect(isOrgRepo("app-vitals/shipwright/extra")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns false when the org part is empty", () => {
|
|
24
|
+
expect(isOrgRepo("/shipwright")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns false when the repo part is empty", () => {
|
|
28
|
+
expect(isOrgRepo("app-vitals/")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns false when the org part is only whitespace", () => {
|
|
32
|
+
expect(isOrgRepo(" /shipwright")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns false when the repo part is only whitespace", () => {
|
|
36
|
+
expect(isOrgRepo("app-vitals/ ")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false for an empty string", () => {
|
|
40
|
+
expect(isOrgRepo("")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-lib",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/app-vitals/agent.git",
|
|
7
|
+
"directory": "lib"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
"./admin-types": "./admin-types.ts",
|
|
16
|
+
"./agent-default-tools": "./agent-default-tools.ts",
|
|
17
|
+
"./chat-notify": "./chat-notify.ts",
|
|
18
|
+
"./claim-ttl": "./claim-ttl.ts",
|
|
19
|
+
"./claude-timeout": "./claude-timeout.ts",
|
|
20
|
+
"./cli-flags": "./cli-flags.ts",
|
|
21
|
+
"./clone-plan": "./clone-plan.ts",
|
|
22
|
+
"./github-login": "./github-login.ts",
|
|
23
|
+
"./graceful-shutdown": "./graceful-shutdown.ts",
|
|
24
|
+
"./http": "./http.ts",
|
|
25
|
+
"./org-repo": "./org-repo.ts",
|
|
26
|
+
"./pg-sslmode": "./pg-sslmode.ts",
|
|
27
|
+
"./pool-observability": "./pool-observability.ts",
|
|
28
|
+
"./pricing": "./pricing.ts",
|
|
29
|
+
"./progress-phases": "./progress-phases.ts",
|
|
30
|
+
"./request-context": "./request-context.ts",
|
|
31
|
+
"./secret-env-vars": "./secret-env-vars.ts",
|
|
32
|
+
"./default-account": "./default-account.ts",
|
|
33
|
+
"./default-agent-env": "./default-agent-env.ts",
|
|
34
|
+
"./sentry": "./sentry.ts",
|
|
35
|
+
"./task-store-types": "./task-store-types.ts",
|
|
36
|
+
"./web/*": "./web/*"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@sentry/bun": "^10.63.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
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
|
+
}
|