@specific.dev/spectest 0.9.0 → 0.11.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/src/sql.ts ADDED
@@ -0,0 +1,223 @@
1
+ // `SQL` — a drop-in for `Bun.SQL` that records every query on the test event
2
+ // log and returns its rows inspect-wrapped, so `expect(rows[0]!.id)` links the
3
+ // assertion back to the query in the timeline (same provenance mechanism as
4
+ // `fetch`). Swap `new Bun.SQL(url)` → `new SQL(url)` and nothing else changes
5
+ // at the call site; query *results* come back `Wrapped<T[]>` instead of raw
6
+ // `T[]` (that wrapper is what carries provenance), so `.unwrap()` before feeding
7
+ // a row value to another real client or a `===` — exactly the rule that already
8
+ // holds for `ctx.svc.<postgres>.client`.
9
+ //
10
+ // This is the core primitive; the `postgres(...)` component is a thin user of
11
+ // it (its `helpers.client` is `new SQL(url, { label })`). Open one yourself
12
+ // whenever you stand up a database without the component — a Bun-native image
13
+ // like timescaledb/postgis, or a connection string a fake handed back after
14
+ // provisioning a DB at runtime.
15
+
16
+ import { recordDb, reserveEvent, safeSerialize } from "./recorder.js";
17
+ import { wrap } from "./inspect.js";
18
+ import type { Wrapped } from "./inspect.js";
19
+
20
+ /**
21
+ * Minimal structural type for the parts of `Bun.SQL` we use. Declared locally
22
+ * so user projects don't need `@types/bun` for type-checking to follow the SDK
23
+ * through to the daemon. The real type comes from Bun at runtime via
24
+ * `globalThis.Bun.SQL`.
25
+ */
26
+ interface RawSqlClient {
27
+ <T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
28
+ unsafe<T = unknown>(text: string, params?: unknown[]): Promise<T[]>;
29
+ close?(opts?: { timeout?: number }): Promise<void>;
30
+ end(opts?: { timeout?: number }): Promise<void>;
31
+ }
32
+
33
+ interface SqlClientBase {
34
+ close?(opts?: { timeout?: number }): Promise<void>;
35
+ /** Close the connection / drain the pool (Bun's `SQL.end`). */
36
+ end(opts?: { timeout?: number }): Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * The instrumented SQL surface. Same shape as `Bun.SQL` — callable as a tagged
41
+ * template (`` sql`SELECT 1` ``) with an `unsafe(text, params?)` escape hatch —
42
+ * but each query resolves to a **wrapped** row array ({@link Wrapped}): every
43
+ * settle is recorded as a `db` event and the wrapper carries provenance back to
44
+ * it. That's why `expect(rows)` / `expect(rows[0]!.id)` link under the query in
45
+ * the timeline with no cast — and why you `.unwrap()` before feeding a row value
46
+ * to a real client or a `===`.
47
+ *
48
+ * Generic in the row type, so callers name the shape once at the call site
49
+ * instead of casting: `` await client<TodoRow>`SELECT * FROM todos` `` is
50
+ * `Promise<Wrapped<TodoRow[]>>`.
51
+ */
52
+ export interface SqlClient extends SqlClientBase {
53
+ <T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<Wrapped<T[]>>;
54
+ unsafe<T = unknown>(text: string, params?: unknown[]): Promise<Wrapped<T[]>>;
55
+ }
56
+
57
+ /** Options accepted by the {@link SQL} constructor beyond Bun's own. */
58
+ export interface SqlOptions {
59
+ /**
60
+ * Label shown on each recorded `db` event (the timeline step's service tag).
61
+ * Defaults to the connection URL's host — e.g. `db` for
62
+ * `postgres://…@db:5432/app` — which is usually the service name.
63
+ */
64
+ label?: string;
65
+ }
66
+
67
+ interface BunGlobal {
68
+ SQL: new (url: string) => RawSqlClient;
69
+ }
70
+
71
+ interface SqlConstructor {
72
+ new (url: string, opts?: SqlOptions): SqlClient;
73
+ (url: string, opts?: SqlOptions): SqlClient;
74
+ }
75
+
76
+ /**
77
+ * Open an instrumented SQL client against `url`. Usable with or without `new`
78
+ * (`new SQL(url)` mirrors `new Bun.SQL(url)`; a constructor that returns an
79
+ * object yields that object). Requires the Bun runtime — it runs inside the
80
+ * spectest daemon.
81
+ */
82
+ export const SQL = function SQL(url: string, opts?: SqlOptions): SqlClient {
83
+ const bun = (globalThis as { Bun?: BunGlobal }).Bun;
84
+ if (!bun?.SQL) {
85
+ throw new Error(
86
+ "SQL(url) requires the Bun runtime (Bun >= 1.2) — it runs inside the spectest daemon.",
87
+ );
88
+ }
89
+ return instrumentSql(new bun.SQL(url), opts?.label ?? hostLabel(url));
90
+ } as unknown as SqlConstructor;
91
+
92
+ /** Derive a default event label from a connection URL's host. */
93
+ function hostLabel(url: string): string {
94
+ try {
95
+ return new URL(url).hostname || "db";
96
+ } catch {
97
+ return "db";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Proxy a `Bun.SQL` instance so each tagged-template call and each
103
+ * `unsafe(...)` call emits a `db` event into the active recorder when its
104
+ * promise settles, and resolves to a {@link Wrapped} result. Other `Bun.SQL`
105
+ * methods (`.transaction`, `.array`, `.file`, …) pass through unwrapped.
106
+ *
107
+ * Exported so a client built some other way (e.g. a pool you already hold) can
108
+ * opt into the same instrumentation without going through {@link SQL}.
109
+ */
110
+ export function instrumentSql(raw: RawSqlClient, label: string): SqlClient {
111
+ const wrapResult = <T>(
112
+ result: PromiseLike<T>,
113
+ query: string,
114
+ params: unknown[],
115
+ ): Promise<T> => {
116
+ const started = Date.now();
117
+ const resv = reserveEvent();
118
+ return Promise.resolve(result).then(
119
+ (value) => {
120
+ const { rows, rowsTruncated, columns } = captureRows(value);
121
+ const seq = recordDb(
122
+ {
123
+ service: label,
124
+ query,
125
+ params: params.length > 0 ? params.map(safeSerialize) : undefined,
126
+ rowCount: rowCountOf(value),
127
+ rows,
128
+ rowsTruncated,
129
+ columns,
130
+ durationMs: Date.now() - started,
131
+ },
132
+ resv,
133
+ );
134
+ return wrap(value, seq) as T;
135
+ },
136
+ (err) => {
137
+ const e = err as Error;
138
+ recordDb(
139
+ {
140
+ service: label,
141
+ query,
142
+ params: params.length > 0 ? params.map(safeSerialize) : undefined,
143
+ durationMs: Date.now() - started,
144
+ error: e?.message ?? String(err),
145
+ },
146
+ resv,
147
+ );
148
+ throw err;
149
+ },
150
+ );
151
+ };
152
+
153
+ const handler: ProxyHandler<RawSqlClient> = {
154
+ apply(target, thisArg, args) {
155
+ const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]];
156
+ const query = reconstructSqlTemplate(strings, values.length);
157
+ const result = Reflect.apply(
158
+ target as unknown as (...a: unknown[]) => PromiseLike<unknown>,
159
+ thisArg,
160
+ args,
161
+ );
162
+ return wrapResult(result, query, values);
163
+ },
164
+ get(target, prop, receiver) {
165
+ if (prop === "unsafe") {
166
+ return (text: string, params?: unknown[]) => {
167
+ const result = (
168
+ target.unsafe as (t: string, p?: unknown[]) => PromiseLike<unknown>
169
+ ).call(target, text, params);
170
+ return wrapResult(result, text, params ?? []);
171
+ };
172
+ }
173
+ const v = Reflect.get(target, prop, receiver);
174
+ return typeof v === "function" ? v.bind(target) : v;
175
+ },
176
+ };
177
+ return new Proxy(raw, handler) as unknown as SqlClient;
178
+ }
179
+
180
+ /** Rebuild a parameterized SQL string from a tagged-template's pieces.
181
+ * Bun.SQL substitutes `${value}` for `$1`, `$2`, …; we do the same so
182
+ * the recorded query matches what the server sees. */
183
+ function reconstructSqlTemplate(
184
+ strings: TemplateStringsArray,
185
+ valueCount: number,
186
+ ): string {
187
+ let out = strings[0] ?? "";
188
+ for (let i = 0; i < valueCount; i++) {
189
+ out += `$${i + 1}` + (strings[i + 1] ?? "");
190
+ }
191
+ return out.trim();
192
+ }
193
+
194
+ function rowCountOf(value: unknown): number | undefined {
195
+ if (Array.isArray(value)) return value.length;
196
+ if (value && typeof value === "object") {
197
+ const obj = value as { count?: unknown; rowCount?: unknown };
198
+ if (typeof obj.count === "number") return obj.count;
199
+ if (typeof obj.rowCount === "number") return obj.rowCount;
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ const MAX_DB_ROWS = 50;
205
+
206
+ function captureRows(value: unknown): {
207
+ rows?: unknown[];
208
+ rowsTruncated?: boolean;
209
+ columns?: string[];
210
+ } {
211
+ if (!Array.isArray(value) || value.length === 0) return {};
212
+ const slice = value.slice(0, MAX_DB_ROWS).map(safeSerialize);
213
+ const first = slice[0];
214
+ const columns =
215
+ first && typeof first === "object" && !Array.isArray(first)
216
+ ? Object.keys(first as Record<string, unknown>)
217
+ : undefined;
218
+ return {
219
+ rows: slice,
220
+ rowsTruncated: value.length > MAX_DB_ROWS ? true : undefined,
221
+ columns,
222
+ };
223
+ }