lopata 0.19.2 → 0.20.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.
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * Local emulation of the Cloudflare Workers Analytics Engine **SQL API**.
3
+ *
4
+ * Cloudflare has no Worker binding for *reading* Analytics Engine data — you
5
+ * POST a ClickHouse-flavoured SQL string to
6
+ * https://api.cloudflare.com/client/v4/accounts/<acc>/analytics_engine/sql
7
+ * with `Authorization: Bearer <token>`. Lopata intercepts that fetch (see
8
+ * `plugin.ts`) and serves it from the local `analytics_engine` SQLite table so
9
+ * the *same* worker code runs locally and in production unchanged.
10
+ *
11
+ * Interception is gated on the token: a missing `Authorization` header or the
12
+ * sentinel `Bearer local` is served locally; any real bearer token falls
13
+ * through to the actual Cloudflare API. Drive the token from env (e.g. `local`
14
+ * in `.dev.vars`, the real secret in prod) to keep one code path. Note that
15
+ * `writeDataPoint` always writes locally — only the query fetch is gated.
16
+ *
17
+ * SPIKE SCOPE — this is a pragmatic subset, not a full ClickHouse engine:
18
+ * SELECT <items> FROM <dataset>
19
+ * [WHERE <cond>] [GROUP BY <exprs>] [ORDER BY <exprs>] [LIMIT n] [FORMAT f]
20
+ * with the functions/operators people actually use for dashboards (counts,
21
+ * sums, time filters, time bucketing). Unsupported constructs throw a clear
22
+ * error instead of silently returning wrong numbers.
23
+ *
24
+ * Key local simplifications (correct *because* there's no sampling locally):
25
+ * - `_sample_interval` is always 1, so `sum(_sample_interval)` == `count()`.
26
+ * - The `timestamp` column is stored as **ms epoch**; ClickHouse treats it as
27
+ * a `DateTime` (seconds). We canonicalise to **seconds** on read so
28
+ * `now()` / `INTERVAL` arithmetic lines up, then render `DateTime` *output*
29
+ * columns as ClickHouse `YYYY-MM-DD HH:MM:SS` (UTC) strings to match the real
30
+ * SQL API — so `new Date(row.bucket)` behaves the same locally as in prod.
31
+ */
32
+
33
+ import type { Database } from 'bun:sqlite'
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // URL matching
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const SQL_PATH_RE = /\/accounts\/[^/]+\/analytics_engine\/sql\/?$/
40
+
41
+ /** True for the Cloudflare Analytics Engine SQL API endpoint. */
42
+ export function isAnalyticsEngineSqlUrl(url: string): boolean {
43
+ try {
44
+ const u = new URL(url)
45
+ return u.hostname === 'api.cloudflare.com' && SQL_PATH_RE.test(u.pathname)
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Whether an AE SQL API request should be served from the local store rather
53
+ * than forwarded to Cloudflare. A missing `Authorization` header or the
54
+ * `Bearer local` sentinel means local; any real bearer token passes through.
55
+ */
56
+ export function isLocalAnalyticsEngineToken(auth: string | null | undefined): boolean {
57
+ return !auth || auth.trim().toLowerCase() === 'bearer local'
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Tokenizer
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface Token {
65
+ type: 'num' | 'str' | 'ident' | 'op' | 'punct'
66
+ value: string
67
+ /** Uppercased value for idents/ops — used for keyword matching. */
68
+ upper: string
69
+ start: number
70
+ end: number
71
+ }
72
+
73
+ const MULTI_OPS = ['<=', '>=', '!=', '<>']
74
+ const SINGLE_OPS = new Set(['+', '-', '*', '/', '%', '=', '<', '>'])
75
+ const PUNCT = new Set(['(', ')', ',', '.', ';'])
76
+
77
+ class SqlError extends Error {}
78
+
79
+ function tokenize(sql: string): Token[] {
80
+ const toks: Token[] = []
81
+ let i = 0
82
+ const n = sql.length
83
+ while (i < n) {
84
+ const c = sql[i]!
85
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
86
+ i++
87
+ continue
88
+ }
89
+ // line comments
90
+ if (c === '-' && sql[i + 1] === '-') {
91
+ while (i < n && sql[i] !== '\n') i++
92
+ continue
93
+ }
94
+ // block comments
95
+ if (c === '/' && sql[i + 1] === '*') {
96
+ i += 2
97
+ while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) i++
98
+ i += 2
99
+ continue
100
+ }
101
+ // string literal (single quotes, '' escape)
102
+ if (c === "'") {
103
+ const start = i
104
+ i++
105
+ let value = ''
106
+ while (i < n) {
107
+ if (sql[i] === "'") {
108
+ if (sql[i + 1] === "'") {
109
+ value += "'"
110
+ i += 2
111
+ continue
112
+ }
113
+ i++
114
+ break
115
+ }
116
+ value += sql[i]
117
+ i++
118
+ }
119
+ toks.push({ type: 'str', value, upper: value, start, end: i })
120
+ continue
121
+ }
122
+ // backtick / double-quoted identifier
123
+ if (c === '`' || c === '"') {
124
+ const quote = c
125
+ const start = i
126
+ i++
127
+ let value = ''
128
+ while (i < n && sql[i] !== quote) {
129
+ value += sql[i]
130
+ i++
131
+ }
132
+ i++ // closing quote
133
+ toks.push({ type: 'ident', value, upper: value.toUpperCase(), start, end: i })
134
+ continue
135
+ }
136
+ // number — at most one decimal point (a second `.` ends the token, so
137
+ // `1.2.3` tokenizes as `1.2` `.` `3` and fails loudly at parse time rather
138
+ // than silently becoming `NaN`).
139
+ if (c >= '0' && c <= '9') {
140
+ const start = i
141
+ let seenDot = false
142
+ while (i < n) {
143
+ const ch = sql[i]!
144
+ if (ch >= '0' && ch <= '9') {
145
+ i++
146
+ continue
147
+ }
148
+ if (ch === '.' && !seenDot) {
149
+ seenDot = true
150
+ i++
151
+ continue
152
+ }
153
+ break
154
+ }
155
+ const value = sql.slice(start, i)
156
+ toks.push({ type: 'num', value, upper: value, start, end: i })
157
+ continue
158
+ }
159
+ // identifier / keyword
160
+ if (/[A-Za-z_]/.test(c)) {
161
+ const start = i
162
+ while (i < n && /[A-Za-z0-9_]/.test(sql[i]!)) i++
163
+ const value = sql.slice(start, i)
164
+ toks.push({ type: 'ident', value, upper: value.toUpperCase(), start, end: i })
165
+ continue
166
+ }
167
+ // multi-char operators
168
+ const two = sql.slice(i, i + 2)
169
+ if (MULTI_OPS.includes(two)) {
170
+ toks.push({ type: 'op', value: two, upper: two, start: i, end: i + 2 })
171
+ i += 2
172
+ continue
173
+ }
174
+ if (SINGLE_OPS.has(c)) {
175
+ toks.push({ type: 'op', value: c, upper: c, start: i, end: i + 1 })
176
+ i++
177
+ continue
178
+ }
179
+ if (PUNCT.has(c)) {
180
+ toks.push({ type: 'punct', value: c, upper: c, start: i, end: i + 1 })
181
+ i++
182
+ continue
183
+ }
184
+ throw new SqlError(`Unexpected character '${c}' at position ${i}`)
185
+ }
186
+ return toks
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // AST
191
+ // ---------------------------------------------------------------------------
192
+
193
+ type Node =
194
+ | { kind: 'num'; value: number }
195
+ | { kind: 'str'; value: string }
196
+ | { kind: 'col'; name: string }
197
+ | { kind: 'star' }
198
+ | { kind: 'func'; name: string; args: Node[]; params?: Node[]; distinct?: boolean }
199
+ | { kind: 'interval'; seconds: number }
200
+ | { kind: 'binary'; op: string; left: Node; right: Node }
201
+ | { kind: 'unary'; op: string; operand: Node }
202
+ | { kind: 'in'; expr: Node; list: Node[]; negate: boolean }
203
+ | { kind: 'like'; expr: Node; pattern: Node; negate: boolean }
204
+ | { kind: 'between'; expr: Node; low: Node; high: Node; negate: boolean }
205
+ | { kind: 'isnull'; expr: Node; negate: boolean }
206
+
207
+ interface SelectItem {
208
+ expr: Node
209
+ /** Output column name (alias, or the raw source text of the expression). */
210
+ name: string
211
+ }
212
+
213
+ interface OrderItem {
214
+ expr: Node
215
+ desc: boolean
216
+ }
217
+
218
+ interface ParsedQuery {
219
+ select: SelectItem[]
220
+ dataset: string
221
+ where?: Node
222
+ groupBy: Node[]
223
+ orderBy: OrderItem[]
224
+ limit?: number
225
+ format: string
226
+ }
227
+
228
+ const CLAUSE_KEYWORDS = new Set(['FROM', 'WHERE', 'GROUP', 'ORDER', 'LIMIT', 'FORMAT', 'BY', 'ASC', 'DESC', 'AS'])
229
+
230
+ // Recognised SQL keywords the emulation deliberately doesn't implement — surfaced
231
+ // with a clearer error than a generic "unexpected token".
232
+ const UNSUPPORTED_KEYWORDS = new Set([
233
+ 'HAVING',
234
+ 'OFFSET',
235
+ 'JOIN',
236
+ 'INNER',
237
+ 'LEFT',
238
+ 'RIGHT',
239
+ 'FULL',
240
+ 'UNION',
241
+ 'WITH',
242
+ ])
243
+
244
+ const INTERVAL_UNITS: Record<string, number> = {
245
+ SECOND: 1,
246
+ SECONDS: 1,
247
+ MINUTE: 60,
248
+ MINUTES: 60,
249
+ HOUR: 3600,
250
+ HOURS: 3600,
251
+ DAY: 86400,
252
+ DAYS: 86400,
253
+ WEEK: 604800,
254
+ WEEKS: 604800,
255
+ // Calendar months/years aren't fixed-length; approximate (documented limitation).
256
+ MONTH: 2592000,
257
+ MONTHS: 2592000,
258
+ YEAR: 31536000,
259
+ YEARS: 31536000,
260
+ }
261
+
262
+ // Binary operator precedence (higher binds tighter).
263
+ const PRECEDENCE: Record<string, number> = {
264
+ OR: 1,
265
+ AND: 2,
266
+ '=': 3,
267
+ '!=': 3,
268
+ '<>': 3,
269
+ '<': 3,
270
+ '<=': 3,
271
+ '>': 3,
272
+ '>=': 3,
273
+ '+': 4,
274
+ '-': 4,
275
+ '*': 5,
276
+ '/': 5,
277
+ '%': 5,
278
+ }
279
+
280
+ class Parser {
281
+ private pos = 0
282
+ constructor(private toks: Token[], private sql: string) {}
283
+
284
+ private peek(): Token | undefined {
285
+ return this.toks[this.pos]
286
+ }
287
+ private next(): Token {
288
+ const t = this.toks[this.pos]
289
+ if (!t) throw new SqlError('Unexpected end of query')
290
+ this.pos++
291
+ return t
292
+ }
293
+ private eof(): boolean {
294
+ return this.pos >= this.toks.length
295
+ }
296
+ private expectKeyword(kw: string): void {
297
+ const t = this.peek()
298
+ if (!t || t.type !== 'ident' || t.upper !== kw) {
299
+ throw new SqlError(`Expected ${kw}${t ? `, got '${t.value}'` : ''}`)
300
+ }
301
+ this.pos++
302
+ }
303
+ private isKeyword(kw: string): boolean {
304
+ const t = this.peek()
305
+ return !!t && t.type === 'ident' && t.upper === kw
306
+ }
307
+
308
+ parse(): ParsedQuery {
309
+ this.expectKeyword('SELECT')
310
+ const select = this.parseSelectList()
311
+ this.expectKeyword('FROM')
312
+ const dataset = this.parseDataset()
313
+
314
+ let where: Node | undefined
315
+ if (this.isKeyword('WHERE')) {
316
+ this.next()
317
+ where = this.parseExpr(0)
318
+ }
319
+ const groupBy: Node[] = []
320
+ if (this.isKeyword('GROUP')) {
321
+ this.next()
322
+ this.expectKeyword('BY')
323
+ groupBy.push(this.parseExpr(0))
324
+ while (this.peek()?.value === ',') {
325
+ this.next()
326
+ groupBy.push(this.parseExpr(0))
327
+ }
328
+ }
329
+ const orderBy: OrderItem[] = []
330
+ if (this.isKeyword('ORDER')) {
331
+ this.next()
332
+ this.expectKeyword('BY')
333
+ orderBy.push(this.parseOrderItem())
334
+ while (this.peek()?.value === ',') {
335
+ this.next()
336
+ orderBy.push(this.parseOrderItem())
337
+ }
338
+ }
339
+ let limit: number | undefined
340
+ if (this.isKeyword('LIMIT')) {
341
+ this.next()
342
+ const t = this.next()
343
+ if (t.type !== 'num') throw new SqlError(`Expected number after LIMIT, got '${t.value}'`)
344
+ limit = Number.parseInt(t.value, 10)
345
+ }
346
+ let format = 'JSON'
347
+ if (this.isKeyword('FORMAT')) {
348
+ this.next()
349
+ format = this.next().value.toUpperCase()
350
+ }
351
+ // allow trailing semicolon
352
+ if (this.peek()?.value === ';') this.next()
353
+ if (!this.eof()) {
354
+ const t = this.peek()!
355
+ if (UNSUPPORTED_KEYWORDS.has(t.upper)) {
356
+ throw new SqlError(`'${t.value}' is not supported by the local Analytics Engine emulation`)
357
+ }
358
+ throw new SqlError(`Unexpected token '${t.value}' — the local Analytics Engine emulation supports a subset of the SQL API`)
359
+ }
360
+ return { select, dataset, where, groupBy, orderBy, limit, format }
361
+ }
362
+
363
+ private parseSelectList(): SelectItem[] {
364
+ const items: SelectItem[] = []
365
+ for (;;) {
366
+ const startTok = this.peek()
367
+ const expr = this.parseExpr(0)
368
+ const endTok = this.toks[this.pos - 1]!
369
+ let name: string
370
+ if (this.isKeyword('AS')) {
371
+ this.next()
372
+ name = this.next().value
373
+ } else if (this.peek()?.type === 'ident' && !CLAUSE_KEYWORDS.has(this.peek()!.upper)) {
374
+ // implicit alias: `expr alias`
375
+ name = this.next().value
376
+ } else {
377
+ // no alias → use the raw source text of the expression (ClickHouse behaviour)
378
+ name = this.sql.slice(startTok!.start, endTok.end).trim()
379
+ }
380
+ items.push({ expr, name })
381
+ if (this.peek()?.value === ',') {
382
+ this.next()
383
+ continue
384
+ }
385
+ break
386
+ }
387
+ return items
388
+ }
389
+
390
+ private parseDataset(): string {
391
+ const t = this.next()
392
+ if (t.type !== 'ident' && t.type !== 'str') {
393
+ throw new SqlError(`Expected dataset name after FROM, got '${t.value}'`)
394
+ }
395
+ return t.value
396
+ }
397
+
398
+ private parseOrderItem(): OrderItem {
399
+ const expr = this.parseExpr(0)
400
+ let desc = false
401
+ if (this.isKeyword('ASC')) this.next()
402
+ else if (this.isKeyword('DESC')) {
403
+ this.next()
404
+ desc = true
405
+ }
406
+ return { expr, desc }
407
+ }
408
+
409
+ // --- Pratt expression parser ---
410
+
411
+ private parseExpr(minPrec: number): Node {
412
+ let left = this.parsePrefix()
413
+ for (;;) {
414
+ const t = this.peek()
415
+ if (!t) break
416
+ // Postfix predicates (IN / LIKE / BETWEEN / IS [NOT] NULL) bind at
417
+ // comparison precedence (3). Skip them when parsing a tighter operand.
418
+ if (t.type === 'ident' && 3 >= minPrec) {
419
+ const u = t.upper
420
+ if (u === 'IN' || u === 'LIKE' || u === 'BETWEEN' || u === 'IS') {
421
+ left = this.parsePredicate(left, false)
422
+ continue
423
+ }
424
+ if (u === 'NOT') {
425
+ const after = this.toks[this.pos + 1]
426
+ if (after?.type === 'ident' && (after.upper === 'IN' || after.upper === 'LIKE' || after.upper === 'BETWEEN')) {
427
+ this.next() // consume NOT
428
+ left = this.parsePredicate(left, true)
429
+ continue
430
+ }
431
+ }
432
+ }
433
+ const op = t.type === 'op' ? t.value : t.type === 'ident' && (t.upper === 'AND' || t.upper === 'OR') ? t.upper : undefined
434
+ if (!op) break
435
+ const prec = PRECEDENCE[op]
436
+ if (prec === undefined || prec < minPrec) break
437
+ this.next()
438
+ const right = this.parseExpr(prec + 1)
439
+ left = { kind: 'binary', op, left, right }
440
+ }
441
+ return left
442
+ }
443
+
444
+ /** Parse a postfix predicate after `left`: IN (...), LIKE p, BETWEEN a AND b, IS [NOT] NULL. */
445
+ private parsePredicate(left: Node, negate: boolean): Node {
446
+ const kw = this.next().upper
447
+ if (kw === 'IN') {
448
+ if (this.next().value !== '(') throw new SqlError('Expected ( after IN')
449
+ const list: Node[] = []
450
+ if (this.peek()?.value !== ')') {
451
+ list.push(this.parseExpr(0))
452
+ while (this.peek()?.value === ',') {
453
+ this.next()
454
+ list.push(this.parseExpr(0))
455
+ }
456
+ }
457
+ if (this.next().value !== ')') throw new SqlError('Expected ) to close IN list')
458
+ return { kind: 'in', expr: left, list, negate }
459
+ }
460
+ if (kw === 'LIKE') {
461
+ // Parse the pattern above AND/OR/comparison so it doesn't swallow the rest.
462
+ return { kind: 'like', expr: left, pattern: this.parseExpr(4), negate }
463
+ }
464
+ if (kw === 'BETWEEN') {
465
+ const low = this.parseExpr(4)
466
+ this.expectKeyword('AND')
467
+ const high = this.parseExpr(4)
468
+ return { kind: 'between', expr: left, low, high, negate }
469
+ }
470
+ // IS [NOT] NULL
471
+ let neg = false
472
+ if (this.isKeyword('NOT')) {
473
+ this.next()
474
+ neg = true
475
+ }
476
+ this.expectKeyword('NULL')
477
+ return { kind: 'isnull', expr: left, negate: neg }
478
+ }
479
+
480
+ /**
481
+ * Logical `NOT` is a low-precedence prefix: it binds looser than comparison
482
+ * (so `NOT a = b` is `NOT (a = b)`, matching SQL/ClickHouse) but tighter than
483
+ * AND/OR. We capture the comparison-level operand to its right, then return so
484
+ * the binary loop in `parseExpr` can still pick up any following AND/OR.
485
+ * (Postfix `NOT IN/LIKE/BETWEEN` is handled separately inside `parseExpr`.)
486
+ */
487
+ private parsePrefix(): Node {
488
+ const t = this.peek()
489
+ if (t?.type === 'ident' && t.upper === 'NOT') {
490
+ this.next()
491
+ // Operand at comparison precedence (3): includes comparisons and tighter
492
+ // operators, but stops before AND (2) / OR (1).
493
+ return { kind: 'unary', op: 'NOT', operand: this.parseExpr(3) }
494
+ }
495
+ return this.parseUnary()
496
+ }
497
+
498
+ private parseUnary(): Node {
499
+ const t = this.peek()
500
+ if (t?.type === 'op' && t.value === '-') {
501
+ this.next()
502
+ return { kind: 'unary', op: '-', operand: this.parseUnary() }
503
+ }
504
+ return this.parsePrimary()
505
+ }
506
+
507
+ private parsePrimary(): Node {
508
+ const t = this.next()
509
+ if (t.type === 'num') {
510
+ const value = Number(t.value)
511
+ if (Number.isNaN(value)) throw new SqlError(`Invalid numeric literal '${t.value}'`)
512
+ return { kind: 'num', value }
513
+ }
514
+ if (t.type === 'str') return { kind: 'str', value: t.value }
515
+ if (t.type === 'op' && t.value === '*') return { kind: 'star' }
516
+ if (t.value === '(') {
517
+ const inner = this.parseExpr(0)
518
+ if (this.next().value !== ')') throw new SqlError('Expected )')
519
+ return inner
520
+ }
521
+ if (t.type === 'ident') {
522
+ // INTERVAL 'n' UNIT / INTERVAL n UNIT
523
+ if (t.upper === 'INTERVAL') return this.parseInterval()
524
+ // function call?
525
+ if (this.peek()?.value === '(') {
526
+ return this.parseFunc(t.value)
527
+ }
528
+ return { kind: 'col', name: t.value }
529
+ }
530
+ throw new SqlError(`Unexpected token '${t.value}'`)
531
+ }
532
+
533
+ private parseInterval(): Node {
534
+ const amountTok = this.next()
535
+ const amount = Number(amountTok.value)
536
+ if (Number.isNaN(amount)) throw new SqlError(`Invalid INTERVAL amount '${amountTok.value}'`)
537
+ const unitTok = this.next()
538
+ const secs = INTERVAL_UNITS[unitTok.upper]
539
+ if (secs === undefined) throw new SqlError(`Unsupported INTERVAL unit '${unitTok.value}'`)
540
+ return { kind: 'interval', seconds: amount * secs }
541
+ }
542
+
543
+ private parseFunc(name: string): Node {
544
+ const { args: first, distinct } = this.parseArgList(name)
545
+ // Parametric aggregates like quantileWeighted(0.5)(value, weight): a second arg list.
546
+ // The first list holds the parameters (e.g. the level), the second the arguments.
547
+ if (this.peek()?.value === '(') {
548
+ const { args } = this.parseArgList(name)
549
+ return { kind: 'func', name, args, params: first }
550
+ }
551
+ return { kind: 'func', name, args: first, distinct }
552
+ }
553
+
554
+ private parseArgList(name: string): { args: Node[]; distinct: boolean } {
555
+ this.next() // (
556
+ let distinct = false
557
+ if (this.isKeyword('DISTINCT')) {
558
+ this.next()
559
+ distinct = true
560
+ }
561
+ const args: Node[] = []
562
+ if (this.peek()?.value !== ')') {
563
+ args.push(this.parseExpr(0))
564
+ while (this.peek()?.value === ',') {
565
+ this.next()
566
+ args.push(this.parseExpr(0))
567
+ }
568
+ }
569
+ if (this.next().value !== ')') throw new SqlError(`Expected ) after ${name}(...)`)
570
+ return { args, distinct }
571
+ }
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Emitter: AST → SQLite expression (+ result type for `meta`)
576
+ // ---------------------------------------------------------------------------
577
+
578
+ interface EmitCtx {
579
+ /** Baked `now()` value in epoch seconds (no UDFs in bun:sqlite). */
580
+ nowSeconds: number
581
+ /** SELECT output aliases — referenceable from GROUP BY / ORDER BY. */
582
+ aliases: Set<string>
583
+ /** Set when a LIKE is emitted, so the runner can force case-sensitive matching. */
584
+ usesLike: boolean
585
+ }
586
+
587
+ const COL_RE = /^(blob([1-9]|1[0-9]|20)|double([1-9]|1[0-9]|20)|index1|_sample_interval|timestamp|dataset)$/
588
+
589
+ function colType(name: string): string {
590
+ if (name === 'timestamp') return 'DateTime'
591
+ if (name.startsWith('double')) return 'Float64'
592
+ if (name === '_sample_interval') return 'UInt32'
593
+ return 'String'
594
+ }
595
+
596
+ function quoteStr(s: string): string {
597
+ return `'${s.replace(/'/g, "''")}'`
598
+ }
599
+
600
+ function emit(node: Node, ctx: EmitCtx): string {
601
+ switch (node.kind) {
602
+ case 'num':
603
+ return String(node.value)
604
+ case 'str':
605
+ return quoteStr(node.value)
606
+ case 'star':
607
+ return '*'
608
+ case 'interval':
609
+ return String(node.seconds)
610
+ case 'col': {
611
+ // Canonicalise stored ms-epoch timestamp to ClickHouse-style seconds.
612
+ if (node.name === 'timestamp') return '(timestamp / 1000.0)'
613
+ if (COL_RE.test(node.name)) return node.name
614
+ // A reference to a SELECT alias (valid in GROUP BY / ORDER BY).
615
+ if (ctx.aliases.has(node.name)) return JSON.stringify(node.name)
616
+ throw new SqlError(`Unknown column '${node.name}'`)
617
+ }
618
+ case 'unary':
619
+ return node.op === 'NOT' ? `(NOT ${emit(node.operand, ctx)})` : `(-${emit(node.operand, ctx)})`
620
+ case 'binary': {
621
+ // ClickHouse `/` is always floating-point; SQLite `/` truncates when both
622
+ // operands are integers — force real division to match.
623
+ if (node.op === '/') return `(${emit(node.left, ctx)} * 1.0 / ${emit(node.right, ctx)})`
624
+ const op = node.op === '<>' ? '!=' : node.op
625
+ return `(${emit(node.left, ctx)} ${op} ${emit(node.right, ctx)})`
626
+ }
627
+ case 'in': {
628
+ const list = node.list.map(n => emit(n, ctx)).join(', ')
629
+ return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT IN' : 'IN'} (${list}))`
630
+ }
631
+ case 'like':
632
+ // ClickHouse LIKE is case-sensitive; SQLite's is not. Flagged here so the
633
+ // runner flips `PRAGMA case_sensitive_like` for the query.
634
+ ctx.usesLike = true
635
+ return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT LIKE' : 'LIKE'} ${emit(node.pattern, ctx)})`
636
+ case 'between':
637
+ return `(${emit(node.expr, ctx)} ${node.negate ? 'NOT BETWEEN' : 'BETWEEN'} ${emit(node.low, ctx)} AND ${emit(node.high, ctx)})`
638
+ case 'isnull':
639
+ return `(${emit(node.expr, ctx)} IS ${node.negate ? 'NOT NULL' : 'NULL'})`
640
+ case 'func':
641
+ return emitFunc(node, ctx)
642
+ }
643
+ }
644
+
645
+ const QUANTILE_FNS = new Set(['quantile', 'quantileexact', 'quantileweighted', 'quantileexactweighted'])
646
+
647
+ /** Non-quantile aggregate functions. */
648
+ const PLAIN_AGGREGATE_FNS = new Set(['count', 'sum', 'avg', 'min', 'max'])
649
+
650
+ /**
651
+ * Whether `node` contains a non-quantile aggregate anywhere in its subtree. Used
652
+ * to reject aggregates where they aren't allowed (WHERE / GROUP BY) and nested
653
+ * inside another aggregate — both of which SQLite would otherwise surface as an
654
+ * opaque "misuse of aggregate" error.
655
+ */
656
+ function containsPlainAggregate(node: Node): boolean {
657
+ switch (node.kind) {
658
+ case 'func':
659
+ if (PLAIN_AGGREGATE_FNS.has(node.name.toLowerCase())) return true
660
+ return node.args.some(containsPlainAggregate) || (node.params?.some(containsPlainAggregate) ?? false)
661
+ case 'binary':
662
+ return containsPlainAggregate(node.left) || containsPlainAggregate(node.right)
663
+ case 'unary':
664
+ return containsPlainAggregate(node.operand)
665
+ case 'in':
666
+ return containsPlainAggregate(node.expr) || node.list.some(containsPlainAggregate)
667
+ case 'like':
668
+ return containsPlainAggregate(node.expr) || containsPlainAggregate(node.pattern)
669
+ case 'between':
670
+ return containsPlainAggregate(node.expr) || containsPlainAggregate(node.low) || containsPlainAggregate(node.high)
671
+ case 'isnull':
672
+ return containsPlainAggregate(node.expr)
673
+ default:
674
+ return false
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Expected `[min, max]` argument counts per function, enforced before emit so a
680
+ * wrong-arity call fails with a clear error instead of emitting a literal
681
+ * `undefined` (e.g. `if(x, 1)` → `... ELSE undefined`, which SQLite then reads as
682
+ * an unknown column).
683
+ */
684
+ const FN_ARITY: Record<string, [number, number]> = {
685
+ count: [0, 1],
686
+ sum: [1, 1],
687
+ avg: [1, 1],
688
+ min: [1, 1],
689
+ max: [1, 1],
690
+ touint32: [1, 1],
691
+ touint64: [1, 1],
692
+ toint32: [1, 1],
693
+ toint64: [1, 1],
694
+ tofloat64: [1, 1],
695
+ tofloat32: [1, 1],
696
+ tostring: [1, 1],
697
+ todatetime: [1, 1],
698
+ intdiv: [2, 2],
699
+ tostartofinterval: [2, 2],
700
+ tostartofminute: [1, 1],
701
+ tostartofhour: [1, 1],
702
+ tostartofday: [1, 1],
703
+ abs: [1, 1],
704
+ round: [1, 2],
705
+ min2: [2, 2],
706
+ max2: [2, 2],
707
+ coalesce: [1, Number.POSITIVE_INFINITY],
708
+ length: [1, 1],
709
+ lower: [1, 1],
710
+ upper: [1, 1],
711
+ floor: [1, 1],
712
+ if: [3, 3],
713
+ now: [0, 0],
714
+ }
715
+
716
+ function checkArity(node: Extract<Node, { kind: 'func' }>): void {
717
+ const arity = FN_ARITY[node.name.toLowerCase()]
718
+ if (!arity) return
719
+ const [min, max] = arity
720
+ const got = node.args.length
721
+ if (got >= min && got <= max) return
722
+ const noun = (n: number) => (n === 1 ? 'argument' : 'arguments')
723
+ const expected = max === Number.POSITIVE_INFINITY
724
+ ? `at least ${min} ${noun(min)}`
725
+ : min === max
726
+ ? `exactly ${min} ${noun(min)}`
727
+ : `${min} to ${max} arguments`
728
+ throw new SqlError(`Function '${node.name}' expects ${expected}, got ${got}`)
729
+ }
730
+
731
+ function emitFunc(node: Extract<Node, { kind: 'func' }>, ctx: EmitCtx): string {
732
+ const fn = node.name.toLowerCase()
733
+ if (QUANTILE_FNS.has(fn)) {
734
+ throw new SqlError(`Function '${node.name}' is only supported in the SELECT list, not inside other expressions`)
735
+ }
736
+ if (PLAIN_AGGREGATE_FNS.has(fn) && node.args.some(containsPlainAggregate)) {
737
+ throw new SqlError(`Aggregate function '${node.name}' cannot be nested inside another aggregate`)
738
+ }
739
+ checkArity(node)
740
+ const a = node.args.map(arg => emit(arg, ctx))
741
+ switch (fn) {
742
+ // --- aggregates ---
743
+ case 'count':
744
+ if (node.distinct && a.length === 1) return `COUNT(DISTINCT ${a[0]})`
745
+ return 'COUNT(*)'
746
+ case 'sum':
747
+ // ClickHouse sum() over zero/all-NULL rows is 0; SQLite SUM is NULL.
748
+ return `COALESCE(SUM(${a[0]}), 0)`
749
+ case 'avg':
750
+ return `AVG(${a[0]})`
751
+ case 'min':
752
+ return `MIN(${a[0]})`
753
+ case 'max':
754
+ return `MAX(${a[0]})`
755
+ // --- type casts (ClickHouse → SQLite) ---
756
+ case 'touint32':
757
+ case 'touint64':
758
+ case 'toint32':
759
+ case 'toint64':
760
+ return `CAST(${a[0]} AS INTEGER)`
761
+ case 'tofloat64':
762
+ case 'tofloat32':
763
+ return `CAST(${a[0]} AS REAL)`
764
+ case 'tostring':
765
+ return `CAST(${a[0]} AS TEXT)`
766
+ case 'todatetime':
767
+ // Our DateTime is just epoch seconds; identity.
768
+ return `(${a[0]})`
769
+ // --- integer / time bucketing ---
770
+ case 'intdiv':
771
+ return `(CAST(${a[0]} AS INTEGER) / CAST(${a[1]} AS INTEGER))`
772
+ case 'tostartofinterval': {
773
+ // toStartOfInterval(ts, INTERVAL n unit) → floor to bucket (seconds)
774
+ const secs = node.args[1]?.kind === 'interval' ? node.args[1].seconds : undefined
775
+ if (secs === undefined) throw new SqlError('toStartOfInterval requires an INTERVAL literal as its 2nd argument')
776
+ return `((CAST(${a[0]} AS INTEGER) / ${secs}) * ${secs})`
777
+ }
778
+ case 'tostartofminute':
779
+ return `((CAST(${a[0]} AS INTEGER) / 60) * 60)`
780
+ case 'tostartofhour':
781
+ return `((CAST(${a[0]} AS INTEGER) / 3600) * 3600)`
782
+ case 'tostartofday':
783
+ return `((CAST(${a[0]} AS INTEGER) / 86400) * 86400)`
784
+ // --- misc passthrough (SQLite has these) ---
785
+ case 'abs':
786
+ case 'round':
787
+ case 'min2':
788
+ case 'max2':
789
+ case 'coalesce':
790
+ case 'length':
791
+ case 'lower':
792
+ case 'upper':
793
+ return `${fn.toUpperCase()}(${a.join(', ')})`
794
+ case 'floor':
795
+ return `CAST(${a[0]} AS INTEGER)`
796
+ case 'if':
797
+ return `(CASE WHEN ${a[0]} THEN ${a[1]} ELSE ${a[2]} END)`
798
+ case 'now':
799
+ return String(ctx.nowSeconds)
800
+ default:
801
+ throw new SqlError(`Function '${node.name}' is not supported by the local Analytics Engine emulation yet`)
802
+ }
803
+ }
804
+
805
+ function inferType(node: Node): string {
806
+ switch (node.kind) {
807
+ case 'num':
808
+ return Number.isInteger(node.value) ? 'UInt64' : 'Float64'
809
+ case 'str':
810
+ return 'String'
811
+ case 'col':
812
+ return colType(node.name)
813
+ case 'binary':
814
+ return ['=', '!=', '<>', '<', '<=', '>', '>=', 'AND', 'OR'].includes(node.op) ? 'UInt8' : 'Float64'
815
+ case 'unary':
816
+ return node.op === 'NOT' ? 'UInt8' : inferType(node.operand)
817
+ case 'in':
818
+ case 'like':
819
+ case 'between':
820
+ case 'isnull':
821
+ return 'UInt8'
822
+ case 'func': {
823
+ const fn = node.name.toLowerCase()
824
+ if (fn === 'count') return 'UInt64'
825
+ if (['sum', 'avg', 'tofloat64', 'tofloat32', 'abs', 'round'].includes(fn)) return 'Float64'
826
+ if (['min', 'max', 'coalesce', 'if'].includes(fn)) return node.args[0] ? inferType(node.args[0]) : 'String'
827
+ if (['touint32', 'touint64', 'toint32', 'toint64', 'intdiv', 'floor', 'length'].includes(fn)) return 'UInt64'
828
+ if (fn.startsWith('tostartof') || fn === 'todatetime' || fn === 'now') return 'DateTime'
829
+ if (['lower', 'upper', 'tostring'].includes(fn)) return 'String'
830
+ return 'Float64'
831
+ }
832
+ default:
833
+ return 'String'
834
+ }
835
+ }
836
+
837
+ // ---------------------------------------------------------------------------
838
+ // Public: translate + run
839
+ // ---------------------------------------------------------------------------
840
+
841
+ /** A quantile that's collected per-group via `group_concat` and computed in JS. */
842
+ interface QuantileSpec {
843
+ /** Internal SQLite column holding the comma-joined values for the group. */
844
+ tempCol: string
845
+ /** Output column name. */
846
+ outputName: string
847
+ /** Quantile level in [0, 1]. */
848
+ level: number
849
+ /**
850
+ * `quantile`/`quantileWeighted` interpolate linearly (ClickHouse default);
851
+ * the `*Exact*` variants return an actual element (nearest-rank).
852
+ */
853
+ interpolate: boolean
854
+ }
855
+
856
+ /**
857
+ * Post-processing applied in JS when the query uses quantile functions.
858
+ * `bun:sqlite` has no UDFs and no percentile aggregate, so we collect the
859
+ * per-group values with `group_concat` and finish the computation here. ORDER BY
860
+ * / LIMIT then also have to run in JS (they may reference a computed quantile).
861
+ */
862
+ interface PostProcess {
863
+ quantiles: QuantileSpec[]
864
+ orderBy: { name: string; desc: boolean }[]
865
+ limit?: number
866
+ }
867
+
868
+ export interface TranslatedQuery {
869
+ sqlite: string
870
+ columns: { name: string; type: string }[]
871
+ format: string
872
+ /** `SELECT *` — column metadata is derived from the result rows at run time. */
873
+ hasStar?: boolean
874
+ /** Present only when the query uses quantile functions. */
875
+ postProcess?: PostProcess
876
+ /** The query uses LIKE — run it with `PRAGMA case_sensitive_like=ON` (ClickHouse semantics). */
877
+ caseSensitiveLike?: boolean
878
+ }
879
+
880
+ /** Build `meta` from result-row keys (used for `SELECT *`, where columns aren't known up front). */
881
+ function deriveMetaFromRows(rows: Record<string, unknown>[]): { name: string; type: string }[] {
882
+ const keys = rows.length ? Object.keys(rows[0]!) : []
883
+ return keys.map(k => ({ name: k, type: colType(k) }))
884
+ }
885
+
886
+ /**
887
+ * Render an epoch-seconds value as a ClickHouse `DateTime` string
888
+ * (`YYYY-MM-DD HH:MM:SS`, UTC) — the form the real Analytics Engine SQL API
889
+ * returns. Fractional seconds are truncated (ClickHouse `DateTime` is
890
+ * second-resolution).
891
+ */
892
+ function formatDateTime(epochSeconds: number): string {
893
+ const d = new Date(Math.floor(epochSeconds) * 1000)
894
+ const p = (n: number) => String(n).padStart(2, '0')
895
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`
896
+ }
897
+
898
+ /**
899
+ * Convert `DateTime` output columns from their internal numeric form to ClickHouse
900
+ * datetime strings, in place — the final projection step so prod and local agree
901
+ * on the result shape. Non-`*` queries canonicalise timestamps to **seconds** in
902
+ * the emitted SQL; `SELECT *` returns the raw `timestamp` column, stored as **ms**.
903
+ * NULL DateTimes (e.g. `max(timestamp)` over an empty dataset) are left untouched.
904
+ */
905
+ function formatDateTimeColumns(data: Record<string, unknown>[], t: TranslatedQuery): void {
906
+ if (t.hasStar) {
907
+ for (const row of data) {
908
+ const v = row.timestamp
909
+ if (typeof v === 'number') row.timestamp = formatDateTime(v / 1000)
910
+ }
911
+ return
912
+ }
913
+ const dtCols = t.columns.filter(c => c.type === 'DateTime').map(c => c.name)
914
+ for (const row of data) {
915
+ for (const name of dtCols) {
916
+ const v = row[name]
917
+ if (typeof v === 'number') row[name] = formatDateTime(v)
918
+ }
919
+ }
920
+ }
921
+
922
+ function isQuantile(node: Node): node is Extract<Node, { kind: 'func' }> {
923
+ return node.kind === 'func' && QUANTILE_FNS.has(node.name.toLowerCase())
924
+ }
925
+
926
+ function quantileLevel(node: Extract<Node, { kind: 'func' }>): number {
927
+ if (!node.params || node.params.length !== 1 || node.params[0]!.kind !== 'num') {
928
+ throw new SqlError(`${node.name} requires a single numeric level, e.g. ${node.name}(0.95)(double1)`)
929
+ }
930
+ const level = (node.params[0] as { value: number }).value
931
+ if (level < 0 || level > 1) throw new SqlError(`${node.name} level must be between 0 and 1`)
932
+ if (node.args.length < 1) throw new SqlError(`${node.name} requires a value argument`)
933
+ return level
934
+ }
935
+
936
+ /** Translate a Cloudflare Analytics Engine SQL string into a SQLite query. */
937
+ export function translateAnalyticsEngineSql(sql: string, nowSeconds: number): TranslatedQuery {
938
+ const parsed = new Parser(tokenize(sql), sql).parse()
939
+ const ctx: EmitCtx = { nowSeconds, aliases: new Set(), usesLike: false }
940
+ const hasQuantiles = parsed.select.some(item => isQuantile(item.expr))
941
+ const hasStar = parsed.select.some(item => item.expr.kind === 'star')
942
+ if (hasStar && hasQuantiles) throw new SqlError('SELECT * cannot be combined with quantile functions')
943
+ // Aggregates aren't valid here; reject with a clear message instead of letting
944
+ // SQLite raise an opaque "misuse of aggregate" error.
945
+ if (parsed.where && containsPlainAggregate(parsed.where)) {
946
+ throw new SqlError('Aggregate functions are not allowed in the WHERE clause')
947
+ }
948
+ if (parsed.groupBy.some(containsPlainAggregate)) {
949
+ throw new SqlError('Aggregate functions are not allowed in GROUP BY')
950
+ }
951
+
952
+ // SELECT + WHERE are emitted before aliases become referenceable (SQLite, like
953
+ // most engines, does not allow output aliases in WHERE).
954
+ const quantiles: QuantileSpec[] = []
955
+ const selectParts = parsed.select.map((item, i) => {
956
+ // `*` is emitted bare — SQLite rejects an alias on a wildcard.
957
+ if (item.expr.kind === 'star') return '*'
958
+ if (isQuantile(item.expr)) {
959
+ const level = quantileLevel(item.expr)
960
+ const tempCol = `__q${i}`
961
+ // The `*Exact*` variants return a real element; plain `quantile`/`quantileWeighted` interpolate.
962
+ const interpolate = !item.expr.name.toLowerCase().includes('exact')
963
+ quantiles.push({ tempCol, outputName: item.name, level, interpolate })
964
+ // Collect the (locally unweighted) values for this group; computed in JS.
965
+ return `group_concat(${emit(item.expr.args[0]!, ctx)}) AS ${JSON.stringify(tempCol)}`
966
+ }
967
+ return `${emit(item.expr, ctx)} AS ${JSON.stringify(item.name)}`
968
+ })
969
+ const columns = parsed.select
970
+ .filter(item => item.expr.kind !== 'star')
971
+ .map(item => ({
972
+ name: item.name,
973
+ type: isQuantile(item.expr) ? 'Float64' : inferType(item.expr),
974
+ }))
975
+
976
+ let sqlite = `SELECT ${selectParts.join(', ')} FROM analytics_engine`
977
+
978
+ const conditions = [`dataset = ${quoteStr(parsed.dataset)}`]
979
+ if (parsed.where) conditions.push(emit(parsed.where, ctx))
980
+ sqlite += ` WHERE ${conditions.join(' AND ')}`
981
+
982
+ // GROUP BY / ORDER BY may reference SELECT aliases.
983
+ for (const item of parsed.select) ctx.aliases.add(item.name)
984
+ if (parsed.groupBy.length) sqlite += ` GROUP BY ${parsed.groupBy.map(g => emit(g, ctx)).join(', ')}`
985
+
986
+ if (!hasQuantiles) {
987
+ if (parsed.orderBy.length) {
988
+ sqlite += ` ORDER BY ${parsed.orderBy.map(o => `${emit(o.expr, ctx)} ${o.desc ? 'DESC' : 'ASC'}`).join(', ')}`
989
+ }
990
+ if (parsed.limit !== undefined) sqlite += ` LIMIT ${parsed.limit}`
991
+ return { sqlite, columns, format: parsed.format, hasStar, caseSensitiveLike: ctx.usesLike }
992
+ }
993
+
994
+ // Quantile path: ORDER BY / LIMIT run in JS after the quantiles are computed.
995
+ const outputNames = new Set(columns.map(c => c.name))
996
+ const orderBy = parsed.orderBy.map(o => {
997
+ if (o.expr.kind !== 'col' || !outputNames.has(o.expr.name)) {
998
+ throw new SqlError('ORDER BY must reference a selected column when using quantile functions')
999
+ }
1000
+ return { name: o.expr.name, desc: o.desc }
1001
+ })
1002
+ return { sqlite, columns, format: parsed.format, caseSensitiveLike: ctx.usesLike, postProcess: { quantiles, orderBy, limit: parsed.limit } }
1003
+ }
1004
+
1005
+ /**
1006
+ * Compute a quantile from collected values.
1007
+ * - `interpolate` (plain `quantile`/`quantileWeighted`): linear interpolation at
1008
+ * position `level * (n-1)` (the "type 7" definition used by ClickHouse `quantile`,
1009
+ * NumPy, and Excel PERCENTILE.INC).
1010
+ * - otherwise (`*Exact*` variants): nearest-rank — returns an actual element.
1011
+ */
1012
+ function computeQuantile(values: number[], level: number, interpolate: boolean): number | null {
1013
+ if (values.length === 0) return null
1014
+ const sorted = [...values].sort((a, b) => a - b)
1015
+ const n = sorted.length
1016
+ if (!interpolate) {
1017
+ const idx = Math.round(level * (n - 1))
1018
+ return sorted[Math.min(Math.max(idx, 0), n - 1)]!
1019
+ }
1020
+ const pos = level * (n - 1)
1021
+ const lo = Math.floor(pos)
1022
+ const hi = Math.ceil(pos)
1023
+ if (lo === hi) return sorted[lo]!
1024
+ return sorted[lo]! + (pos - lo) * (sorted[hi]! - sorted[lo]!)
1025
+ }
1026
+
1027
+ function parseConcatNumbers(value: unknown): number[] {
1028
+ if (value == null) return []
1029
+ return String(value)
1030
+ .split(',')
1031
+ .map(Number)
1032
+ .filter(n => !Number.isNaN(n))
1033
+ }
1034
+
1035
+ function applyPostProcess(
1036
+ rows: Record<string, unknown>[],
1037
+ pp: PostProcess,
1038
+ columns: { name: string }[],
1039
+ ): Record<string, unknown>[] {
1040
+ // Compute each quantile from its group_concat'd values, then reproject rows in
1041
+ // declared column order (so output keys match `meta`).
1042
+ let out = rows.map(row => {
1043
+ for (const q of pp.quantiles) {
1044
+ row[q.outputName] = computeQuantile(parseConcatNumbers(row[q.tempCol]), q.level, q.interpolate)
1045
+ }
1046
+ const projected: Record<string, unknown> = {}
1047
+ for (const c of columns) projected[c.name] = row[c.name]
1048
+ return projected
1049
+ })
1050
+
1051
+ if (pp.orderBy.length) {
1052
+ out = out.sort((a, b) => {
1053
+ for (const { name, desc } of pp.orderBy) {
1054
+ const av = a[name]
1055
+ const bv = b[name]
1056
+ let cmp: number
1057
+ if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv
1058
+ else cmp = String(av).localeCompare(String(bv))
1059
+ if (cmp !== 0) return desc ? -cmp : cmp
1060
+ }
1061
+ return 0
1062
+ })
1063
+ }
1064
+ if (pp.limit !== undefined) out = out.slice(0, pp.limit)
1065
+ return out
1066
+ }
1067
+
1068
+ export interface AnalyticsEngineSqlResult {
1069
+ meta: { name: string; type: string }[]
1070
+ data: Record<string, unknown>[]
1071
+ rows: number
1072
+ rows_before_limit_at_least: number
1073
+ }
1074
+
1075
+ /**
1076
+ * Run a translated query, forcing case-sensitive LIKE when needed. The
1077
+ * `case_sensitive_like` pragma is connection-global, but the query runs
1078
+ * synchronously between toggling it on and off, so no other binding sharing the
1079
+ * connection can observe the flip.
1080
+ */
1081
+ function executeTranslated(db: Database, t: TranslatedQuery): Record<string, unknown>[] {
1082
+ if (!t.caseSensitiveLike) return db.query(t.sqlite).all() as Record<string, unknown>[]
1083
+ db.run('PRAGMA case_sensitive_like=ON')
1084
+ try {
1085
+ return db.query(t.sqlite).all() as Record<string, unknown>[]
1086
+ } finally {
1087
+ db.run('PRAGMA case_sensitive_like=OFF')
1088
+ }
1089
+ }
1090
+
1091
+ /** Translate + execute a Cloudflare Analytics Engine SQL query against SQLite. */
1092
+ export function runAnalyticsEngineSql(db: Database, sql: string, nowMs: number = Date.now()): AnalyticsEngineSqlResult {
1093
+ const translated = translateAnalyticsEngineSql(sql, Math.floor(nowMs / 1000))
1094
+ let data = executeTranslated(db, translated)
1095
+ if (translated.postProcess) data = applyPostProcess(data, translated.postProcess, translated.columns)
1096
+ formatDateTimeColumns(data, translated)
1097
+ const meta = translated.hasStar ? deriveMetaFromRows(data) : translated.columns
1098
+ return {
1099
+ meta,
1100
+ data,
1101
+ rows: data.length,
1102
+ rows_before_limit_at_least: data.length,
1103
+ }
1104
+ }
1105
+
1106
+ /**
1107
+ * Run an Analytics Engine SQL string and build a Cloudflare-shaped `Response`
1108
+ * (JSON by default, NDJSON for `FORMAT JSONEachRow`, or a 400 with the error
1109
+ * message). Synchronous so callers can wrap it in their own tracing span.
1110
+ */
1111
+ export function buildAnalyticsEngineSqlResponse(db: Database, sql: string): Response {
1112
+ const trimmed = sql.trim()
1113
+ if (!trimmed) return new Response('Empty query', { status: 400 })
1114
+ try {
1115
+ const translated = translateAnalyticsEngineSql(trimmed, Math.floor(Date.now() / 1000))
1116
+ let data = executeTranslated(db, translated)
1117
+ if (translated.postProcess) data = applyPostProcess(data, translated.postProcess, translated.columns)
1118
+ formatDateTimeColumns(data, translated)
1119
+ if (translated.format === 'JSONEACHROW') {
1120
+ const body = data.map(row => JSON.stringify(row)).join('\n')
1121
+ return new Response(body, { headers: { 'content-type': 'application/x-ndjson' } })
1122
+ }
1123
+ const meta = translated.hasStar ? deriveMetaFromRows(data) : translated.columns
1124
+ const result: AnalyticsEngineSqlResult = { meta, data, rows: data.length, rows_before_limit_at_least: data.length }
1125
+ return new Response(JSON.stringify(result), { headers: { 'content-type': 'application/json' } })
1126
+ } catch (err) {
1127
+ const message = err instanceof Error ? err.message : String(err)
1128
+ return new Response(message, { status: 400 })
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Handle an intercepted POST to the Analytics Engine SQL API. Returns a
1134
+ * Cloudflare-shaped JSON `Response` (or a 400 with the error message).
1135
+ */
1136
+ export async function handleAnalyticsEngineSqlRequest(db: Database, request: Request): Promise<Response> {
1137
+ return buildAnalyticsEngineSqlResponse(db, await request.text())
1138
+ }