@vaultgradient/pipequery-cli 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +98 -13
  2. package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
  3. package/__tests__/event-log.test.ts +267 -0
  4. package/__tests__/mongo-pushdown-compile.test.ts +160 -0
  5. package/__tests__/mysql-pushdown-compile.test.ts +122 -0
  6. package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
  7. package/__tests__/postgres-pushdown-compile.test.ts +240 -0
  8. package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
  9. package/__tests__/pushdown-routing.test.ts +144 -0
  10. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  11. package/dist/index.js +3642 -2172
  12. package/package.json +11 -2
  13. package/src/commands/telegram.ts +10 -0
  14. package/src/config/schema.ts +68 -1
  15. package/src/index.ts +2 -1
  16. package/src/mcp/provider.ts +4 -7
  17. package/src/server/routes/control.ts +1 -3
  18. package/src/server/routes/dynamic.ts +2 -4
  19. package/src/server/sources/clickhouse.ts +153 -0
  20. package/src/server/sources/manager.ts +71 -1
  21. package/src/server/sources/mongodb.ts +165 -0
  22. package/src/server/sources/mysql.ts +44 -0
  23. package/src/server/sources/postgres.ts +50 -0
  24. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  25. package/src/server/sources/pushdown/compile.ts +328 -0
  26. package/src/server/sources/pushdown/dialect.ts +88 -0
  27. package/src/server/sources/pushdown/mongodb.ts +450 -0
  28. package/src/server/sources/pushdown/mysql.ts +17 -0
  29. package/src/server/sources/pushdown/postgres.ts +19 -0
  30. package/src/server/sources/pushdown/snowflake.ts +17 -0
  31. package/src/server/sources/snowflake.ts +158 -0
  32. package/src/server/sources/types.ts +27 -0
  33. package/src/telegram/bot.ts +198 -12
  34. package/src/telegram/event-log.ts +234 -0
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # pq — PipeQuery CLI
2
2
 
3
- A command-line tool for building data pipelines, REST APIs, and terminal dashboards using PipeQuery expressions.
3
+ A command-line tool for building data pipelines, live API endpoints, terminal dashboards, MCP servers, and Telegram bots — all driven by PipeQuery expressions.
4
4
 
5
- Connect to any data source (REST APIs, WebSockets, files, Postgres, MySQL, SQLite, Kafka), run pipe-based queries to transform and aggregate data, then expose results as API endpoints or visualize them as rich terminal charts.
5
+ Connect to any of 11 data sources (REST, WebSocket, file, static, Postgres, MySQL, SQLite, Kafka, Snowflake, ClickHouse, MongoDB), run pipe-based queries to transform and aggregate data, then expose results as API endpoints, surface them in a TUI, hand them to an AI agent over MCP, or chat with them on Telegram.
6
6
 
7
7
  **Create API endpoints on the fly** — run `pq endpoint add /api/prices -q "crypto | sort(price desc)"` and instantly get a live JSON endpoint, no config file needed.
8
8
 
9
- **Give your AI agent live data** — `pq mcp serve` exposes every configured source to any Model Context Protocol client (Claude Desktop, Claude Code, Cursor, Copilot). Your AI can now query your REST APIs, files, Postgres / MySQL / SQLite databases, Kafka streams, and WebSocket feeds directly.
9
+ **Give your AI agent live data** — `pq mcp serve` exposes every configured source to any Model Context Protocol client (Claude Desktop, Claude Code, Cursor, Copilot). Pipe expressions targeting Postgres / MySQL / Snowflake / ClickHouse compile to native SQL automatically; targeting MongoDB compile to `find()` / `aggregate()` plans. No in-memory materialization for those engines; the AI gets native-engine performance.
10
10
 
11
- **Use it from Telegram** — `pq telegram serve` runs a Telegram bot that maps the same MCP commands to chat (`/query`, `/sources`, `/describe`, `/call`). Pair with `pq watch add` to push alerts to a Telegram channel when a query result transitions (`when_non_empty`, `when_empty`, or `on_change`).
11
+ **Use it from Telegram** — `pq telegram serve` runs a Telegram bot that maps the same MCP commands to chat (`/query`, `/sources`, `/describe`, `/call`). Pass `--anthropic-key` to enable natural-language queries (Claude Haiku 4.5 translates plain English into pipequery). Pass `--log-file ./bot.jsonl` to record every event for `jq`/SIEM analysis. Pair with `pq watch add` to push alerts to a Telegram channel when a query result transitions (`when_non_empty`, `when_empty`, or `on_change`).
12
12
 
13
13
  ## Installation
14
14
 
@@ -114,15 +114,81 @@ pq source test coins
114
114
  pq source remove coins
115
115
  ```
116
116
 
117
- Source types:
118
- - **rest** — Polls a REST API at a configurable interval
119
- - **websocket** — Streams data from a WebSocket connection
120
- - **file** — Reads JSON or CSV files, optionally watches for changes
121
- - **static** — Inline JSON data defined in config
122
- - **postgres** — Polls a Postgres query. Supports `${ENV_VAR}` interpolation in the connection URL so credentials stay out of `pipequery.yaml`. Options: `-u` URL, `-q` query, `-i` interval, `--ssl <require\|no-verify\|false>`, `--max-rows <n>` (default 10000).
123
- - **mysql** — Polls a MySQL (or MariaDB) query. Same env-var interpolation and `--ssl` / `--max-rows` options as `postgres`.
124
- - **sqlite** — Polls a query against a local SQLite file (or `:memory:`). Opens read-only by default (pass `--no-readonly` to open read-write). Options: `-p` path, `-q` query, `-i` interval, `--max-rows <n>`, `--no-readonly`.
125
- - **kafka** — Streams messages from a Kafka (or Kafka-compatible, e.g. Redpanda) topic into a bounded ring buffer. Each message is decoded per `valueFormat` and spread into a row alongside `_kafka_topic`/`_kafka_partition`/`_kafka_offset`/`_kafka_timestamp`/`_kafka_key` metadata. Auto-generates a per-process consumer group so each pipequery instance sees the full firehose; set `groupId` explicitly to load-balance across replicas. Options: `--brokers` (comma list, supports `${ENV_VAR}`), `--topic`, `--group-id`, `--from-beginning`, `--value-format <json\|string\|raw>`, `--max-buffer <n>` (default 1000), `--ssl true`. SASL is config-only (not CLI-exposed yet).
117
+ Snowflake / ClickHouse / MongoDB are configured directly in `pipequery.yaml` (richer config than the CLI flags expose):
118
+
119
+ ```yaml
120
+ sources:
121
+ warehouse:
122
+ type: snowflake
123
+ account: "${SF_ACCOUNT}" # myorg-myaccount
124
+ username: "${SF_USER}"
125
+ password: "${SF_PASS}"
126
+ database: ANALYTICS
127
+ schema: PUBLIC
128
+ warehouse: COMPUTE_WH
129
+ query: "SELECT * FROM events WHERE ts > DATEADD(day, -1, CURRENT_TIMESTAMP)"
130
+
131
+ metrics:
132
+ type: clickhouse
133
+ url: "https://${CH_HOST}:8443"
134
+ username: "${CH_USER}"
135
+ password: "${CH_PASS}"
136
+ database: default
137
+ query: "SELECT * FROM metrics WHERE ts > now() - INTERVAL 1 HOUR"
138
+
139
+ users:
140
+ type: mongodb
141
+ url: "mongodb://${DB_USER}:${DB_PASS}@cluster.example.com/?authSource=admin"
142
+ database: app
143
+ collection: users
144
+ filter: { active: true } # default filter, AND-merged with push-down where()
145
+
146
+ binance_btc:
147
+ type: websocket
148
+ url: wss://stream.binance.com:9443/ws
149
+ subscribe: # required by every exchange feed
150
+ - { method: SUBSCRIBE, params: [btcusdt@ticker], id: 1 }
151
+ heartbeat:
152
+ payload: { method: PING }
153
+ interval: 30s
154
+ ```
155
+
156
+ REST sources support `${ENV_VAR}` interpolation in `url`, `headers`, `params`, and `auth.token`, plus an `auth: { kind: bearer, token }` helper:
157
+
158
+ ```yaml
159
+ sources:
160
+ github_issues:
161
+ type: rest
162
+ url: "https://api.github.com/repos/${REPO}/issues"
163
+ auth:
164
+ kind: bearer
165
+ token: "${GITHUB_TOKEN}"
166
+ interval: 5m
167
+ ```
168
+
169
+ ### Source types
170
+
171
+ - **rest** — Polls a REST API at a configurable interval. `${ENV_VAR}` interpolation in url/headers/params/auth.token. Optional `auth: { kind: bearer, token }` helper.
172
+ - **websocket** — Streams data from a WebSocket connection. Optional `subscribe` payload (single object or array) sent immediately after connect — required by every exchange feed (Binance / Coinbase / Kraken / OKX / Bybit / Deribit / Polygon / Alpaca). Re-sent on every reconnect. Optional `heartbeat: { payload, interval }` keepalive.
173
+ - **file** — Reads JSON or CSV files, optionally watches for changes via `chokidar`.
174
+ - **static** — Inline JSON data defined in config.
175
+ - **postgres** — Polls a Postgres query. `${ENV_VAR}` URL interpolation. SSL: `require` (default), `no-verify`, `false`. Safety `maxRows` cap (default 10000). **Push-down auto-routing**: where/sort/first/select/distinct/rollup/aggregates compile to native SQL.
176
+ - **mysql** / MariaDB — Same pattern + push-down as Postgres, MySQL dialect (backticks + `?` placeholders).
177
+ - **sqlite** — Polls a query against a local SQLite file (or `:memory:`). Opens read-only by default (pass `--no-readonly` to open read-write). No push-down today.
178
+ - **kafka** / Redpanda — Streams messages into a bounded ring buffer. Each message is decoded per `valueFormat` (`json` / `string` / `raw`) and spread into a row alongside `_kafka_topic`/`_kafka_partition`/`_kafka_offset`/`_kafka_timestamp`/`_kafka_key` metadata. Auto-generates a per-process consumer group; set `groupId` explicitly to load-balance across replicas. Options: `--brokers` (comma list, supports `${ENV_VAR}`), `--topic`, `--group-id`, `--from-beginning`, `--value-format`, `--max-buffer`, `--ssl true`. SASL is config-only.
179
+ - **snowflake** — Polls a SELECT against Snowflake. `account` / `username` / `password` / `database` / `schema` / `warehouse` / `role` all support `${ENV_VAR}`. **Push-down**: same operator + aggregate set as Postgres; double-quoted identifiers, `?` placeholders.
180
+ - **clickhouse** — Polls a SELECT against ClickHouse over HTTP/HTTPS. URL + creds support `${ENV_VAR}`. **Push-down**: same operator + aggregate set; backtick identifiers; literal values inlined (escaped) rather than bound, since the HTTP client uses `{name:Type}` named binds that need per-param type tagging.
181
+ - **mongodb** — Polls `find()` against a Mongo collection. Optional yaml `filter` is `$and`-merged with any push-down `where()` predicates. **Push-down**: `where`/`sort`/`first`/bare-field `select` → `find()` plan; grouping (`rollup` / pipeline-terminal aggregates) → `aggregate()` pipeline. Aggregate functions: `sum` / `avg` / `min` / `max` / `count` / `distinct_count`. `_id` stripped from result rows by default.
182
+
183
+ ### Push-down auto-routing
184
+
185
+ Pipe expressions targeting a Postgres / MySQL / Snowflake / ClickHouse / MongoDB source are compiled to native SQL or Mongo plans and run on the database directly — no in-memory materialization. The dispatch is automatic and transparent: the same `query()` / `runQuery()` call surface is used everywhere, and unsupported pipeline shapes (e.g. `where` after `rollup`, exotic aggregates like `percentile`/`vwap`) silently fall back to the in-process engine.
186
+
187
+ Operators pushed (SQL engines): `where`, `sort`, `first`, `select` (with arithmetic + aliases), `distinct()` (full-row), `rollup(keys, aggregates...)`, pipeline-terminal aggregates (`| sum(field)`, `| count()`, etc.). Aggregate functions translated to portable SQL: `sum`, `avg`, `min`, `max`, `count`, `distinct_count`.
188
+
189
+ Operators pushed (Mongo): same operator set, with `find()` for non-grouping pipelines and `aggregate()` for grouping.
190
+
191
+ What stays in-process: anything the compiler declines (multi-segment field paths, exotic aggregates, `where` after grouping, `groupBy` without `rollup`, `flatten`, `pivot`, `transpose`, `reduce`, `map`, `join` between sources from different engines, custom expression shapes). The AI agent doesn't need to know which path runs — both produce the same row shape.
126
192
 
127
193
  ### `pq endpoint`
128
194
 
@@ -305,6 +371,25 @@ Then in Telegram: `top 5 most expensive paid orders` → bot replies with the tr
305
371
 
306
372
  The translator uses prompt caching for both the system prompt (always-stable DSL grammar) and the per-tenant schema preamble (source list + inferred fields), so repeated queries are cheap. The schema preamble refreshes every minute.
307
373
 
374
+ ### Debug / monitor — structured event log
375
+
376
+ Every command, NL query, and unauthorized attempt is printed as a colored human-readable line on stderr — so you can see who's hitting the bot, what they're asking, and how it's being translated as it happens. Pass `--log-file ./bot.jsonl` to also append one JSON object per line for `jq`-style analysis or feeding into a SIEM.
377
+
378
+ ```bash
379
+ pq telegram serve --allow-user @yourname --log-file ./bot.jsonl
380
+ ```
381
+
382
+ ```
383
+ 21:15:03 ✓ @andreadito /sources 8 rows 12ms
384
+ 21:15:24 ✓ @andreadito "top 5 paid orders" 5 rows 847ms
385
+ → orders | where(status == 'paid') | sort(total desc) | first(5)
386
+ 21:15:47 🔒 @bob unauthorized
387
+ 21:17:00 ⚠ @andreadito "drop the orders table" not answerable
388
+ → PipeQuery is read-only and cannot drop tables.
389
+ ```
390
+
391
+ JSONL fields per event: `ts`, `kind` (`command` / `nl` / `unauthorized`), `user`, plus `cmd` / `args` / `outcome` / `rowCount` / `latencyMs` / `error` / `text` / `expression` / `explanation` as applicable. Writes are fire-and-forget so a slow disk doesn't back-pressure incoming Telegram messages.
392
+
308
393
  ## Watch — alerts to Telegram
309
394
 
310
395
  `pq watch add` registers a query that runs on every interval; when its result transitions (becomes non-empty, becomes empty, or changes content), pipequery posts a notification to a Telegram chat / channel.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Unit tests for compileClickhousePushdown — no ClickHouse needed.
3
+ *
4
+ * ClickHouse INLINES literal values (vs. the bind-then-placeholder path
5
+ * Postgres / MySQL / Snowflake take), so these tests focus on:
6
+ * 1. The string-escape contract (no SQL injection possible)
7
+ * 2. Identifier backticks
8
+ * 3. Operator-set parity with the other dialects
9
+ * 4. Empty params array (because everything is inlined)
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+ import { parseQuery } from '../src/engine.js';
13
+ import { compileClickhousePushdown } from '../src/server/sources/pushdown/clickhouse.js';
14
+
15
+ const BASE = 'SELECT * FROM events';
16
+
17
+ function compile(expression: string) {
18
+ const pipeline = parseQuery(expression);
19
+ return compileClickhousePushdown(pipeline, BASE);
20
+ }
21
+
22
+ describe('compileClickhousePushdown — dialect surface', () => {
23
+ it('quotes identifiers with backticks', () => {
24
+ const r = compile('events | sort(amount desc) | first(5)');
25
+ expect(r.ok).toBe(true);
26
+ if (!r.ok) return;
27
+ expect(r.compiled.sql).toContain('`amount`');
28
+ expect(r.compiled.sql).not.toContain('"amount"');
29
+ });
30
+
31
+ it('inlines literals — params array stays empty', () => {
32
+ const r = compile("events | where(status == 'paid' && amount > 100)");
33
+ expect(r.ok).toBe(true);
34
+ if (!r.ok) return;
35
+ expect(r.compiled.params).toEqual([]);
36
+ expect(r.compiled.sql).toContain("'paid'");
37
+ expect(r.compiled.sql).toContain('100');
38
+ // No ?, no $N placeholders anywhere.
39
+ expect(r.compiled.sql).not.toContain('?');
40
+ expect(r.compiled.sql).not.toContain('$1');
41
+ });
42
+
43
+ it('escapes single quotes inside string literals', () => {
44
+ const r = compile('events | where(customer == "O\'Brien")');
45
+ expect(r.ok).toBe(true);
46
+ if (!r.ok) return;
47
+ // The inlined literal must have the apostrophe escaped so it can't
48
+ // close the string early.
49
+ expect(r.compiled.sql).toContain("'O\\'Brien'");
50
+ });
51
+
52
+ it('escapes backslashes inside string literals (no double-escape)', () => {
53
+ const r = compile('events | where(path == "C:\\\\users")');
54
+ expect(r.ok).toBe(true);
55
+ if (!r.ok) return;
56
+ // Each backslash in the original string becomes a doubled backslash
57
+ // in the SQL — that's 2 bytes per source byte. "C:\\users" is 8
58
+ // characters in the source string (C : \ \ u s e r s actually no
59
+ // wait it's the JS-escaped form so the actual string is "C:\users"
60
+ // = 8 chars including 1 backslash). After escape: "C:\\\\users" =
61
+ // 10 chars including 2 backslashes. Quoted: 12.
62
+ expect(r.compiled.sql).toContain("'C:\\\\users'");
63
+ });
64
+
65
+ it('cannot be SQL-injected — apostrophe in string is escaped, not closing the literal', () => {
66
+ const r = compile('events | where(customer == "Robert\'); DROP TABLE events; --")');
67
+ expect(r.ok).toBe(true);
68
+ if (!r.ok) return;
69
+ // The bytes "DROP TABLE" do appear in the SQL — they're inside a quoted
70
+ // string literal, which is harmless. What matters is that the closing
71
+ // apostrophe is escaped (`\'`) so the literal can't terminate early
72
+ // and let the trailing `; DROP TABLE …; --` reach the parser as code.
73
+ expect(r.compiled.sql).toContain("\\'");
74
+ // Substring check: the original apostrophe lives between two backslash
75
+ // escapes — i.e. `Robert\');` should appear as `Robert\\\'); …` in the
76
+ // emitted SQL. That confirms the apostrophe was rewritten before the
77
+ // ); could close the string and start a new statement.
78
+ expect(r.compiled.sql).toContain("Robert\\');");
79
+ });
80
+
81
+ it('numbers, booleans, and null inline directly', () => {
82
+ const r1 = compile('events | where(amount > 99.5)');
83
+ if (r1.ok) expect(r1.compiled.sql).toContain('99.5');
84
+
85
+ // Boolean → 1 / 0 (ClickHouse uses UInt8 for booleans).
86
+ const r2 = compile('events | where(active == true)');
87
+ if (r2.ok) expect(r2.compiled.sql).toContain('= 1');
88
+
89
+ const r3 = compile('events | where(deleted_at == null)');
90
+ if (r3.ok) expect(r3.compiled.sql).toContain('IS NULL');
91
+ });
92
+ });
93
+
94
+ describe('compileClickhousePushdown — operator parity', () => {
95
+ it('rollup with GROUP BY + aggregates compiles', () => {
96
+ const r = compile('events | rollup(severity, sum(amount) as total, count() as n) | sort(total desc) | first(10)');
97
+ expect(r.ok).toBe(true);
98
+ if (!r.ok) return;
99
+ expect(r.compiled.sql).toContain('SUM(`amount`) AS `total`');
100
+ expect(r.compiled.sql).toContain('COUNT(*) AS `n`');
101
+ expect(r.compiled.sql).toContain('GROUP BY `severity`');
102
+ expect(r.compiled.sql).toContain('ORDER BY `total` DESC');
103
+ });
104
+
105
+ it('full-row distinct compiles', () => {
106
+ const r = compile('events | distinct()');
107
+ expect(r.ok).toBe(true);
108
+ if (!r.ok) return;
109
+ expect(r.compiled.sql).toMatch(/^SELECT DISTINCT \* FROM/);
110
+ });
111
+
112
+ it('declines unsupported aggregates (e.g. percentile)', () => {
113
+ const r = compile('events | rollup(percentile(amount, 0.95) as p95)');
114
+ expect(r.ok).toBe(false);
115
+ });
116
+
117
+ it('declines where AFTER rollup', () => {
118
+ const r = compile('events | rollup(severity, sum(amount) as t) | where(t > 100)');
119
+ expect(r.ok).toBe(false);
120
+ });
121
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Telegram event-log tests.
3
+ *
4
+ * Two surfaces under test:
5
+ * 1. The pretty formatter — strings only, run with chalk.level=0 so no
6
+ * ANSI codes are emitted; assertions look for substrings/values.
7
+ * 2. The JSONL file writer — backed by a real temp file on disk; verifies
8
+ * one JSON object per line, with the documented field shapes.
9
+ *
10
+ * No Telegram, no network.
11
+ */
12
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
13
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import chalk from 'chalk';
17
+ import { createEventLogger, formatPretty, type BotEvent } from '../src/telegram/event-log.js';
18
+
19
+ beforeAll(() => {
20
+ // No ANSI codes in test assertions — easier to write substring checks.
21
+ chalk.level = 0;
22
+ });
23
+
24
+ const FIXED_TS = new Date('2026-04-26T21:15:03.412Z');
25
+
26
+ describe('formatPretty — command events', () => {
27
+ it('formats a successful command with row count + latency', () => {
28
+ const out = formatPretty(
29
+ {
30
+ kind: 'command',
31
+ user: { id: 1, username: 'andreadito' },
32
+ cmd: '/sources',
33
+ outcome: 'success',
34
+ rowCount: 8,
35
+ latencyMs: 12,
36
+ },
37
+ FIXED_TS,
38
+ );
39
+ expect(out).toContain('@andreadito');
40
+ expect(out).toContain('/sources');
41
+ expect(out).toContain('✓');
42
+ expect(out).toContain('8 rows');
43
+ expect(out).toContain('12ms');
44
+ });
45
+
46
+ it('singularizes "1 row"', () => {
47
+ const out = formatPretty(
48
+ {
49
+ kind: 'command',
50
+ user: { id: 1, username: 'a' },
51
+ cmd: '/describe',
52
+ args: 'orders',
53
+ outcome: 'success',
54
+ rowCount: 1,
55
+ latencyMs: 5,
56
+ },
57
+ FIXED_TS,
58
+ );
59
+ expect(out).toContain('1 row');
60
+ expect(out).not.toContain('1 rows');
61
+ });
62
+
63
+ it('shows the command args inline', () => {
64
+ const out = formatPretty(
65
+ {
66
+ kind: 'command',
67
+ user: { id: 1, username: 'a' },
68
+ cmd: '/describe',
69
+ args: 'orders',
70
+ outcome: 'success',
71
+ rowCount: 5,
72
+ latencyMs: 7,
73
+ },
74
+ FIXED_TS,
75
+ );
76
+ expect(out).toContain('/describe orders');
77
+ });
78
+
79
+ it('truncates long args', () => {
80
+ const longArg = 'a'.repeat(200);
81
+ const out = formatPretty(
82
+ {
83
+ kind: 'command',
84
+ user: { id: 1, username: 'a' },
85
+ cmd: '/query',
86
+ args: longArg,
87
+ outcome: 'success',
88
+ rowCount: 0,
89
+ latencyMs: 1,
90
+ },
91
+ FIXED_TS,
92
+ );
93
+ expect(out).toContain('…');
94
+ expect(out.length).toBeLessThan(200);
95
+ });
96
+
97
+ it('formats a failed command with error message', () => {
98
+ const out = formatPretty(
99
+ {
100
+ kind: 'command',
101
+ user: { id: 1, username: 'a' },
102
+ cmd: '/query',
103
+ args: 'foo',
104
+ outcome: 'error',
105
+ latencyMs: 3,
106
+ error: 'parse error: unexpected token',
107
+ },
108
+ FIXED_TS,
109
+ );
110
+ expect(out).toContain('✗');
111
+ expect(out).toContain('parse error');
112
+ });
113
+ });
114
+
115
+ describe('formatPretty — natural-language events', () => {
116
+ it('formats a successful NL query with a continuation showing the translated expression', () => {
117
+ const out = formatPretty(
118
+ {
119
+ kind: 'nl',
120
+ user: { id: 1, username: 'andreadito' },
121
+ text: 'top 5 paid orders',
122
+ expression: 'orders | where(status == "paid") | sort(total desc) | first(5)',
123
+ explanation: 'Top 5 paid orders by total.',
124
+ outcome: 'success',
125
+ rowCount: 5,
126
+ latencyMs: 847,
127
+ },
128
+ FIXED_TS,
129
+ );
130
+ expect(out).toContain('"top 5 paid orders"');
131
+ expect(out).toContain('5 rows');
132
+ expect(out).toContain('847ms');
133
+ // Continuation line under the action column shows the expression.
134
+ expect(out).toContain('→ orders | where');
135
+ // Two lines (primary + continuation).
136
+ expect(out.split('\n')).toHaveLength(2);
137
+ });
138
+
139
+ it('formats not-answerable with the model explanation on a continuation line', () => {
140
+ const out = formatPretty(
141
+ {
142
+ kind: 'nl',
143
+ user: { id: 1, username: 'a' },
144
+ text: 'drop the orders table',
145
+ explanation: 'PipeQuery is read-only and cannot drop tables.',
146
+ outcome: 'not_answerable',
147
+ latencyMs: 412,
148
+ },
149
+ FIXED_TS,
150
+ );
151
+ expect(out).toContain('⚠');
152
+ expect(out).toContain('not answerable');
153
+ expect(out).toContain('PipeQuery is read-only');
154
+ // Two lines.
155
+ expect(out.split('\n')).toHaveLength(2);
156
+ });
157
+
158
+ it('formats an NL error inline (no continuation)', () => {
159
+ const out = formatPretty(
160
+ {
161
+ kind: 'nl',
162
+ user: { id: 1, username: 'a' },
163
+ text: 'something',
164
+ outcome: 'error',
165
+ latencyMs: 30,
166
+ error: 'translator returned non-JSON',
167
+ },
168
+ FIXED_TS,
169
+ );
170
+ expect(out).toContain('✗');
171
+ expect(out).toContain('translator returned non-JSON');
172
+ expect(out.split('\n')).toHaveLength(1);
173
+ });
174
+ });
175
+
176
+ describe('formatPretty — unauthorized', () => {
177
+ it('formats an unauthorized event with id when no username', () => {
178
+ const out = formatPretty({ kind: 'unauthorized', user: { id: 12345 } }, FIXED_TS);
179
+ expect(out).toContain('🔒');
180
+ expect(out).toContain('id:12345');
181
+ expect(out).toContain('unauthorized');
182
+ });
183
+
184
+ it('uses @username when present', () => {
185
+ const out = formatPretty({ kind: 'unauthorized', user: { id: 1, username: 'bob' } }, FIXED_TS);
186
+ expect(out).toContain('@bob');
187
+ });
188
+ });
189
+
190
+ describe('createEventLogger — JSONL file output', () => {
191
+ let dir: string;
192
+
193
+ beforeEach(async () => {
194
+ dir = await mkdtemp(join(tmpdir(), 'pq-eventlog-'));
195
+ });
196
+ afterEach(async () => {
197
+ await rm(dir, { recursive: true, force: true });
198
+ });
199
+
200
+ it('appends one JSON object per event', async () => {
201
+ const file = join(dir, 'bot.jsonl');
202
+ const stderrLines: string[] = [];
203
+ const logger = createEventLogger({
204
+ filePath: file,
205
+ writeStderr: (s) => stderrLines.push(s),
206
+ now: () => FIXED_TS,
207
+ });
208
+
209
+ const events: BotEvent[] = [
210
+ { kind: 'command', user: { id: 1, username: 'a' }, cmd: '/sources', outcome: 'success', rowCount: 8, latencyMs: 12 },
211
+ { kind: 'nl', user: { id: 1, username: 'a' }, text: 'top 5', expression: 'orders | first(5)', explanation: 'Top 5.', outcome: 'success', rowCount: 5, latencyMs: 800 },
212
+ { kind: 'unauthorized', user: { id: 99, username: 'bob' } },
213
+ ];
214
+ for (const e of events) logger.log(e);
215
+
216
+ // Give appendFile a tick to flush.
217
+ await new Promise((r) => setTimeout(r, 50));
218
+ const content = await readFile(file, 'utf8');
219
+ const lines = content.trim().split('\n');
220
+ expect(lines).toHaveLength(3);
221
+
222
+ const first = JSON.parse(lines[0]) as Record<string, unknown>;
223
+ expect(first.kind).toBe('command');
224
+ expect(first.cmd).toBe('/sources');
225
+ expect(first.outcome).toBe('success');
226
+ expect(first.rowCount).toBe(8);
227
+ expect(first.latencyMs).toBe(12);
228
+ expect(first.ts).toBe(FIXED_TS.toISOString());
229
+ expect(first.user).toEqual({ id: 1, username: 'a' });
230
+
231
+ const second = JSON.parse(lines[1]) as Record<string, unknown>;
232
+ expect(second.kind).toBe('nl');
233
+ expect(second.text).toBe('top 5');
234
+ expect(second.expression).toBe('orders | first(5)');
235
+ expect(second.outcome).toBe('success');
236
+
237
+ const third = JSON.parse(lines[2]) as Record<string, unknown>;
238
+ expect(third.kind).toBe('unauthorized');
239
+ expect(third.user).toEqual({ id: 99, username: 'bob' });
240
+ });
241
+
242
+ it('always writes to stderr regardless of whether a file is configured', async () => {
243
+ const stderrLines: string[] = [];
244
+ const logger = createEventLogger({
245
+ writeStderr: (s) => stderrLines.push(s),
246
+ now: () => FIXED_TS,
247
+ });
248
+ logger.log({ kind: 'command', user: { id: 1 }, cmd: '/sources', outcome: 'success', rowCount: 0, latencyMs: 1 });
249
+ expect(stderrLines).toHaveLength(1);
250
+ expect(stderrLines[0]).toContain('/sources');
251
+ });
252
+
253
+ it('omits optional fields from JSONL when absent', async () => {
254
+ const file = join(dir, 'bot.jsonl');
255
+ const logger = createEventLogger({
256
+ filePath: file,
257
+ writeStderr: () => undefined,
258
+ now: () => FIXED_TS,
259
+ });
260
+ logger.log({ kind: 'command', user: { id: 1 }, cmd: '/help', outcome: 'success', latencyMs: 1 });
261
+ await new Promise((r) => setTimeout(r, 50));
262
+ const obj = JSON.parse((await readFile(file, 'utf8')).trim()) as Record<string, unknown>;
263
+ expect(obj.rowCount).toBeUndefined();
264
+ expect(obj.error).toBeUndefined();
265
+ expect(obj.args).toBeUndefined();
266
+ });
267
+ });