@obsunified/collector 2.0.1 → 2.2.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 (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Unit coverage for the D1 → Postgres SQL translation and the `pg` result
3
+ * type parsers. These run without a database; the live-Postgres checks
4
+ * live in `sql-db-postgres.integration.test.ts`.
5
+ */
6
+
7
+ import type { Pool } from "pg";
8
+ import { describe, expect, it } from "vitest";
9
+ import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
10
+ import { TIER0_ANALYSES } from "../analyses/tier0";
11
+ import { postgresDialect, sqliteDialect } from "./sql-db";
12
+ import {
13
+ configurePostgresTypeParsers,
14
+ mixedCaseAliases,
15
+ POSTGRES_TYPE_OIDS,
16
+ PostgresAdapter,
17
+ parsePostgresNumber,
18
+ prepareSqlForPostgres,
19
+ rewriteQuestionMarks,
20
+ stripSqlComments,
21
+ translateD1Sql,
22
+ } from "./sql-db-postgres";
23
+
24
+ const ISO = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`;
25
+ const UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
26
+ const translate = prepareSqlForPostgres;
27
+
28
+ describe("rewriteQuestionMarks", () => {
29
+ it("numbers placeholders and leaves '?' inside string literals alone", () => {
30
+ expect(rewriteQuestionMarks("SELECT ? WHERE a = ? AND b = 'x?y'")).toBe(
31
+ "SELECT $1 WHERE a = $2 AND b = 'x?y'",
32
+ );
33
+ });
34
+ });
35
+
36
+ describe("stripSqlComments", () => {
37
+ it("drops -- and /* */ comments but keeps string literals and newlines", () => {
38
+ const sql =
39
+ "SELECT 'a -- not a comment' AS a, -- isn't this fun?\n 1 /* block ' */ AS b";
40
+ expect(stripSqlComments(sql)).toBe(
41
+ "SELECT 'a -- not a comment' AS a, \n 1 AS b",
42
+ );
43
+ });
44
+
45
+ it("keeps placeholder numbering correct after a comment with an apostrophe", () => {
46
+ expect(translate("SELECT ? -- don't\n WHERE x = ?")).toBe(
47
+ "SELECT $1 \n WHERE x = $2",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("translateD1Sql — time windows against TEXT timestamp columns", () => {
53
+ it("renders datetime('now') as ISO text", () => {
54
+ expect(
55
+ translate("DELETE FROM logs WHERE expires_at < datetime('now')"),
56
+ ).toBe(`DELETE FROM logs WHERE expires_at < to_char(${UTC_NOW}, ${ISO})`);
57
+ });
58
+
59
+ it("renders literal relative windows as ISO text", () => {
60
+ expect(translate("received_at >= datetime('now', '-5 minutes')")).toBe(
61
+ `received_at >= to_char(${UTC_NOW} - INTERVAL '5 minutes', ${ISO})`,
62
+ );
63
+ expect(translate("received_at < datetime('now', '-1 hour')")).toBe(
64
+ `received_at < to_char(${UTC_NOW} - INTERVAL '1 hour', ${ISO})`,
65
+ );
66
+ expect(translate("received_at >= datetime('now', '-25 hours')")).toBe(
67
+ `received_at >= to_char(${UTC_NOW} - INTERVAL '25 hours', ${ISO})`,
68
+ );
69
+ });
70
+
71
+ it("renders bound relative windows (sqlite dialect form) as ISO text", () => {
72
+ expect(
73
+ translate("received_at >= datetime('now', '-' || ? || ' hours')"),
74
+ ).toBe(
75
+ `received_at >= to_char(${UTC_NOW} - ($1::text || ' hours')::interval, ${ISO})`,
76
+ );
77
+ });
78
+
79
+ it("renders the sqlite ISO strftime('now') forms as the same ISO text", () => {
80
+ // `sqliteDialect` and the shared analysis SQL now spell time windows
81
+ // as strftime('%Y-%m-%dT%H:%M:%fZ', 'now'[, modifier]) so SQLite
82
+ // compares ISO text to ISO text; Postgres gets postgresDialect's shape.
83
+ expect(
84
+ translate("expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"),
85
+ ).toBe(`expires_at < to_char(${UTC_NOW}, ${ISO})`);
86
+ expect(
87
+ translate(
88
+ "received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')",
89
+ ),
90
+ ).toBe(`received_at >= to_char(${UTC_NOW} - INTERVAL '5 minutes', ${ISO})`);
91
+ expect(
92
+ translate(
93
+ "received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' hours')",
94
+ ),
95
+ ).toBe(
96
+ `received_at >= to_char(${UTC_NOW} - ($1::text || ' hours')::interval, ${ISO})`,
97
+ );
98
+ expect(translate(sqliteDialect.sinceMinutes("?"))).toBe(
99
+ postgresDialect.sinceMinutes("$1"),
100
+ );
101
+ expect(translate(sqliteDialect.now())).toBe(postgresDialect.now());
102
+ });
103
+
104
+ it("maps datetime(…, 'unixepoch') to to_timestamp", () => {
105
+ expect(translate("datetime(x, 'unixepoch')")).toBe("to_timestamp(x)");
106
+ });
107
+ });
108
+
109
+ describe("translateD1Sql — strftime / julianday", () => {
110
+ it("buckets by minute and hour in UTC", () => {
111
+ expect(
112
+ translate("strftime('%Y-%m-%dT%H:%M:00Z', received_at) AS minute"),
113
+ ).toBe(
114
+ `to_char(date_trunc('minute', (received_at)::timestamptz AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:00"Z"') AS minute`,
115
+ );
116
+ expect(
117
+ translate("strftime('%Y-%m-%dT%H:00:00Z', received_at) AS hour"),
118
+ ).toBe(
119
+ `to_char(date_trunc('hour', (received_at)::timestamptz AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') AS hour`,
120
+ );
121
+ });
122
+
123
+ it("handles the nested 5-minute bucket expression from tier0 latency p95", () => {
124
+ const sql = `strftime('%Y-%m-%dT%H:%M:00Z',
125
+ datetime((CAST(strftime('%s', received_at) AS INTEGER) / 300) * 300, 'unixepoch')
126
+ ) AS bucket`;
127
+ expect(translate(sql)).toBe(
128
+ `to_char(date_trunc('minute', (to_timestamp((CAST(EXTRACT(EPOCH FROM (received_at)::timestamptz) AS INTEGER) / 300) * 300))::timestamptz AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:00"Z"') AS bucket`,
129
+ );
130
+ });
131
+
132
+ it("rewrites julianday differences to epoch seconds", () => {
133
+ expect(
134
+ translate("(julianday(c.start_time) - julianday(p.end_time)) * 86400.0"),
135
+ ).toBe(
136
+ "((EXTRACT(EPOCH FROM (c.start_time)::timestamptz) / 86400.0 + 2440587.5) - (EXTRACT(EPOCH FROM (p.end_time)::timestamptz) / 86400.0 + 2440587.5)) * 86400.0",
137
+ );
138
+ });
139
+ });
140
+
141
+ describe("translateD1Sql — JSON functions", () => {
142
+ it("maps json_extract with quoted keys to ->>", () => {
143
+ expect(
144
+ translate(`json_extract(attributes_json, '$."messaging.destination"')`),
145
+ ).toBe(`((attributes_json)::jsonb ->> 'messaging.destination')`);
146
+ });
147
+
148
+ it("maps bare and nested json_extract paths", () => {
149
+ expect(translate("json_extract(link.value, '$.traceId')")).toBe(
150
+ "((link.value)::jsonb ->> 'traceId')",
151
+ );
152
+ expect(translate("json_extract(p, '$.a.b')")).toBe(
153
+ "((p)::jsonb #>> '{a,b}')",
154
+ );
155
+ });
156
+
157
+ it("maps json_object / json_array / json_group_array to Postgres builders", () => {
158
+ expect(
159
+ translate(
160
+ "json_object('unit', 'usd', 'n', (SELECT n FROM cur), 'rows', (SELECT json_group_array(json_array(service, errors)) FROM t))",
161
+ ),
162
+ ).toBe(
163
+ "json_build_object('unit', 'usd', 'n', (SELECT n FROM cur), 'rows', (SELECT json_agg(json_build_array(service, errors)) FROM t))",
164
+ );
165
+ });
166
+ });
167
+
168
+ describe("translateD1Sql — scalar MAX/MIN", () => {
169
+ it("turns multi-argument MAX/MIN into GREATEST/LEAST but keeps aggregates", () => {
170
+ expect(translate("WHERE rn = MAX(1, (95 * n + 99) / 100)")).toBe(
171
+ "WHERE rn = GREATEST(1, (95 * n + 99) / 100)",
172
+ );
173
+ expect(translate("SELECT MAX(duration_ms) AS approx_max, MIN(a, b)")).toBe(
174
+ "SELECT MAX(duration_ms) AS approx_max, LEAST(a, b)",
175
+ );
176
+ });
177
+ });
178
+
179
+ describe("translateD1Sql — ROUND", () => {
180
+ it("casts two-argument ROUND to numeric", () => {
181
+ expect(translate("ROUND(AVG(duration_ms), 2) AS avg_ms")).toBe(
182
+ "ROUND((AVG(duration_ms))::numeric, 2) AS avg_ms",
183
+ );
184
+ expect(translate("ROUND(x)")).toBe("ROUND(x)");
185
+ });
186
+ });
187
+
188
+ describe("translateD1Sql — safety", () => {
189
+ it("does not touch function names inside string literals", () => {
190
+ const sql = "SELECT 'datetime(''now'')' AS label, datetime('now') AS now";
191
+ expect(translate(sql)).toBe(
192
+ `SELECT 'datetime(''now'')' AS label, to_char(${UTC_NOW}, ${ISO}) AS now`,
193
+ );
194
+ });
195
+
196
+ it("keeps INSERT OR IGNORE → ON CONFLICT DO NOTHING", () => {
197
+ expect(translate("INSERT OR IGNORE INTO t (a) VALUES (?)")).toBe(
198
+ "INSERT INTO t (a) VALUES ($1) ON CONFLICT DO NOTHING",
199
+ );
200
+ });
201
+
202
+ it("leaves plain SQL untouched", () => {
203
+ const sql =
204
+ "SELECT COUNT(*) AS c, SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS e FROM ai_calls WHERE project_id = $1";
205
+ expect(translateD1Sql(sql)).toBe(sql);
206
+ });
207
+
208
+ it("leaves no SQLite-only built-ins in the shipped analysis SQL", () => {
209
+ const sqliteOnly =
210
+ /\b(datetime|strftime|julianday|json_object|json_array|json_group_array|json_extract)\s*\(/i;
211
+ for (const def of [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES]) {
212
+ const translated = translate(def.sql ?? "");
213
+ expect(translated, def.id).not.toMatch(sqliteOnly);
214
+ expect(translated, def.id).not.toMatch(/\bMAX\(\s*1\s*,/i);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe("mixedCaseAliases", () => {
220
+ it("maps the lower-cased Postgres column name back to the camelCase alias", () => {
221
+ const sql = `SELECT COUNT(*) as totalCalls, SUM(CAST(x AS REAL)) AS totalCostUsd,
222
+ COUNT(*) AS plain, 'AS notAnAlias' AS label FROM ai_calls`;
223
+ expect([...mixedCaseAliases(sql)]).toEqual([
224
+ ["totalcalls", "totalCalls"],
225
+ ["totalcostusd", "totalCostUsd"],
226
+ ]);
227
+ });
228
+
229
+ it("returns an empty map for snake_case-only statements", () => {
230
+ expect(
231
+ mixedCaseAliases("SELECT a AS b, CAST(c AS INTEGER) FROM t").size,
232
+ ).toBe(0);
233
+ });
234
+ });
235
+
236
+ describe("configurePostgresTypeParsers", () => {
237
+ it("returns D1-shaped values for booleans, big integers, numerics and JSON", () => {
238
+ const parsers = new Map<number, (value: string) => unknown>();
239
+ configurePostgresTypeParsers({
240
+ setTypeParser: (oid, parser) => {
241
+ parsers.set(oid, parser);
242
+ },
243
+ });
244
+ expect(parsers.get(POSTGRES_TYPE_OIDS.BOOL)?.("t")).toBe(1);
245
+ expect(parsers.get(POSTGRES_TYPE_OIDS.BOOL)?.("f")).toBe(0);
246
+ expect(parsers.get(POSTGRES_TYPE_OIDS.INT8)?.("42")).toBe(42);
247
+ expect(parsers.get(POSTGRES_TYPE_OIDS.NUMERIC)?.("0.25")).toBe(0.25);
248
+ expect(parsers.get(POSTGRES_TYPE_OIDS.JSON)?.('{"a":1}')).toBe('{"a":1}');
249
+ expect(parsers.get(POSTGRES_TYPE_OIDS.JSONB)?.("[1]")).toBe("[1]");
250
+ });
251
+
252
+ it("fails loudly instead of rounding integers beyond MAX_SAFE_INTEGER", () => {
253
+ expect(parsePostgresNumber("9007199254740991", "BIGINT")).toBe(
254
+ Number.MAX_SAFE_INTEGER,
255
+ );
256
+ expect(parsePostgresNumber("-9007199254740991", "NUMERIC")).toBe(
257
+ -Number.MAX_SAFE_INTEGER,
258
+ );
259
+ expect(parsePostgresNumber("12345.678", "NUMERIC")).toBe(12345.678);
260
+ expect(() => parsePostgresNumber("9007199254740993", "BIGINT")).toThrow(
261
+ /BIGINT value 9007199254740993 exceeds Number.MAX_SAFE_INTEGER/,
262
+ );
263
+ expect(() =>
264
+ parsePostgresNumber("18446744073709551616", "NUMERIC"),
265
+ ).toThrow(RangeError);
266
+ expect(() => parsePostgresNumber("NaN", "NUMERIC")).toThrow(RangeError);
267
+ });
268
+ });
269
+
270
+ describe("PostgresAdapter statements", () => {
271
+ /** Minimal `pg.Pool` stand-in that records every query it is asked to run. */
272
+ const fakePool = () => {
273
+ const queries: Array<{ text: string; values: unknown[] | undefined }> = [];
274
+ const client = {
275
+ query: async (text: string, values?: unknown[]) => {
276
+ queries.push({ text, values });
277
+ return {
278
+ rows: text.startsWith("SELECT") ? [{ totalcalls: 2, plain: 1 }] : [],
279
+ rowCount: 1,
280
+ };
281
+ },
282
+ release: () => {},
283
+ };
284
+ const pool = {
285
+ on: () => pool,
286
+ connect: async () => client,
287
+ } as unknown as Pool;
288
+ return { pool, queries };
289
+ };
290
+
291
+ it("bind() returns a new statement so one prepared statement can back a whole batch", async () => {
292
+ const { pool, queries } = fakePool();
293
+ const db = new PostgresAdapter(pool);
294
+ const stmt = db.prepare("INSERT OR IGNORE INTO t (a, b) VALUES (?, ?)");
295
+ await db.batch([stmt.bind("row-1", 1), stmt.bind("row-2", 2)]);
296
+ const inserts = queries.filter((q) => q.text.startsWith("INSERT"));
297
+ expect(inserts.map((q) => q.values)).toEqual([
298
+ ["row-1", 1],
299
+ ["row-2", 2],
300
+ ]);
301
+ expect(inserts[0]?.text).toBe(
302
+ "INSERT INTO t (a, b) VALUES ($1, $2) ON CONFLICT DO NOTHING",
303
+ );
304
+ });
305
+
306
+ it("restores camelCase aliases on rows read back from Postgres", async () => {
307
+ const { pool } = fakePool();
308
+ const db = new PostgresAdapter(pool);
309
+ const row = await db
310
+ .prepare("SELECT COUNT(*) AS totalCalls, 1 AS plain FROM t")
311
+ .first<{ totalCalls: number; plain: number }>();
312
+ expect(row).toEqual({ totalCalls: 2, plain: 1 });
313
+ });
314
+ });