@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- 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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
}
|