@vaultgradient/pipequery-cli 0.7.0 → 0.10.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/README.md +98 -13
- package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
- package/__tests__/event-log.test.ts +267 -0
- package/__tests__/mongo-pushdown-compile.test.ts +160 -0
- package/__tests__/mysql-pushdown-compile.test.ts +122 -0
- package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
- package/__tests__/postgres-pushdown-compile.test.ts +240 -0
- package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
- package/__tests__/pushdown-routing.test.ts +144 -0
- package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
- package/dist/index.js +3642 -2172
- package/package.json +12 -2
- package/src/commands/telegram.ts +10 -0
- package/src/config/schema.ts +68 -1
- package/src/index.ts +2 -1
- package/src/mcp/provider.ts +4 -7
- package/src/server/routes/control.ts +1 -3
- package/src/server/routes/dynamic.ts +2 -4
- package/src/server/sources/clickhouse.ts +153 -0
- package/src/server/sources/manager.ts +71 -1
- package/src/server/sources/mongodb.ts +165 -0
- package/src/server/sources/mysql.ts +44 -0
- package/src/server/sources/postgres.ts +50 -0
- package/src/server/sources/pushdown/clickhouse.ts +17 -0
- package/src/server/sources/pushdown/compile.ts +328 -0
- package/src/server/sources/pushdown/dialect.ts +88 -0
- package/src/server/sources/pushdown/mongodb.ts +450 -0
- package/src/server/sources/pushdown/mysql.ts +17 -0
- package/src/server/sources/pushdown/postgres.ts +19 -0
- package/src/server/sources/pushdown/snowflake.ts +17 -0
- package/src/server/sources/snowflake.ts +158 -0
- package/src/server/sources/types.ts +27 -0
- package/src/telegram/bot.ts +198 -12
- package/src/telegram/event-log.ts +234 -0
- package/tsup.config.ts +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# pq — PipeQuery CLI
|
|
2
2
|
|
|
3
|
-
A command-line tool for building data pipelines,
|
|
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
|
|
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).
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
});
|