@specific.dev/spectest 0.10.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 +10 -0
- 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/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
10
|
postgres,
|
|
11
|
-
sqlClient,
|
|
12
11
|
type PostgresOptions,
|
|
13
|
-
type
|
|
12
|
+
type PostgresHelpers,
|
|
14
13
|
} from "./postgres.js";
|
|
14
|
+
// `SQL` / `SqlClient` now live in the core SDK (`@specific.dev/spectest`),
|
|
15
|
+
// next to `expect` and `fetch` — re-exported here so existing
|
|
16
|
+
// `from ".../components"` imports keep resolving.
|
|
17
|
+
export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
|
|
15
18
|
export {
|
|
16
19
|
k3s,
|
|
17
20
|
type K3sOptions,
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { ServiceDefinition } from "../index.js";
|
|
2
|
-
import {
|
|
3
|
-
import { wrap } from "../inspect.js";
|
|
4
|
-
import type { Wrapped } from "../inspect.js";
|
|
2
|
+
import { SQL, type SqlClient } from "../sql.js";
|
|
5
3
|
|
|
6
4
|
export interface PostgresOptions {
|
|
7
|
-
/** Image tag for the official `postgres` image. Default `"18-alpine"`.
|
|
5
|
+
/** Image tag for the official `postgres` image. Default `"18-alpine"`.
|
|
6
|
+
* Ignored when {@link image} is set. */
|
|
8
7
|
version?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Full image reference, overriding the default `postgres:<version>`. For a
|
|
10
|
+
* Bun-SQL-/wire-compatible image (timescaledb, postgis, pgvector, …) you
|
|
11
|
+
* still get the instrumented client on `ctx.svc.<name>.client`.
|
|
12
|
+
*/
|
|
13
|
+
image?: string;
|
|
9
14
|
/** Database created on first boot. */
|
|
10
15
|
database: string;
|
|
11
16
|
/** Superuser name created on first boot. */
|
|
@@ -21,94 +26,23 @@ export interface PostgresOptions {
|
|
|
21
26
|
persistent?: boolean;
|
|
22
27
|
/** TCP port the container listens on. Default `5432`. */
|
|
23
28
|
port?: number;
|
|
29
|
+
/**
|
|
30
|
+
* CMD args appended after the image entrypoint (keeps the entrypoint, unlike
|
|
31
|
+
* `command`). For extension tuning, e.g.
|
|
32
|
+
* `["postgres", "-c", "timescaledb.max_background_workers=0"]`.
|
|
33
|
+
*/
|
|
34
|
+
args?: string[];
|
|
24
35
|
/** Extra environment variables forwarded to the container. */
|
|
25
36
|
env?: Record<string, string>;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
/**
|
|
29
|
-
* Minimal structural type for the parts of `Bun.SQL` we use. Declared
|
|
30
|
-
* locally so user projects don't need `@types/bun` for type-checking to
|
|
31
|
-
* follow the SDK through to the daemon. The real type comes from Bun at
|
|
32
|
-
* runtime via `globalThis.Bun.SQL`.
|
|
33
|
-
*
|
|
34
|
-
* `SqlClient` is callable as a tagged template (`sql\`...\``) and has an
|
|
35
|
-
* `unsafe(text, params?)` escape hatch. Both return a thenable that
|
|
36
|
-
* executes lazily; we wrap them so each settle is recorded as a DbEvent.
|
|
37
|
-
*
|
|
38
|
-
* Both forms are generic in the row type and resolve to an array of rows,
|
|
39
|
-
* so callers name the shape once at the call site instead of casting the
|
|
40
|
-
* result: `` await ctx.svc.db.client<TodoRow>`SELECT * FROM todos` `` is
|
|
41
|
-
* `Promise<TodoRow[]>`. `T` defaults to `unknown` (the row shape is the
|
|
42
|
-
* caller's to assert) — pass it to drop the cast.
|
|
43
|
-
*/
|
|
44
|
-
interface SqlClientBase {
|
|
45
|
-
close?(opts?: { timeout?: number }): Promise<void>;
|
|
46
|
-
/** Close the connection / drain the pool (Bun's `SQL.end`). */
|
|
47
|
-
end(opts?: { timeout?: number }): Promise<void>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SqlClient extends SqlClientBase {
|
|
51
|
-
// Callable as a tagged template; resolves to the result rows.
|
|
52
|
-
<T = unknown>(
|
|
53
|
-
strings: TemplateStringsArray,
|
|
54
|
-
...values: unknown[]
|
|
55
|
-
): Promise<T[]>;
|
|
56
|
-
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<T[]>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The recorder-instrumented client wired onto `ctx.svc.<name>.client`. Same
|
|
61
|
-
* surface as {@link SqlClient}, but each query resolves to a **wrapped** row
|
|
62
|
-
* array ({@link Wrapped}) rather than the raw rows: every settle is recorded
|
|
63
|
-
* as a DbEvent, and the wrapper carries provenance back to it. That's why
|
|
64
|
-
* `expect(rows)` / `expect(rows[0]!.id)` link under the query in the timeline
|
|
65
|
-
* with no cast — and why you `.unwrap()` before feeding a row
|
|
66
|
-
* value to a real client or a `===`. The raw escape-hatch {@link sqlClient}
|
|
67
|
-
* stays un-instrumented (plain {@link SqlClient}), so its results are genuinely
|
|
68
|
-
* raw and belong with `expectRaw`.
|
|
69
|
-
*/
|
|
70
|
-
export interface RecordingSqlClient extends SqlClientBase {
|
|
71
|
-
<T = unknown>(
|
|
72
|
-
strings: TemplateStringsArray,
|
|
73
|
-
...values: unknown[]
|
|
74
|
-
): Promise<Wrapped<T[]>>;
|
|
75
|
-
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<Wrapped<T[]>>;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface BunGlobal {
|
|
79
|
-
SQL: new (url: string) => SqlClient;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Open a raw Bun SQL client against `url`, typed as {@link SqlClient}.
|
|
84
|
-
*
|
|
85
|
-
* Unlike the pool `postgres(...)` wires onto `ctx.svc.<name>.client`, this
|
|
86
|
-
* is **not** recorder-instrumented — reach for it when you only learn a
|
|
87
|
-
* connection string at runtime (e.g. one a fake handed back after
|
|
88
|
-
* provisioning a database via `ctx.startService(...)`), where the boot-time
|
|
89
|
-
* component isn't an option. Queries are still generic in the row type:
|
|
90
|
-
* `` await sqlClient(url)<{ name: string }>`SELECT name FROM t` ``.
|
|
91
|
-
*
|
|
92
|
-
* Requires the Bun runtime (it runs inside the spectest daemon), so it
|
|
93
|
-
* replaces the `(globalThis as any).Bun` reach-around with a typed entry
|
|
94
|
-
* point.
|
|
95
|
-
*/
|
|
96
|
-
export function sqlClient(url: string): SqlClient {
|
|
97
|
-
const bun = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
98
|
-
if (!bun?.SQL) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
"sqlClient(url) requires Bun.SQL (Bun >= 1.2) — it runs inside the spectest daemon.",
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
return new bun.SQL(url);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
39
|
/** Helpers a `postgres(...)` service exposes on `ctx.svc.<name>`. */
|
|
107
40
|
export interface PostgresHelpers {
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
|
|
41
|
+
/** An instrumented Bun SQL client — each query lands on the test event log
|
|
42
|
+
* alongside http/exec/assertion events, and its rows come back
|
|
43
|
+
* inspect-wrapped so `expect(...)` on them links to the query. See
|
|
44
|
+
* {@link SQL}. */
|
|
45
|
+
client: SqlClient;
|
|
112
46
|
}
|
|
113
47
|
|
|
114
48
|
/**
|
|
@@ -125,9 +59,20 @@ export interface PostgresHelpers {
|
|
|
125
59
|
* example above). Tests get a wired-up Bun SQL client at
|
|
126
60
|
* `ctx.svc.<key>.client` — `await ctx.svc.db.client\`SELECT 1\`` — with
|
|
127
61
|
* every query recorded on the test event log.
|
|
62
|
+
*
|
|
63
|
+
* Point it at a wire-compatible image with `image`/`args`:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* db: postgres({
|
|
67
|
+
* image: "timescale/timescaledb:latest-pg17",
|
|
68
|
+
* args: ["postgres", "-c", "timescaledb.max_background_workers=0"],
|
|
69
|
+
* database: "app", user: "app", password: "app",
|
|
70
|
+
* }),
|
|
71
|
+
* ```
|
|
128
72
|
*/
|
|
129
73
|
export function postgres(opts: PostgresOptions) {
|
|
130
74
|
const version = opts.version ?? "18-alpine";
|
|
75
|
+
const reference = opts.image ?? `postgres:${version}`;
|
|
131
76
|
const port = opts.port ?? 5432;
|
|
132
77
|
const persistent = opts.persistent ?? true;
|
|
133
78
|
// `satisfies` (instead of a return-type annotation) preserves the
|
|
@@ -135,7 +80,8 @@ export function postgres(opts: PostgresOptions) {
|
|
|
135
80
|
// The mapped type in `ServiceHandlesFor<S>` reads that to type
|
|
136
81
|
// `ctx.svc.<name>` as `{ client: SqlClient }`.
|
|
137
82
|
return {
|
|
138
|
-
image: { type: "registry", reference
|
|
83
|
+
image: { type: "registry", reference },
|
|
84
|
+
...(opts.args ? { args: opts.args } : {}),
|
|
139
85
|
env: {
|
|
140
86
|
POSTGRES_DB: opts.database,
|
|
141
87
|
POSTGRES_USER: opts.user,
|
|
@@ -149,133 +95,11 @@ export function postgres(opts: PostgresOptions) {
|
|
|
149
95
|
ports: [port],
|
|
150
96
|
readyCheck: { type: "tcp" as const, port, timeoutSecs: 60 },
|
|
151
97
|
helpers: ({ name }: { name: string }): PostgresHelpers => {
|
|
152
|
-
const bun = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
153
|
-
if (!bun?.SQL) {
|
|
154
|
-
throw new Error(
|
|
155
|
-
"postgres component requires Bun.SQL (Bun >= 1.2). " +
|
|
156
|
-
"This helpers factory is meant to run inside the spectest daemon.",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
98
|
const url =
|
|
160
99
|
`postgres://${encodeURIComponent(opts.user)}` +
|
|
161
100
|
`:${encodeURIComponent(opts.password)}` +
|
|
162
101
|
`@${name}:${port}/${encodeURIComponent(opts.database)}`;
|
|
163
|
-
return { client:
|
|
102
|
+
return { client: new SQL(url, { label: name }) };
|
|
164
103
|
},
|
|
165
104
|
} satisfies ServiceDefinition<PostgresHelpers>;
|
|
166
105
|
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Proxy a Bun.SQL instance so each tagged-template call and each
|
|
170
|
-
* `unsafe(...)` call emits a DbEvent into the active recorder when its
|
|
171
|
-
* promise settles. Other Bun.SQL methods (`.transaction`, `.array`,
|
|
172
|
-
* `.file`, …) pass through unwrapped.
|
|
173
|
-
*/
|
|
174
|
-
function wrapSqlForRecording(raw: SqlClient, service: string): RecordingSqlClient {
|
|
175
|
-
const wrapResult = <T>(
|
|
176
|
-
result: PromiseLike<T>,
|
|
177
|
-
query: string,
|
|
178
|
-
params: unknown[],
|
|
179
|
-
): Promise<T> => {
|
|
180
|
-
const started = Date.now();
|
|
181
|
-
const resv = reserveEvent();
|
|
182
|
-
return Promise.resolve(result).then(
|
|
183
|
-
(value) => {
|
|
184
|
-
const { rows, rowsTruncated, columns } = captureRows(value);
|
|
185
|
-
const seq = recordDb({
|
|
186
|
-
service,
|
|
187
|
-
query,
|
|
188
|
-
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
189
|
-
rowCount: rowCountOf(value),
|
|
190
|
-
rows,
|
|
191
|
-
rowsTruncated,
|
|
192
|
-
columns,
|
|
193
|
-
durationMs: Date.now() - started,
|
|
194
|
-
}, resv);
|
|
195
|
-
return wrap(value, seq) as T;
|
|
196
|
-
},
|
|
197
|
-
(err) => {
|
|
198
|
-
const e = err as Error;
|
|
199
|
-
recordDb({
|
|
200
|
-
service,
|
|
201
|
-
query,
|
|
202
|
-
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
203
|
-
durationMs: Date.now() - started,
|
|
204
|
-
error: e?.message ?? String(err),
|
|
205
|
-
}, resv);
|
|
206
|
-
throw err;
|
|
207
|
-
},
|
|
208
|
-
);
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const handler: ProxyHandler<SqlClient> = {
|
|
212
|
-
apply(target, thisArg, args) {
|
|
213
|
-
const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]];
|
|
214
|
-
const query = reconstructSqlTemplate(strings, values.length);
|
|
215
|
-
const result = Reflect.apply(
|
|
216
|
-
target as unknown as (...a: unknown[]) => PromiseLike<unknown>,
|
|
217
|
-
thisArg,
|
|
218
|
-
args,
|
|
219
|
-
);
|
|
220
|
-
return wrapResult(result, query, values);
|
|
221
|
-
},
|
|
222
|
-
get(target, prop, receiver) {
|
|
223
|
-
if (prop === "unsafe") {
|
|
224
|
-
return (text: string, params?: unknown[]) => {
|
|
225
|
-
const result = (
|
|
226
|
-
target.unsafe as (t: string, p?: unknown[]) => PromiseLike<unknown>
|
|
227
|
-
).call(target, text, params);
|
|
228
|
-
return wrapResult(result, text, params ?? []);
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
const v = Reflect.get(target, prop, receiver);
|
|
232
|
-
return typeof v === "function" ? v.bind(target) : v;
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
return new Proxy(raw, handler) as unknown as RecordingSqlClient;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Rebuild a parameterized SQL string from a tagged-template's pieces.
|
|
239
|
-
* Bun.SQL substitutes `${value}` for `$1`, `$2`, …; we do the same so
|
|
240
|
-
* the recorded query matches what the server sees. */
|
|
241
|
-
function reconstructSqlTemplate(
|
|
242
|
-
strings: TemplateStringsArray,
|
|
243
|
-
valueCount: number,
|
|
244
|
-
): string {
|
|
245
|
-
let out = strings[0] ?? "";
|
|
246
|
-
for (let i = 0; i < valueCount; i++) {
|
|
247
|
-
out += `$${i + 1}` + (strings[i + 1] ?? "");
|
|
248
|
-
}
|
|
249
|
-
return out.trim();
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function rowCountOf(value: unknown): number | undefined {
|
|
253
|
-
if (Array.isArray(value)) return value.length;
|
|
254
|
-
if (value && typeof value === "object") {
|
|
255
|
-
const obj = value as { count?: unknown; rowCount?: unknown };
|
|
256
|
-
if (typeof obj.count === "number") return obj.count;
|
|
257
|
-
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
258
|
-
}
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const MAX_DB_ROWS = 50;
|
|
263
|
-
|
|
264
|
-
function captureRows(value: unknown): {
|
|
265
|
-
rows?: unknown[];
|
|
266
|
-
rowsTruncated?: boolean;
|
|
267
|
-
columns?: string[];
|
|
268
|
-
} {
|
|
269
|
-
if (!Array.isArray(value) || value.length === 0) return {};
|
|
270
|
-
const slice = value.slice(0, MAX_DB_ROWS).map(safeSerialize);
|
|
271
|
-
const first = slice[0];
|
|
272
|
-
const columns =
|
|
273
|
-
first && typeof first === "object" && !Array.isArray(first)
|
|
274
|
-
? Object.keys(first as Record<string, unknown>)
|
|
275
|
-
: undefined;
|
|
276
|
-
return {
|
|
277
|
-
rows: slice,
|
|
278
|
-
rowsTruncated: value.length > MAX_DB_ROWS ? true : undefined,
|
|
279
|
-
columns,
|
|
280
|
-
};
|
|
281
|
-
}
|
package/src/daemon.ts
CHANGED
|
@@ -166,6 +166,25 @@ function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
|
|
|
166
166
|
}));
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
interface FakeMeta {
|
|
170
|
+
name: string;
|
|
171
|
+
hostnames: string[];
|
|
172
|
+
port: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Display-only summary of the project's fakes for the control plane (folded
|
|
176
|
+
// into the env config's `fakes`, surfaced on the run page). Fakes aren't part
|
|
177
|
+
// of `project.environment`, so they ride alongside it as a separate field of
|
|
178
|
+
// the /load response — just the routing surface, no handler/state/helpers.
|
|
179
|
+
function fakesSummary(project: Project | undefined): FakeMeta[] {
|
|
180
|
+
if (!project?.fakes) return [];
|
|
181
|
+
return Object.entries(project.fakes).map(([name, def]) => ({
|
|
182
|
+
name,
|
|
183
|
+
hostnames: def.hostnames.map((h) => h.toLowerCase()),
|
|
184
|
+
port: def.port ?? DEFAULT_FAKE_PORT,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
169
188
|
function resolveEntry(): string {
|
|
170
189
|
const explicit = process.env.SPECTEST_PROJECT_ENTRY;
|
|
171
190
|
if (explicit && existsSync(explicit)) return explicit;
|
|
@@ -3935,6 +3954,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3935
3954
|
jsonResponse(res, 200, {
|
|
3936
3955
|
environment: proj.environment,
|
|
3937
3956
|
cases: casesMetadata(proj.tests),
|
|
3957
|
+
fakes: fakesSummary(proj),
|
|
3938
3958
|
});
|
|
3939
3959
|
return;
|
|
3940
3960
|
}
|
|
@@ -3948,6 +3968,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3948
3968
|
jsonResponse(res, 200, {
|
|
3949
3969
|
environment: l.project.environment,
|
|
3950
3970
|
cases: casesMetadata(l.project.tests),
|
|
3971
|
+
fakes: fakesSummary(l.project),
|
|
3951
3972
|
});
|
|
3952
3973
|
return;
|
|
3953
3974
|
}
|
|
@@ -3966,6 +3987,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3966
3987
|
jsonResponse(res, 200, {
|
|
3967
3988
|
environment: l.project.environment,
|
|
3968
3989
|
cases: casesMetadata(l.project.tests),
|
|
3990
|
+
fakes: fakesSummary(l.project),
|
|
3969
3991
|
});
|
|
3970
3992
|
return;
|
|
3971
3993
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,16 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
34
34
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
35
35
|
export { field } from "./inspect.js";
|
|
36
36
|
|
|
37
|
+
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
38
|
+
// clients that record each operation on the test event log and return their
|
|
39
|
+
// results inspect-wrapped, so `expect(...)` on a result links back to the op
|
|
40
|
+
// in the timeline (same provenance mechanism as the wrapped `fetch`). Reach
|
|
41
|
+
// for these over the raw `Bun.*` clients in service `helpers` and tests so
|
|
42
|
+
// assertions stay tracked. See `sql.ts` / `redis.ts` / `s3.ts`.
|
|
43
|
+
export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
44
|
+
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
45
|
+
export { S3Client, type S3ClientLike, type S3File, type S3Options } from "./s3.js";
|
|
46
|
+
|
|
37
47
|
export type {
|
|
38
48
|
Browser,
|
|
39
49
|
BrowserOptions,
|
package/src/recorder.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type TestEvent =
|
|
|
19
19
|
| KubeEvent
|
|
20
20
|
| BrowserEvent
|
|
21
21
|
| DbEvent
|
|
22
|
+
| RedisEvent
|
|
23
|
+
| S3Event
|
|
22
24
|
| TerminalEvent
|
|
23
25
|
| TerminalStepEvent
|
|
24
26
|
| WaitEvent
|
|
@@ -124,6 +126,67 @@ export interface DbEvent extends BaseEvent {
|
|
|
124
126
|
error?: string;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* One Redis/Valkey command issued through the instrumented {@link
|
|
131
|
+
* "../redis".RedisClient} (a drop-in for `Bun.RedisClient` / `Bun.redis`).
|
|
132
|
+
*
|
|
133
|
+
* The return value is `wrap()`ped against this event's seq, so a later
|
|
134
|
+
* `expect(...)` that drills into it nests under this step in the timeline
|
|
135
|
+
* (same mechanism as http/db).
|
|
136
|
+
*/
|
|
137
|
+
export interface RedisEvent extends BaseEvent {
|
|
138
|
+
kind: "redis";
|
|
139
|
+
/** Label for the connection — the URL host (often the service name). */
|
|
140
|
+
service: string;
|
|
141
|
+
/** Command name, upper-cased (`GET`, `SET`, `HGETALL`, `PUBLISH`, …). */
|
|
142
|
+
command: string;
|
|
143
|
+
/** First argument — almost always the key the command targets. Surfaced
|
|
144
|
+
* separately so the UI can read `GET session:42` at a glance. */
|
|
145
|
+
key?: string;
|
|
146
|
+
/** Remaining arguments (best-effort JSON-safe; large/binary stringified).
|
|
147
|
+
* Omitted when there are none. */
|
|
148
|
+
args?: unknown[];
|
|
149
|
+
/** Reply, best-effort JSON-safe and capped. Omitted when the command threw
|
|
150
|
+
* or returned `null`/`undefined`. */
|
|
151
|
+
result?: unknown;
|
|
152
|
+
resultTruncated?: boolean;
|
|
153
|
+
durationMs: number;
|
|
154
|
+
/** Set if the command threw. */
|
|
155
|
+
error?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One object-storage operation through the instrumented {@link
|
|
160
|
+
* "../s3".S3Client} (a drop-in for `Bun.S3Client` / `Bun.s3`), covering both
|
|
161
|
+
* client-level calls (`write`/`delete`/`exists`/`list`/`presign`/`stat`) and
|
|
162
|
+
* the `S3File` handle `.file(path)` returns (`text`/`json`/`write`/…).
|
|
163
|
+
*
|
|
164
|
+
* The return value is `wrap()`ped against this event's seq, so `expect(...)`
|
|
165
|
+
* drilling into it nests under this step (same mechanism as http/db).
|
|
166
|
+
*/
|
|
167
|
+
export interface S3Event extends BaseEvent {
|
|
168
|
+
kind: "s3";
|
|
169
|
+
/** Operation: `write` | `read` | `delete` | `exists` | `list` |
|
|
170
|
+
* `presign` | `stat`. */
|
|
171
|
+
op: string;
|
|
172
|
+
/** Bucket, when the client/handle knows it. */
|
|
173
|
+
bucket?: string;
|
|
174
|
+
/** Object key (path) the op targeted. Omitted for bucket-wide `list`. */
|
|
175
|
+
key?: string;
|
|
176
|
+
/** Bytes written or read, when known. */
|
|
177
|
+
size?: number;
|
|
178
|
+
/** Content type, when known (read/write/stat). */
|
|
179
|
+
contentType?: string;
|
|
180
|
+
/** Small text preview of the body (read/write of text), capped. */
|
|
181
|
+
preview?: string;
|
|
182
|
+
previewTruncated?: boolean;
|
|
183
|
+
/** For `list`: number of keys returned. */
|
|
184
|
+
count?: number;
|
|
185
|
+
durationMs: number;
|
|
186
|
+
/** Set if the op threw. */
|
|
187
|
+
error?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
127
190
|
export interface HttpEvent extends BaseEvent {
|
|
128
191
|
kind: "http";
|
|
129
192
|
method: string;
|
|
@@ -556,6 +619,20 @@ export function recordDb(
|
|
|
556
619
|
return active() ? current!.push({ kind: "db", ...ev }, reservation) : undefined;
|
|
557
620
|
}
|
|
558
621
|
|
|
622
|
+
export function recordRedis(
|
|
623
|
+
ev: Omit<RedisEvent, "seq" | "tOffsetMs" | "kind">,
|
|
624
|
+
reservation?: EventReservation,
|
|
625
|
+
): number | undefined {
|
|
626
|
+
return active() ? current!.push({ kind: "redis", ...ev }, reservation) : undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function recordS3(
|
|
630
|
+
ev: Omit<S3Event, "seq" | "tOffsetMs" | "kind">,
|
|
631
|
+
reservation?: EventReservation,
|
|
632
|
+
): number | undefined {
|
|
633
|
+
return active() ? current!.push({ kind: "s3", ...ev }, reservation) : undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
559
636
|
export function recordTerminal(
|
|
560
637
|
ev: Omit<TerminalEvent, "seq" | "tOffsetMs" | "kind">,
|
|
561
638
|
reservation?: EventReservation,
|
package/src/redis.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// `RedisClient` — a drop-in for `Bun.RedisClient` (`Bun.redis`) that records
|
|
2
|
+
// every command on the test event log and returns its reply inspect-wrapped,
|
|
3
|
+
// so `expect(await client.get("k"))` links the assertion back to the command in
|
|
4
|
+
// the timeline (same provenance mechanism as the wrapped `fetch` / `SQL`). Swap
|
|
5
|
+
// `new Bun.RedisClient(url)` → `new RedisClient(url)` and nothing else changes
|
|
6
|
+
// at the call site; replies come back `Wrapped<T>` (that wrapper is what carries
|
|
7
|
+
// provenance), so `.unwrap()` before a `===` or before feeding a value onward.
|
|
8
|
+
//
|
|
9
|
+
// Reach for it in service `helpers` when you stand up a redis/valkey yourself,
|
|
10
|
+
// or directly in a test against a connection string a fake handed back.
|
|
11
|
+
|
|
12
|
+
import { recordRedis, reserveEvent, safeSerialize } from "./recorder.js";
|
|
13
|
+
import { wrap } from "./inspect.js";
|
|
14
|
+
import type { Wrapped } from "./inspect.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Structural type for the parts of `Bun.RedisClient` we use, declared locally
|
|
18
|
+
* so user projects don't need `@types/bun`. The real implementation comes from
|
|
19
|
+
* Bun at runtime via `globalThis.Bun.RedisClient`. Commands resolve to their
|
|
20
|
+
* reply; lifecycle methods (`connect`/`close`/`subscribe`/…) are passed through
|
|
21
|
+
* un-instrumented.
|
|
22
|
+
*/
|
|
23
|
+
interface RawRedisClient {
|
|
24
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
25
|
+
close?(): void;
|
|
26
|
+
[method: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The instrumented Redis surface — same shape as `Bun.RedisClient`, but every
|
|
30
|
+
* command resolves to a {@link Wrapped} reply recorded as a `redis` event.
|
|
31
|
+
* Indexable so every Bun command method is reachable without re-declaring the
|
|
32
|
+
* whole (large) surface; the common ones are typed for ergonomics. */
|
|
33
|
+
export interface RedisClientLike {
|
|
34
|
+
get(key: string): Promise<Wrapped<string | null>>;
|
|
35
|
+
set(key: string, value: string): Promise<Wrapped<string>>;
|
|
36
|
+
del(...keys: string[]): Promise<Wrapped<number>>;
|
|
37
|
+
exists(...keys: string[]): Promise<Wrapped<number>>;
|
|
38
|
+
incr(key: string): Promise<Wrapped<number>>;
|
|
39
|
+
hgetall(key: string): Promise<Wrapped<Record<string, string>>>;
|
|
40
|
+
/** Run an arbitrary command — `client.send("SET", ["k", "v"])`. */
|
|
41
|
+
send(command: string, args: string[]): Promise<Wrapped<unknown>>;
|
|
42
|
+
close(): void;
|
|
43
|
+
[method: string]: (...args: any[]) => any;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options accepted by the {@link RedisClient} constructor beyond Bun's own. */
|
|
47
|
+
export interface RedisOptions {
|
|
48
|
+
/** Label shown on each recorded `redis` event (the timeline step's tag).
|
|
49
|
+
* Defaults to the connection URL's host (usually the service name). */
|
|
50
|
+
label?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BunGlobal {
|
|
54
|
+
RedisClient: new (url?: string, options?: unknown) => RawRedisClient;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RedisConstructor {
|
|
58
|
+
new (url?: string, opts?: RedisOptions): RedisClientLike;
|
|
59
|
+
(url?: string, opts?: RedisOptions): RedisClientLike;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Long-lived / connection-management methods that don't map to a single
|
|
63
|
+
// request→reply step — passed straight through, never recorded.
|
|
64
|
+
const REDIS_PASSTHROUGH = new Set([
|
|
65
|
+
"connect",
|
|
66
|
+
"close",
|
|
67
|
+
"disconnect",
|
|
68
|
+
"duplicate",
|
|
69
|
+
"subscribe",
|
|
70
|
+
"unsubscribe",
|
|
71
|
+
"psubscribe",
|
|
72
|
+
"punsubscribe",
|
|
73
|
+
"ssubscribe",
|
|
74
|
+
"sunsubscribe",
|
|
75
|
+
"onclose",
|
|
76
|
+
"ref",
|
|
77
|
+
"unref",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const MAX_RESULT_BYTES = 2048;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Open an instrumented Redis client against `url` (default: Bun's own
|
|
84
|
+
* `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
|
|
85
|
+
* runtime — it runs inside the spectest daemon.
|
|
86
|
+
*/
|
|
87
|
+
export const RedisClient = function RedisClient(
|
|
88
|
+
url?: string,
|
|
89
|
+
opts?: RedisOptions,
|
|
90
|
+
): RedisClientLike {
|
|
91
|
+
const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
|
|
92
|
+
if (!bun?.RedisClient) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"RedisClient requires the Bun runtime (Bun >= 1.2.9) — it runs inside the spectest daemon.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const raw = url !== undefined ? new bun.RedisClient(url, opts) : new bun.RedisClient();
|
|
98
|
+
return instrumentRedis(raw, opts?.label ?? hostLabel(url));
|
|
99
|
+
} as unknown as RedisConstructor;
|
|
100
|
+
|
|
101
|
+
function hostLabel(url?: string): string {
|
|
102
|
+
if (!url) return "redis";
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).hostname || "redis";
|
|
105
|
+
} catch {
|
|
106
|
+
return "redis";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
|
|
112
|
+
* `redis` event when its promise settles and resolves to a {@link Wrapped}
|
|
113
|
+
* reply. Lifecycle methods and non-function properties pass through.
|
|
114
|
+
*
|
|
115
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
116
|
+
*/
|
|
117
|
+
export function instrumentRedis(raw: RawRedisClient, label: string): RedisClientLike {
|
|
118
|
+
const run = (
|
|
119
|
+
command: string,
|
|
120
|
+
key: string | undefined,
|
|
121
|
+
extra: unknown[],
|
|
122
|
+
invoke: () => unknown,
|
|
123
|
+
): Promise<unknown> => {
|
|
124
|
+
const started = Date.now();
|
|
125
|
+
const resv = reserveEvent();
|
|
126
|
+
return Promise.resolve(invoke()).then(
|
|
127
|
+
(value) => {
|
|
128
|
+
const { preview, truncated } = capValue(value);
|
|
129
|
+
const seq = recordRedis(
|
|
130
|
+
{
|
|
131
|
+
service: label,
|
|
132
|
+
command,
|
|
133
|
+
key,
|
|
134
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
135
|
+
result: preview,
|
|
136
|
+
resultTruncated: truncated,
|
|
137
|
+
durationMs: Date.now() - started,
|
|
138
|
+
},
|
|
139
|
+
resv,
|
|
140
|
+
);
|
|
141
|
+
return wrap(value, seq);
|
|
142
|
+
},
|
|
143
|
+
(err) => {
|
|
144
|
+
const e = err as Error;
|
|
145
|
+
recordRedis(
|
|
146
|
+
{
|
|
147
|
+
service: label,
|
|
148
|
+
command,
|
|
149
|
+
key,
|
|
150
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
151
|
+
durationMs: Date.now() - started,
|
|
152
|
+
error: e?.message ?? String(err),
|
|
153
|
+
},
|
|
154
|
+
resv,
|
|
155
|
+
);
|
|
156
|
+
throw err;
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handler: ProxyHandler<RawRedisClient> = {
|
|
162
|
+
get(target, prop, receiver) {
|
|
163
|
+
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
164
|
+
const v = Reflect.get(target, prop, receiver);
|
|
165
|
+
if (typeof v !== "function") return v;
|
|
166
|
+
const fn = v as (...a: unknown[]) => unknown;
|
|
167
|
+
if (REDIS_PASSTHROUGH.has(prop)) return fn.bind(target);
|
|
168
|
+
// `send(command, args)` carries the real command in its first arg.
|
|
169
|
+
if (prop === "send") {
|
|
170
|
+
return (command: string, args: string[] = []) =>
|
|
171
|
+
run(String(command).toUpperCase(), args[0], args.slice(1), () =>
|
|
172
|
+
fn.call(target, command, args),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return (...args: unknown[]) =>
|
|
176
|
+
run(
|
|
177
|
+
prop.toUpperCase(),
|
|
178
|
+
typeof args[0] === "string" ? args[0] : undefined,
|
|
179
|
+
args.slice(1),
|
|
180
|
+
() => fn.apply(target, args),
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
return new Proxy(raw, handler) as unknown as RedisClientLike;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Best-effort JSON-safe preview of a reply, capped at {@link MAX_RESULT_BYTES}. */
|
|
188
|
+
function capValue(value: unknown): { preview?: unknown; truncated?: boolean } {
|
|
189
|
+
if (value === null || value === undefined) return {};
|
|
190
|
+
const safe = safeSerialize(value);
|
|
191
|
+
const json = (() => {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.stringify(safe);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
if (json !== undefined && json.length > MAX_RESULT_BYTES) {
|
|
199
|
+
return { preview: `${json.slice(0, MAX_RESULT_BYTES)}…`, truncated: true };
|
|
200
|
+
}
|
|
201
|
+
return { preview: safe };
|
|
202
|
+
}
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// `S3Client` — a drop-in for `Bun.S3Client` (`Bun.s3`) that records every
|
|
2
|
+
// object-storage operation on the test event log and returns its result
|
|
3
|
+
// inspect-wrapped, so `expect(await client.file("k").json())` links the
|
|
4
|
+
// assertion back to the read in the timeline (same provenance mechanism as the
|
|
5
|
+
// wrapped `fetch` / `SQL` / `RedisClient`). Swap `new Bun.S3Client(opts)` →
|
|
6
|
+
// `new S3Client(opts)` and nothing else changes at the call site.
|
|
7
|
+
//
|
|
8
|
+
// Instrumentation covers both the client-level ops (`write`/`delete`/`exists`/
|
|
9
|
+
// `list`/`presign`/`stat`/`size`) and the lazy `S3File` handle that
|
|
10
|
+
// `.file(path)` returns (`text`/`json`/`arrayBuffer`/`bytes`/`write`/…). Reach
|
|
11
|
+
// for it whenever you stand up S3-compatible storage (MinIO, real S3, R2) in a
|
|
12
|
+
// service `helpers` factory or a test.
|
|
13
|
+
|
|
14
|
+
import { recordS3, reserveEvent } from "./recorder.js";
|
|
15
|
+
import { wrap } from "./inspect.js";
|
|
16
|
+
import type { Wrapped } from "./inspect.js";
|
|
17
|
+
|
|
18
|
+
/** Structural types for the parts of `Bun.S3Client` we use, declared locally so
|
|
19
|
+
* user projects don't need `@types/bun`. The real implementation comes from
|
|
20
|
+
* Bun at runtime via `globalThis.Bun.S3Client`. */
|
|
21
|
+
interface RawS3File {
|
|
22
|
+
name?: string;
|
|
23
|
+
bucket?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
type?: string;
|
|
26
|
+
text(): Promise<string>;
|
|
27
|
+
json<T = unknown>(): Promise<T>;
|
|
28
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
29
|
+
bytes(): Promise<Uint8Array>;
|
|
30
|
+
write(data: unknown, options?: unknown): Promise<number>;
|
|
31
|
+
exists(): Promise<boolean>;
|
|
32
|
+
delete(): Promise<void>;
|
|
33
|
+
unlink?(): Promise<void>;
|
|
34
|
+
stat(): Promise<unknown>;
|
|
35
|
+
presign(options?: unknown): string;
|
|
36
|
+
[m: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RawS3Client {
|
|
40
|
+
file(path: string, options?: unknown): RawS3File;
|
|
41
|
+
write(path: string, data: unknown, options?: unknown): Promise<number>;
|
|
42
|
+
delete(path: string, options?: unknown): Promise<void>;
|
|
43
|
+
unlink?(path: string, options?: unknown): Promise<void>;
|
|
44
|
+
exists(path: string, options?: unknown): Promise<boolean>;
|
|
45
|
+
size?(path: string, options?: unknown): Promise<number>;
|
|
46
|
+
stat(path: string, options?: unknown): Promise<unknown>;
|
|
47
|
+
presign(path: string, options?: unknown): string;
|
|
48
|
+
list(input?: unknown, options?: unknown): Promise<unknown>;
|
|
49
|
+
[m: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A lazy S3 object handle (Bun's `S3File`), wrapped so each read/write records
|
|
53
|
+
* an `s3` event and returns a {@link Wrapped} result. */
|
|
54
|
+
export interface S3File {
|
|
55
|
+
readonly name?: string;
|
|
56
|
+
readonly bucket?: string;
|
|
57
|
+
readonly size?: number;
|
|
58
|
+
text(): Promise<Wrapped<string>>;
|
|
59
|
+
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
60
|
+
arrayBuffer(): Promise<Wrapped<ArrayBuffer>>;
|
|
61
|
+
bytes(): Promise<Wrapped<Uint8Array>>;
|
|
62
|
+
write(data: unknown, options?: unknown): Promise<Wrapped<number>>;
|
|
63
|
+
exists(): Promise<Wrapped<boolean>>;
|
|
64
|
+
delete(): Promise<Wrapped<void>>;
|
|
65
|
+
stat(): Promise<Wrapped<unknown>>;
|
|
66
|
+
presign(options?: unknown): string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The instrumented S3 surface — same shape as `Bun.S3Client`, but every op
|
|
70
|
+
* records an `s3` event and resolves to a {@link Wrapped} result. */
|
|
71
|
+
export interface S3ClientLike {
|
|
72
|
+
file(path: string, options?: unknown): S3File;
|
|
73
|
+
write(path: string, data: unknown, options?: unknown): Promise<Wrapped<number>>;
|
|
74
|
+
delete(path: string, options?: unknown): Promise<Wrapped<void>>;
|
|
75
|
+
exists(path: string, options?: unknown): Promise<Wrapped<boolean>>;
|
|
76
|
+
stat(path: string, options?: unknown): Promise<Wrapped<unknown>>;
|
|
77
|
+
list(input?: unknown, options?: unknown): Promise<Wrapped<unknown>>;
|
|
78
|
+
presign(path: string, options?: unknown): string;
|
|
79
|
+
[m: string]: (...args: any[]) => any;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Options accepted by the {@link S3Client} constructor — Bun's own plus a
|
|
83
|
+
* recording `label`. Passed through to `Bun.S3Client` verbatim. */
|
|
84
|
+
export interface S3Options {
|
|
85
|
+
accessKeyId?: string;
|
|
86
|
+
secretAccessKey?: string;
|
|
87
|
+
sessionToken?: string;
|
|
88
|
+
bucket?: string;
|
|
89
|
+
region?: string;
|
|
90
|
+
endpoint?: string;
|
|
91
|
+
[opt: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface BunGlobal {
|
|
95
|
+
S3Client: new (options?: S3Options) => RawS3Client;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface S3Constructor {
|
|
99
|
+
new (options?: S3Options): S3ClientLike;
|
|
100
|
+
(options?: S3Options): S3ClientLike;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MAX_PREVIEW = 512;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Open an instrumented S3 client. Usable with or without `new`. Requires the
|
|
107
|
+
* Bun runtime — it runs inside the spectest daemon.
|
|
108
|
+
*/
|
|
109
|
+
export const S3Client = function S3Client(options?: S3Options): S3ClientLike {
|
|
110
|
+
const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
|
|
111
|
+
if (!bun?.S3Client) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"S3Client requires the Bun runtime (Bun >= 1.1) — it runs inside the spectest daemon.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return instrumentS3(new bun.S3Client(options), options?.bucket);
|
|
117
|
+
} as unknown as S3Constructor;
|
|
118
|
+
|
|
119
|
+
/** Record an awaitable S3 op, wrapping its settled result for provenance. */
|
|
120
|
+
function runS3<T>(
|
|
121
|
+
base: { op: string; bucket?: string; key?: string },
|
|
122
|
+
invoke: () => PromiseLike<T>,
|
|
123
|
+
describe?: (value: T) => Partial<{
|
|
124
|
+
size: number;
|
|
125
|
+
contentType: string;
|
|
126
|
+
preview: string;
|
|
127
|
+
previewTruncated: boolean;
|
|
128
|
+
count: number;
|
|
129
|
+
}>,
|
|
130
|
+
): Promise<T> {
|
|
131
|
+
const started = Date.now();
|
|
132
|
+
const resv = reserveEvent();
|
|
133
|
+
return Promise.resolve(invoke()).then(
|
|
134
|
+
(value) => {
|
|
135
|
+
const seq = recordS3(
|
|
136
|
+
{ ...base, ...(describe?.(value) ?? {}), durationMs: Date.now() - started },
|
|
137
|
+
resv,
|
|
138
|
+
);
|
|
139
|
+
return wrap(value, seq) as T;
|
|
140
|
+
},
|
|
141
|
+
(err) => {
|
|
142
|
+
const e = err as Error;
|
|
143
|
+
recordS3(
|
|
144
|
+
{ ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) },
|
|
145
|
+
resv,
|
|
146
|
+
);
|
|
147
|
+
throw err;
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Record a synchronous op (`presign`) — no result wrapping (returns a URL). */
|
|
153
|
+
function runS3Sync<T>(base: { op: string; bucket?: string; key?: string }, invoke: () => T): T {
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
try {
|
|
156
|
+
const value = invoke();
|
|
157
|
+
recordS3({ ...base, durationMs: Date.now() - started }, undefined);
|
|
158
|
+
return value;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const e = err as Error;
|
|
161
|
+
recordS3(
|
|
162
|
+
{ ...base, durationMs: Date.now() - started, error: e?.message ?? String(err) },
|
|
163
|
+
undefined,
|
|
164
|
+
);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Proxy a `Bun.S3Client` so each op records an `s3` event. `file(path)` returns
|
|
171
|
+
* a wrapped {@link S3File}; unknown methods pass through.
|
|
172
|
+
*
|
|
173
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
174
|
+
*/
|
|
175
|
+
export function instrumentS3(raw: RawS3Client, bucket?: string): S3ClientLike {
|
|
176
|
+
const handler: ProxyHandler<RawS3Client> = {
|
|
177
|
+
get(target, prop, receiver) {
|
|
178
|
+
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
179
|
+
switch (prop) {
|
|
180
|
+
case "file":
|
|
181
|
+
return (path: string, options?: unknown) =>
|
|
182
|
+
instrumentS3File(target.file(path, options), path, bucket);
|
|
183
|
+
case "write":
|
|
184
|
+
return (path: string, data: unknown, options?: unknown) =>
|
|
185
|
+
runS3({ op: "write", bucket, key: path }, () => target.write(path, data, options), () =>
|
|
186
|
+
describeData(data),
|
|
187
|
+
);
|
|
188
|
+
case "delete":
|
|
189
|
+
case "unlink":
|
|
190
|
+
return (path: string, options?: unknown) =>
|
|
191
|
+
runS3({ op: "delete", bucket, key: path }, () => target.delete(path, options));
|
|
192
|
+
case "exists":
|
|
193
|
+
return (path: string, options?: unknown) =>
|
|
194
|
+
runS3({ op: "exists", bucket, key: path }, () => target.exists(path, options));
|
|
195
|
+
case "size":
|
|
196
|
+
return (path: string, options?: unknown) =>
|
|
197
|
+
runS3({ op: "stat", bucket, key: path }, () => target.size!(path, options), (n) => ({
|
|
198
|
+
size: typeof n === "number" ? n : undefined,
|
|
199
|
+
}));
|
|
200
|
+
case "stat":
|
|
201
|
+
return (path: string, options?: unknown) =>
|
|
202
|
+
runS3({ op: "stat", bucket, key: path }, () => target.stat(path, options), describeStat);
|
|
203
|
+
case "list":
|
|
204
|
+
return (input?: unknown, options?: unknown) =>
|
|
205
|
+
runS3({ op: "list", bucket }, () => target.list(input, options), (r) => ({
|
|
206
|
+
count: listCount(r),
|
|
207
|
+
}));
|
|
208
|
+
case "presign":
|
|
209
|
+
return (path: string, options?: unknown) =>
|
|
210
|
+
runS3Sync({ op: "presign", bucket, key: path }, () => target.presign(path, options));
|
|
211
|
+
default: {
|
|
212
|
+
const v = Reflect.get(target, prop, receiver);
|
|
213
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
return new Proxy(raw, handler) as unknown as S3ClientLike;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function instrumentS3File(raw: RawS3File, key: string, bucket?: string): S3File {
|
|
222
|
+
const b = raw.bucket ?? bucket;
|
|
223
|
+
const handler: ProxyHandler<RawS3File> = {
|
|
224
|
+
get(target, prop, receiver) {
|
|
225
|
+
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
226
|
+
switch (prop) {
|
|
227
|
+
case "text":
|
|
228
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.text(), describeData);
|
|
229
|
+
case "json":
|
|
230
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.json(), describeData);
|
|
231
|
+
case "arrayBuffer":
|
|
232
|
+
return () =>
|
|
233
|
+
runS3({ op: "read", bucket: b, key }, () => target.arrayBuffer(), describeData);
|
|
234
|
+
case "bytes":
|
|
235
|
+
return () => runS3({ op: "read", bucket: b, key }, () => target.bytes(), describeData);
|
|
236
|
+
case "write":
|
|
237
|
+
return (data: unknown, options?: unknown) =>
|
|
238
|
+
runS3({ op: "write", bucket: b, key }, () => target.write(data, options), () =>
|
|
239
|
+
describeData(data),
|
|
240
|
+
);
|
|
241
|
+
case "exists":
|
|
242
|
+
return () => runS3({ op: "exists", bucket: b, key }, () => target.exists());
|
|
243
|
+
case "delete":
|
|
244
|
+
case "unlink":
|
|
245
|
+
return () => runS3({ op: "delete", bucket: b, key }, () => target.delete());
|
|
246
|
+
case "stat":
|
|
247
|
+
return () => runS3({ op: "stat", bucket: b, key }, () => target.stat(), describeStat);
|
|
248
|
+
case "presign":
|
|
249
|
+
return (options?: unknown) =>
|
|
250
|
+
runS3Sync({ op: "presign", bucket: b, key }, () => target.presign(options));
|
|
251
|
+
default: {
|
|
252
|
+
const v = Reflect.get(target, prop, receiver);
|
|
253
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
return new Proxy(raw, handler) as unknown as S3File;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Byte size + small text preview of a written/read body. */
|
|
262
|
+
function describeData(value: unknown): Partial<{
|
|
263
|
+
size: number;
|
|
264
|
+
contentType: string;
|
|
265
|
+
preview: string;
|
|
266
|
+
previewTruncated: boolean;
|
|
267
|
+
}> {
|
|
268
|
+
if (typeof value === "string") {
|
|
269
|
+
const truncated = value.length > MAX_PREVIEW;
|
|
270
|
+
return {
|
|
271
|
+
size: byteLength(value),
|
|
272
|
+
preview: truncated ? value.slice(0, MAX_PREVIEW) : value,
|
|
273
|
+
previewTruncated: truncated || undefined,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (value && typeof value === "object" && !isBinary(value)) {
|
|
277
|
+
// A parsed JSON body (from `.json()`): preview a capped serialization.
|
|
278
|
+
try {
|
|
279
|
+
const json = JSON.stringify(value);
|
|
280
|
+
const truncated = json.length > MAX_PREVIEW;
|
|
281
|
+
return {
|
|
282
|
+
contentType: "application/json",
|
|
283
|
+
preview: truncated ? `${json.slice(0, MAX_PREVIEW)}…` : json,
|
|
284
|
+
previewTruncated: truncated || undefined,
|
|
285
|
+
};
|
|
286
|
+
} catch {
|
|
287
|
+
/* fall through to size-only */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const size = byteLength(value);
|
|
291
|
+
return size !== undefined ? { size } : {};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function describeStat(stat: unknown): Partial<{ size: number; contentType: string }> {
|
|
295
|
+
if (stat && typeof stat === "object") {
|
|
296
|
+
const s = stat as { size?: unknown; type?: unknown; contentType?: unknown };
|
|
297
|
+
return {
|
|
298
|
+
size: typeof s.size === "number" ? s.size : undefined,
|
|
299
|
+
contentType:
|
|
300
|
+
typeof s.type === "string"
|
|
301
|
+
? s.type
|
|
302
|
+
: typeof s.contentType === "string"
|
|
303
|
+
? s.contentType
|
|
304
|
+
: undefined,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return {};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function listCount(result: unknown): number | undefined {
|
|
311
|
+
if (result && typeof result === "object") {
|
|
312
|
+
const r = result as { contents?: unknown[]; keyCount?: unknown };
|
|
313
|
+
if (Array.isArray(r.contents)) return r.contents.length;
|
|
314
|
+
if (typeof r.keyCount === "number") return r.keyCount;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isBinary(value: unknown): boolean {
|
|
320
|
+
return (
|
|
321
|
+
value instanceof ArrayBuffer ||
|
|
322
|
+
ArrayBuffer.isView(value) ||
|
|
323
|
+
(typeof Blob !== "undefined" && value instanceof Blob)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function byteLength(value: unknown): number | undefined {
|
|
328
|
+
if (typeof value === "string") return Buffer.byteLength(value);
|
|
329
|
+
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
330
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
331
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) return value.size;
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
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
|
+
}
|