@specific.dev/spectest 0.38.0 → 0.41.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.
Files changed (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
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 `S3Options`,
83
+ * passed through to `Bun.S3Client` verbatim. */
84
+ export interface S3ClientOptions {
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?: S3ClientOptions) => RawS3Client;
96
+ }
97
+
98
+ interface S3Constructor {
99
+ new (options?: S3ClientOptions): S3ClientLike;
100
+ (options?: S3ClientOptions): 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?: S3ClientOptions): 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,243 @@
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
+ rowsAffected: isAffectedCount(query, value) || undefined,
128
+ rows,
129
+ rowsTruncated,
130
+ columns,
131
+ durationMs: Date.now() - started,
132
+ },
133
+ resv,
134
+ );
135
+ return wrap(value, seq) as T;
136
+ },
137
+ (err) => {
138
+ const e = err as Error;
139
+ recordDb(
140
+ {
141
+ service: label,
142
+ query,
143
+ params: params.length > 0 ? params.map(safeSerialize) : undefined,
144
+ durationMs: Date.now() - started,
145
+ error: e?.message ?? String(err),
146
+ },
147
+ resv,
148
+ );
149
+ throw err;
150
+ },
151
+ );
152
+ };
153
+
154
+ const handler: ProxyHandler<RawSqlClient> = {
155
+ apply(target, thisArg, args) {
156
+ const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]];
157
+ const query = reconstructSqlTemplate(strings, values.length);
158
+ const result = Reflect.apply(
159
+ target as unknown as (...a: unknown[]) => PromiseLike<unknown>,
160
+ thisArg,
161
+ args,
162
+ );
163
+ return wrapResult(result, query, values);
164
+ },
165
+ get(target, prop, receiver) {
166
+ if (prop === "unsafe") {
167
+ return (text: string, params?: unknown[]) => {
168
+ const result = (
169
+ target.unsafe as (t: string, p?: unknown[]) => PromiseLike<unknown>
170
+ ).call(target, text, params);
171
+ return wrapResult(result, text, params ?? []);
172
+ };
173
+ }
174
+ const v = Reflect.get(target, prop, receiver);
175
+ return typeof v === "function" ? v.bind(target) : v;
176
+ },
177
+ };
178
+ return new Proxy(raw, handler) as unknown as SqlClient;
179
+ }
180
+
181
+ /** Rebuild a parameterized SQL string from a tagged-template's pieces.
182
+ * Bun.SQL substitutes `${value}` for `$1`, `$2`, …; we do the same so
183
+ * the recorded query matches what the server sees. */
184
+ function reconstructSqlTemplate(
185
+ strings: TemplateStringsArray,
186
+ valueCount: number,
187
+ ): string {
188
+ let out = strings[0] ?? "";
189
+ for (let i = 0; i < valueCount; i++) {
190
+ out += `$${i + 1}` + (strings[i + 1] ?? "");
191
+ }
192
+ return out.trim();
193
+ }
194
+
195
+ function rowCountOf(value: unknown): number | undefined {
196
+ if (value && typeof value === "object") {
197
+ const obj = value as { count?: unknown; rowCount?: unknown };
198
+ // Bun.SQL (postgres.js semantics): `.count` is rows RETURNED for
199
+ // SELECT/RETURNING queries and rows AFFECTED for other writes — where
200
+ // the result array itself is empty. Check it before `.length`, or a
201
+ // non-RETURNING `UPDATE` that touched 3 rows records a misleading 0.
202
+ if (typeof obj.count === "number") return obj.count;
203
+ if (typeof obj.rowCount === "number") return obj.rowCount;
204
+ }
205
+ if (Array.isArray(value)) return value.length;
206
+ return undefined;
207
+ }
208
+
209
+ /** True when the recorded count means "rows affected" rather than "rows
210
+ * returned" — a write with no result set. Lets the dashboard render
211
+ * `3 affected` instead of `3 rows` (and keeps `UPDATE … (0 rows)` from
212
+ * reading as "nothing updated"). */
213
+ function isAffectedCount(query: string, value: unknown): boolean {
214
+ if (Array.isArray(value) && value.length > 0) return false; // rows came back
215
+ const q = query.trimStart().slice(0, 8).toUpperCase();
216
+ return (
217
+ q.startsWith("INSERT") ||
218
+ q.startsWith("UPDATE") ||
219
+ q.startsWith("DELETE") ||
220
+ q.startsWith("MERGE")
221
+ );
222
+ }
223
+
224
+ const MAX_DB_ROWS = 50;
225
+
226
+ function captureRows(value: unknown): {
227
+ rows?: unknown[];
228
+ rowsTruncated?: boolean;
229
+ columns?: string[];
230
+ } {
231
+ if (!Array.isArray(value) || value.length === 0) return {};
232
+ const slice = value.slice(0, MAX_DB_ROWS).map(safeSerialize);
233
+ const first = slice[0];
234
+ const columns =
235
+ first && typeof first === "object" && !Array.isArray(first)
236
+ ? Object.keys(first as Record<string, unknown>)
237
+ : undefined;
238
+ return {
239
+ rows: slice,
240
+ rowsTruncated: value.length > MAX_DB_ROWS ? true : undefined,
241
+ columns,
242
+ };
243
+ }