@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/package.json +1 -1
- package/src/components/index.ts +5 -2
- package/src/components/postgres.ts +34 -210
- package/src/daemon.ts +22 -0
- package/src/index.ts +14 -92
- package/src/inspect.ts +61 -4
- package/src/recorder.ts +77 -0
- package/src/redis.ts +202 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +223 -0
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
|
+
}
|