@obsunified/collector 2.0.1 → 2.1.1

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 (107) 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/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
@@ -9,6 +9,15 @@
9
9
  * Notable differences from D1 handled here:
10
10
  * - Placeholders: D1 uses `?`; Postgres uses `$1`, `$2`. We rewrite
11
11
  * `?` to `$N` in `prepare`. Statements MUST use `?` for portability.
12
+ * - SQLite built-ins used by the shared analysis SQL (`datetime`,
13
+ * `strftime`, `julianday`, `json_object`, `json_extract`, scalar
14
+ * `MAX`/`MIN`, …) are rewritten to Postgres equivalents by
15
+ * `translateD1Sql`. Timestamp columns are TEXT on both backends, so
16
+ * time windows are rendered as ISO text rather than `timestamptz`.
17
+ * - Result types: `pg` returns BIGINT/NUMERIC as strings, BOOLEAN as
18
+ * JS booleans and JSON/JSONB as parsed objects. Hosts must call
19
+ * `configurePostgresTypeParsers(pg.types)` once so rows come back in
20
+ * the D1 shape the stores expect (numbers, 0/1 flags, JSON text).
12
21
  * - `INSERT … RETURNING`: D1 doesn't support RETURNING; Postgres does.
13
22
  * Code that targets both MUST NOT rely on RETURNING.
14
23
  * - `TEXT vs VARCHAR`: D1 is permissive; we use TEXT exclusively in
@@ -23,7 +32,13 @@
23
32
  */
24
33
 
25
34
  import type { Pool, QueryResultRow } from "pg";
26
- import { postgresDialect, type SqlDb, type SqlStatement } from "./sql-db";
35
+ import {
36
+ postgresDialect,
37
+ postgresIsoText,
38
+ SQLITE_ISO_FORMAT,
39
+ type SqlDb,
40
+ type SqlStatement,
41
+ } from "./sql-db";
27
42
 
28
43
  export interface PostgresAdapterOptions {
29
44
  /**
@@ -57,9 +72,11 @@ export class PostgresAdapter implements SqlDb {
57
72
  }
58
73
 
59
74
  prepare(sql: string): SqlStatement {
75
+ const translated = prepareSqlForPostgres(sql);
60
76
  return new PostgresStatement(
61
77
  this.pool,
62
- translateD1Sql(rewriteQuestionMarks(sql)),
78
+ translated,
79
+ mixedCaseAliases(translated),
63
80
  );
64
81
  }
65
82
 
@@ -95,26 +112,52 @@ export class PostgresAdapter implements SqlDb {
95
112
  }
96
113
 
97
114
  class PostgresStatement implements SqlStatement {
98
- private bound: unknown[] = [];
99
-
100
115
  constructor(
101
116
  private readonly pool: Pool,
102
117
  private readonly sql: string,
118
+ private readonly aliases: ReadonlyMap<string, string> = new Map(),
119
+ private readonly bound: readonly unknown[] = [],
103
120
  ) {}
104
121
 
122
+ /**
123
+ * Returns a NEW statement, matching D1 semantics. Stores prepare once and
124
+ * bind per row (`calls.map((c) => stmt.bind(...))`), so mutating in place
125
+ * would make every statement in a batch carry the last row's values.
126
+ */
105
127
  bind(...args: unknown[]): SqlStatement {
106
- this.bound = args;
107
- return this;
128
+ return new PostgresStatement(this.pool, this.sql, this.aliases, args);
108
129
  }
109
130
 
110
131
  async first<T = Record<string, unknown>>(): Promise<T | null> {
111
132
  const result = await this.exec<T & QueryResultRow>();
112
- return (result.rows[0] as T | undefined) ?? null;
133
+ const row = result.rows[0] as T | undefined;
134
+ return row === undefined ? null : this.restoreAliases(row);
113
135
  }
114
136
 
115
137
  async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
116
138
  const result = await this.exec<T & QueryResultRow>();
117
- return { results: result.rows as unknown as T[] };
139
+ return {
140
+ results: (result.rows as unknown as T[]).map((row) =>
141
+ this.restoreAliases(row),
142
+ ),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Postgres folds unquoted identifiers to lower case, so `COUNT(*) AS
148
+ * totalCalls` comes back as `totalcalls`. D1 preserves the alias. Put the
149
+ * mixed-case spelling back so stores can read `row.totalCalls` on both.
150
+ */
151
+ private restoreAliases<T>(row: T): T {
152
+ if (this.aliases.size === 0 || !row || typeof row !== "object") return row;
153
+ const out = row as Record<string, unknown>;
154
+ for (const [lower, original] of this.aliases) {
155
+ if (lower in out && !(original in out)) {
156
+ out[original] = out[lower];
157
+ delete out[lower];
158
+ }
159
+ }
160
+ return row;
118
161
  }
119
162
 
120
163
  async run(): Promise<{ meta: { changes: number } }> {
@@ -130,7 +173,7 @@ class PostgresStatement implements SqlStatement {
130
173
  values?: unknown[],
131
174
  ) => Promise<{ rowCount: number | null }>;
132
175
  }): Promise<{ meta: { changes: number } }> {
133
- const r = await client.query(this.sql, this.bound);
176
+ const r = await client.query(this.sql, [...this.bound]);
134
177
  return { meta: { changes: r.rowCount ?? 0 } };
135
178
  }
136
179
 
@@ -140,7 +183,7 @@ class PostgresStatement implements SqlStatement {
140
183
  }> {
141
184
  const client = await this.pool.connect();
142
185
  try {
143
- const r = await client.query<T>(this.sql, this.bound);
186
+ const r = await client.query<T>(this.sql, [...this.bound]);
144
187
  return { rows: r.rows, rowCount: r.rowCount };
145
188
  } finally {
146
189
  client.release();
@@ -148,11 +191,143 @@ class PostgresStatement implements SqlStatement {
148
191
  }
149
192
  }
150
193
 
194
+ /**
195
+ * Install `pg` result-type parsers so Postgres rows match the shape D1
196
+ * returns and the shared stores keep working unchanged:
197
+ *
198
+ * - BOOLEAN → 0 / 1 (D1 stores flags as INTEGER; stores compare `=== 1`)
199
+ * - BIGINT → number (`COUNT(*)`, `SUM(bigint)` would otherwise be strings;
200
+ * integral values above MAX_SAFE_INTEGER throw)
201
+ * - NUMERIC → number (`SUM`, `EXTRACT(EPOCH …)`; same overflow rule)
202
+ * - JSON/JSONB → raw text (stores `JSON.parse` the column themselves)
203
+ *
204
+ * Call once per process before issuing queries, e.g.
205
+ * `configurePostgresTypeParsers(pg.types)` in the Node entrypoint.
206
+ */
207
+ export interface PostgresTypeParserRegistry {
208
+ setTypeParser(oid: number, parser: (value: string) => unknown): void;
209
+ }
210
+
211
+ export const POSTGRES_TYPE_OIDS = {
212
+ BOOL: 16,
213
+ INT8: 20,
214
+ JSON: 114,
215
+ NUMERIC: 1700,
216
+ JSONB: 3802,
217
+ } as const;
218
+
219
+ /**
220
+ * Convert a BIGINT/NUMERIC text value to a JS number. Integral values beyond
221
+ * `Number.MAX_SAFE_INTEGER` cannot be represented exactly, so the query fails
222
+ * loudly instead of returning a silently rounded count. Fractional NUMERIC
223
+ * values round to double precision like D1's REAL. Exported for tests.
224
+ */
225
+ export const parsePostgresNumber = (
226
+ value: string,
227
+ typeName: "BIGINT" | "NUMERIC",
228
+ ): number => {
229
+ const parsed = Number(value);
230
+ if (!Number.isFinite(parsed)) {
231
+ throw new RangeError(
232
+ `PostgresAdapter: cannot convert ${typeName} value ${JSON.stringify(value)} to a JS number`,
233
+ );
234
+ }
235
+ if (/^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) {
236
+ throw new RangeError(
237
+ `PostgresAdapter: ${typeName} value ${value} exceeds Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER}); cast it to TEXT in SQL if the exact value is needed`,
238
+ );
239
+ }
240
+ return parsed;
241
+ };
242
+
243
+ export const configurePostgresTypeParsers = (
244
+ types: PostgresTypeParserRegistry,
245
+ ): void => {
246
+ types.setTypeParser(POSTGRES_TYPE_OIDS.BOOL, (value) =>
247
+ value === "t" || value === "true" ? 1 : 0,
248
+ );
249
+ types.setTypeParser(POSTGRES_TYPE_OIDS.INT8, (value) =>
250
+ parsePostgresNumber(value, "BIGINT"),
251
+ );
252
+ types.setTypeParser(POSTGRES_TYPE_OIDS.NUMERIC, (value) =>
253
+ parsePostgresNumber(value, "NUMERIC"),
254
+ );
255
+ types.setTypeParser(POSTGRES_TYPE_OIDS.JSON, (value) => value);
256
+ types.setTypeParser(POSTGRES_TYPE_OIDS.JSONB, (value) => value);
257
+ };
258
+
259
+ /**
260
+ * Full D1 → Postgres statement pipeline used by `prepare`: strip SQL
261
+ * comments (apostrophes inside `-- …` would otherwise confuse the quote
262
+ * tracking below), number the placeholders, translate SQLite built-ins.
263
+ */
264
+ export const prepareSqlForPostgres = (sql: string): string =>
265
+ translateD1Sql(rewriteQuestionMarks(stripSqlComments(sql)));
266
+
267
+ /**
268
+ * Remove `-- line` and `/* block *\/` comments outside string literals.
269
+ * Line breaks are preserved so the statement keeps its shape.
270
+ */
271
+ export const stripSqlComments = (sql: string): string => {
272
+ let out = "";
273
+ let inString = false;
274
+ for (let i = 0; i < sql.length; i++) {
275
+ const ch = sql[i];
276
+ if (ch === "'") {
277
+ inString = !inString;
278
+ out += ch;
279
+ continue;
280
+ }
281
+ if (inString) {
282
+ out += ch;
283
+ continue;
284
+ }
285
+ if (ch === "-" && sql[i + 1] === "-") {
286
+ const eol = sql.indexOf("\n", i);
287
+ if (eol === -1) return out;
288
+ i = eol - 1;
289
+ continue;
290
+ }
291
+ if (ch === "/" && sql[i + 1] === "*") {
292
+ const end = sql.indexOf("*/", i + 2);
293
+ if (end === -1) return out;
294
+ i = end + 1;
295
+ continue;
296
+ }
297
+ out += ch;
298
+ }
299
+ return out;
300
+ };
301
+
302
+ /**
303
+ * Collect `AS alias` names that mix upper and lower case (e.g. `totalCalls`)
304
+ * from the parts of the statement outside string literals, keyed by the
305
+ * lower-cased spelling Postgres will return. All-caps words are skipped so
306
+ * `CAST(x AS REAL)` is not mistaken for an alias.
307
+ */
308
+ export const mixedCaseAliases = (sql: string): Map<string, string> => {
309
+ const aliases = new Map<string, string>();
310
+ const pattern = /\bAS\s+([A-Za-z_][A-Za-z0-9_]*)/gi;
311
+ stripSqlComments(sql)
312
+ .split("'")
313
+ // Even-indexed segments are outside quotes (odd ones are literals).
314
+ .filter((_segment, index) => index % 2 === 0)
315
+ .forEach((segment) => {
316
+ for (const match of segment.matchAll(pattern)) {
317
+ const alias = match[1];
318
+ if (alias !== alias.toLowerCase() && alias !== alias.toUpperCase()) {
319
+ aliases.set(alias.toLowerCase(), alias);
320
+ }
321
+ }
322
+ });
323
+ return aliases;
324
+ };
325
+
151
326
  // D1 → Postgres placeholder rewrite. Walks the string once; tracks
152
327
  // whether we're inside a single-quoted string literal so that '?' inside
153
328
  // strings isn't rewritten. Doesn't handle dollar-quoted strings — none
154
329
  // of the collector's SQL uses them.
155
- const rewriteQuestionMarks = (sql: string): string => {
330
+ export const rewriteQuestionMarks = (sql: string): string => {
156
331
  let out = "";
157
332
  let inString = false;
158
333
  let n = 0;
@@ -173,54 +348,217 @@ const rewriteQuestionMarks = (sql: string): string => {
173
348
  return out;
174
349
  };
175
350
 
176
- const translateD1Sql = (sql: string): string => {
351
+ const UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
352
+ const INTERVAL_UNITS = /^(hour|hours|minute|minutes|day|days|second|seconds)$/i;
353
+
354
+ /** `'-5 minutes'` → `INTERVAL '5 minutes'`; `'-' || $1 || ' hours'` → `($1::text || ' hours')::interval`. */
355
+ const sqliteModifierToInterval = (modifier: string): string | null => {
356
+ const literal = /^'-(\d+)\s+([a-z]+)'$/i.exec(modifier);
357
+ if (literal && INTERVAL_UNITS.test(literal[2])) {
358
+ return `INTERVAL '${literal[1]} ${literal[2].toLowerCase()}'`;
359
+ }
360
+ const concat = /^'-'\s*\|\|\s*(.+?)\s*\|\|\s*'\s*([a-z]+)'$/i.exec(modifier);
361
+ if (concat && INTERVAL_UNITS.test(concat[2])) {
362
+ return `(${concat[1].trim()}::text || ' ${concat[2].toLowerCase()}')::interval`;
363
+ }
364
+ return null;
365
+ };
366
+
367
+ const asUtcTimestamp = (expr: string): string =>
368
+ `(${expr})::timestamptz AT TIME ZONE 'UTC'`;
369
+
370
+ const STRFTIME_FORMATS: Record<string, (expr: string) => string> = {
371
+ "%Y-%m-%dT%H:%M:00Z": (expr) =>
372
+ `to_char(date_trunc('minute', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:MI:00"Z"')`,
373
+ "%Y-%m-%dT%H:00:00Z": (expr) =>
374
+ `to_char(date_trunc('hour', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
375
+ "%s": (expr) => `EXTRACT(EPOCH FROM (${expr})::timestamptz)`,
376
+ };
377
+
378
+ const unquote = (literal: string): string | null => {
379
+ const m = /^'((?:[^']|'')*)'$/.exec(literal.trim());
380
+ return m ? m[1].replace(/''/g, "'") : null;
381
+ };
382
+
383
+ const jsonPathToPostgres = (
384
+ expr: string,
385
+ pathLiteral: string,
386
+ ): string | null => {
387
+ const path = unquote(pathLiteral);
388
+ if (!path?.startsWith("$.")) return null;
389
+ const rest = path.slice(2);
390
+ const quoted = /^"([^"]+)"$/.exec(rest);
391
+ const escapeKey = (key: string) =>
392
+ key.replace(/\\u002E/g, ".").replace(/'/g, "''");
393
+ if (quoted) return `((${expr})::jsonb ->> '${escapeKey(quoted[1])}')`;
394
+ if (!/^[A-Za-z0-9_.\\]+$/.test(rest)) return null;
395
+ const segments = rest.split(".").map(escapeKey);
396
+ if (segments.length === 1) return `((${expr})::jsonb ->> '${segments[0]}')`;
397
+ return `((${expr})::jsonb #>> '{${segments.join(",")}}')`;
398
+ };
399
+
400
+ /**
401
+ * SQLite → Postgres function rewrites. Each receives the already-translated
402
+ * argument list and returns the replacement, or `null` to leave the call
403
+ * untouched.
404
+ */
405
+ const FUNCTION_REWRITES: Record<string, (args: string[]) => string | null> = {
406
+ datetime: (args) => {
407
+ if (args.length === 1 && /^'now'$/i.test(args[0])) {
408
+ return postgresIsoText(UTC_NOW);
409
+ }
410
+ if (args.length === 2 && /^'now'$/i.test(args[0])) {
411
+ const interval = sqliteModifierToInterval(args[1]);
412
+ return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
413
+ }
414
+ if (args.length === 2 && /^'unixepoch'$/i.test(args[1])) {
415
+ return `to_timestamp(${args[0]})`;
416
+ }
417
+ return null;
418
+ },
419
+ strftime: (args) => {
420
+ const format = args.length >= 2 ? unquote(args[0]) : null;
421
+ // `strftime('%Y-%m-%dT%H:%M:%fZ', 'now'[, modifier])` is how the
422
+ // shared SQL and `sqliteDialect` render ISO-text time windows on
423
+ // SQLite; on Postgres that is the same `to_char(now - interval)`
424
+ // expression `postgresDialect` emits.
425
+ if (format === SQLITE_ISO_FORMAT && /^'now'$/i.test(args[1])) {
426
+ if (args.length === 2) return postgresIsoText(UTC_NOW);
427
+ if (args.length === 3) {
428
+ const interval = sqliteModifierToInterval(args[2]);
429
+ return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
430
+ }
431
+ return null;
432
+ }
433
+ if (args.length !== 2) return null;
434
+ const render = format ? STRFTIME_FORMATS[format] : undefined;
435
+ return render ? render(args[1]) : null;
436
+ },
437
+ julianday: (args) =>
438
+ args.length === 1
439
+ ? `(EXTRACT(EPOCH FROM (${args[0]})::timestamptz) / 86400.0 + 2440587.5)`
440
+ : null,
441
+ json_extract: (args) =>
442
+ args.length === 2 ? jsonPathToPostgres(args[0], args[1]) : null,
443
+ json_object: (args) => `json_build_object(${args.join(", ")})`,
444
+ json_array: (args) => `json_build_array(${args.join(", ")})`,
445
+ json_group_array: (args) => `json_agg(${args.join(", ")})`,
446
+ // SQLite's scalar MAX/MIN take several arguments; Postgres spells those
447
+ // GREATEST/LEAST. Single-argument MAX/MIN stay aggregates.
448
+ max: (args) => (args.length > 1 ? `GREATEST(${args.join(", ")})` : null),
449
+ min: (args) => (args.length > 1 ? `LEAST(${args.join(", ")})` : null),
450
+ // Postgres only defines ROUND(numeric, int); SQLite rounds REAL directly.
451
+ round: (args) =>
452
+ args.length === 2 ? `ROUND((${args[0]})::numeric, ${args[1]})` : null,
453
+ };
454
+
455
+ const FUNCTION_NAME_PATTERN = new RegExp(
456
+ `\\b(${Object.keys(FUNCTION_REWRITES).join("|")})\\s*\\(`,
457
+ "i",
458
+ );
459
+
460
+ const isIdentifierChar = (ch: string | undefined): boolean =>
461
+ ch !== undefined && /[A-Za-z0-9_.]/.test(ch);
462
+
463
+ /**
464
+ * Find the index of the `)` closing the call whose `(` is at `open`,
465
+ * skipping parentheses inside single-quoted literals. Returns -1 when the
466
+ * SQL is unbalanced.
467
+ */
468
+ const findClosingParen = (sql: string, open: number): number => {
469
+ let depth = 0;
470
+ let inString = false;
471
+ for (let i = open; i < sql.length; i++) {
472
+ const ch = sql[i];
473
+ if (ch === "'") {
474
+ inString = !inString;
475
+ continue;
476
+ }
477
+ if (inString) continue;
478
+ if (ch === "(") depth += 1;
479
+ else if (ch === ")") {
480
+ depth -= 1;
481
+ if (depth === 0) return i;
482
+ }
483
+ }
484
+ return -1;
485
+ };
486
+
487
+ /** Split a call's argument text on top-level commas (outside quotes/parens). */
488
+ const splitTopLevelArgs = (inner: string): string[] => {
489
+ const args: string[] = [];
490
+ let depth = 0;
491
+ let inString = false;
492
+ let current = "";
493
+ for (const ch of inner) {
494
+ if (ch === "'") inString = !inString;
495
+ if (!inString) {
496
+ if (ch === "(") depth += 1;
497
+ else if (ch === ")") depth -= 1;
498
+ else if (ch === "," && depth === 0) {
499
+ args.push(current.trim());
500
+ current = "";
501
+ continue;
502
+ }
503
+ }
504
+ current += ch;
505
+ }
506
+ if (current.trim() !== "" || args.length > 0) args.push(current.trim());
507
+ return args;
508
+ };
509
+
510
+ /** Does `index` fall inside a single-quoted literal of `sql`? */
511
+ const insideStringLiteral = (sql: string, index: number): boolean => {
512
+ let inString = false;
513
+ for (let i = 0; i < index; i++) {
514
+ if (sql[i] === "'") inString = !inString;
515
+ }
516
+ return inString;
517
+ };
518
+
519
+ const translateFunctionCalls = (sql: string): string => {
520
+ let out = "";
521
+ let rest = sql;
522
+ for (;;) {
523
+ const match = FUNCTION_NAME_PATTERN.exec(rest);
524
+ if (!match) return out + rest;
525
+ const start = match.index;
526
+ const open = start + match[0].length - 1;
527
+ if (insideStringLiteral(rest, start)) {
528
+ // Skip the rest of the literal so quote tracking stays in sync.
529
+ const closeQuote = rest.indexOf("'", start);
530
+ const cut = closeQuote === -1 ? rest.length : closeQuote + 1;
531
+ out += rest.slice(0, cut);
532
+ rest = rest.slice(cut);
533
+ continue;
534
+ }
535
+ const precededByIdentifier = isIdentifierChar(rest[start - 1]);
536
+ const close = findClosingParen(rest, open);
537
+ if (precededByIdentifier || close === -1) {
538
+ out += rest.slice(0, open + 1);
539
+ rest = rest.slice(open + 1);
540
+ continue;
541
+ }
542
+ const name = match[1].toLowerCase();
543
+ const inner = translateFunctionCalls(rest.slice(open + 1, close));
544
+ const replacement = FUNCTION_REWRITES[name](splitTopLevelArgs(inner));
545
+ out += rest.slice(0, start);
546
+ out += replacement ?? `${match[1]}(${inner})`;
547
+ rest = rest.slice(close + 1);
548
+ }
549
+ };
550
+
551
+ /**
552
+ * Translate SQL written against SQLite/D1 into Postgres. Placeholders must
553
+ * already be `$N` (see `rewriteQuestionMarks`). Exported for tests.
554
+ */
555
+ export const translateD1Sql = (sql: string): string => {
177
556
  let out = sql;
178
557
  const hadInsertOrIgnore = /\bINSERT\s+OR\s+IGNORE\s+INTO\b/i.test(out);
179
558
 
180
559
  out = out.replace(/\bINSERT\s+OR\s+IGNORE\s+INTO\b/gi, "INSERT INTO");
560
+ out = translateFunctionCalls(out);
181
561
 
182
- out = out.replace(
183
- /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*hours'\)/gi,
184
- "(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
185
- );
186
- out = out.replace(
187
- /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*minutes'\)/gi,
188
- "(CURRENT_TIMESTAMP - ($1::text || ' minutes')::interval)",
189
- );
190
- out = out.replace(
191
- /datetime\('now',\s*'-(\d+)\s+(hour|hours|minute|minutes|day|days)'\)/gi,
192
- "CURRENT_TIMESTAMP - INTERVAL '$1 $2'",
193
- );
194
- out = out.replace(/datetime\('now'\)/gi, "CURRENT_TIMESTAMP");
195
-
196
- out = out.replace(
197
- /json_extract\(([^,()]+),\s*'\$\.(?:"([^"]+)"|([A-Za-z0-9_\\.\\\\]+))'\)/gi,
198
- (
199
- _match,
200
- expr: string,
201
- quotedKey: string | undefined,
202
- bareKey: string | undefined,
203
- ) => {
204
- const key = (quotedKey ?? bareKey ?? "").replace(
205
- /\\\\u002E|\\u002E/g,
206
- ".",
207
- );
208
- return `(${expr.trim()}::jsonb ->> '${key.replace(/'/g, "''")}')`;
209
- },
210
- );
211
-
212
- out = out.replace(
213
- /strftime\('%Y-%m-%dT%H:%M:00Z',\s*([^)]+)\)/gi,
214
- "to_char(date_trunc('minute', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:MI:00\"Z\"')",
215
- );
216
- out = out.replace(
217
- /strftime\('%Y-%m-%dT%H:00:00Z',\s*([^)]+)\)/gi,
218
- "to_char(date_trunc('hour', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:00:00\"Z\"')",
219
- );
220
- out = out.replace(
221
- /strftime\('%s',\s*([^)]+)\)/gi,
222
- "EXTRACT(EPOCH FROM $1::timestamp)",
223
- );
224
562
  if (hadInsertOrIgnore && /\bON\s+CONFLICT\b/i.test(out) === false) {
225
563
  out = out.replace(/(;?\s*)$/, " ON CONFLICT DO NOTHING$1");
226
564
  }