@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.
Files changed (35) 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 +12 -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
  35. package/tsup.config.ts +1 -1
@@ -5,6 +5,26 @@ export interface SourceStatus {
5
5
  error?: string;
6
6
  }
7
7
 
8
+ /**
9
+ * Result of an adapter-native push-down attempt.
10
+ *
11
+ * `ok: true` means the adapter executed the pipe expression natively (e.g.
12
+ * Postgres compiled it to SQL and ran it on the database) and returned rows
13
+ * without materializing the source query in memory. `sql` / `params` are
14
+ * optional telemetry — useful for audit logs and debugging, not required
15
+ * for callers to act on.
16
+ *
17
+ * `ok: false` is the explicit decline channel: the adapter recognised the
18
+ * shape but declined (unsupported op, multi-source pipeline, etc.). The
19
+ * caller should fall back to in-process execution against `getContext()`.
20
+ *
21
+ * Any thrown error means *neither* path succeeded — let it propagate; the
22
+ * caller has no useful fallback.
23
+ */
24
+ export type PushdownResult =
25
+ | { ok: true; rows: unknown[]; sql?: string; params?: unknown[] }
26
+ | { ok: false; reason: string };
27
+
8
28
  export interface SourceAdapter {
9
29
  start(): Promise<void>;
10
30
  stop(): void;
@@ -12,4 +32,11 @@ export interface SourceAdapter {
12
32
  getStatus(): SourceStatus;
13
33
  onUpdate(callback: (data: unknown[]) => void): () => void;
14
34
  refresh?(): Promise<void>;
35
+ /**
36
+ * Optional: compile + execute the pipe expression natively against the
37
+ * underlying engine instead of materializing into memory. When absent
38
+ * (or when the adapter declines), SourceManager falls back to in-process
39
+ * execution. Today only PostgresSourceAdapter implements this.
40
+ */
41
+ runPushdown?(expression: string): Promise<PushdownResult>;
15
42
  }
@@ -10,6 +10,7 @@ import { Bot, type Context, GrammyError, HttpError } from 'grammy';
10
10
  import type { Provider } from '../mcp/provider.js';
11
11
  import { formatError, formatResult, escapeHtml } from './format.js';
12
12
  import type { NLTranslator } from './nl.js';
13
+ import type { EventLogger, UserRef } from './event-log.js';
13
14
 
14
15
  export interface BotOptions {
15
16
  /** Whitelist of Telegram user IDs (numeric) or @usernames allowed to query.
@@ -18,12 +19,16 @@ export interface BotOptions {
18
19
  /** Optional natural-language translator. If present, plain-text messages
19
20
  * (no leading slash) are routed through it. */
20
21
  nl?: NLTranslator;
22
+ /** Optional structured event logger. When present, every command,
23
+ * NL query, and unauthorized attempt is recorded. */
24
+ logger?: EventLogger;
21
25
  }
22
26
 
23
27
  export function buildBot(token: string, provider: Provider, opts: BotOptions = {}): Bot {
24
28
  const bot = new Bot(token);
25
29
  const allowList = normalizeAllowList(opts.allowUsers ?? []);
26
30
  const nl = opts.nl;
31
+ const logger = opts.logger;
27
32
 
28
33
  // Auth middleware — runs before any handler.
29
34
  bot.use(async (ctx, next) => {
@@ -36,6 +41,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
36
41
  return;
37
42
  }
38
43
  if (!isAllowed(ctx, allowList)) {
44
+ logger?.log({ kind: 'unauthorized', user: userOf(ctx) });
39
45
  await ctx.reply('🔒 You are not authorized to use this bot.');
40
46
  return;
41
47
  }
@@ -43,6 +49,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
43
49
  });
44
50
 
45
51
  bot.command('start', async (ctx) => {
52
+ const start = Date.now();
46
53
  await ctx.reply(
47
54
  [
48
55
  '👋 <b>pipequery bot</b>',
@@ -59,9 +66,11 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
59
66
  ].join('\n'),
60
67
  { parse_mode: 'HTML' },
61
68
  );
69
+ logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/start', outcome: 'success', latencyMs: Date.now() - start });
62
70
  });
63
71
 
64
72
  bot.command('help', async (ctx) => {
73
+ const start = Date.now();
65
74
  const lines = [
66
75
  '<b>Examples</b>',
67
76
  '<code>/sources</code>',
@@ -86,37 +95,73 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
86
95
  '• Results &gt; 30 rows are truncated with a footer; tighten your query to see all of them.',
87
96
  );
88
97
  await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
98
+ logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/help', outcome: 'success', latencyMs: Date.now() - start });
89
99
  });
90
100
 
91
101
  bot.command('sources', async (ctx) => {
102
+ const start = Date.now();
92
103
  try {
93
104
  const sources = await provider.listSources();
94
105
  if (sources.length === 0) {
95
106
  await ctx.reply('<i>No sources configured.</i>', { parse_mode: 'HTML' });
96
- return;
107
+ } else {
108
+ const lines = sources.map((s) => {
109
+ const dot = s.status.healthy ? '🟢' : '🔴';
110
+ const rowCount = s.status.rowCount.toLocaleString();
111
+ const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
112
+ return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
113
+ });
114
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
97
115
  }
98
- const lines = sources.map((s) => {
99
- const dot = s.status.healthy ? '🟢' : '🔴';
100
- const rowCount = s.status.rowCount.toLocaleString();
101
- const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
102
- return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
116
+ logger?.log({
117
+ kind: 'command',
118
+ user: userOf(ctx),
119
+ cmd: '/sources',
120
+ outcome: 'success',
121
+ rowCount: sources.length,
122
+ latencyMs: Date.now() - start,
103
123
  });
104
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
105
124
  } catch (err) {
106
125
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
126
+ logger?.log({
127
+ kind: 'command',
128
+ user: userOf(ctx),
129
+ cmd: '/sources',
130
+ outcome: 'error',
131
+ latencyMs: Date.now() - start,
132
+ error: errMessage(err),
133
+ });
107
134
  }
108
135
  });
109
136
 
110
137
  bot.command('describe', async (ctx) => {
138
+ const start = Date.now();
111
139
  const name = ctx.match.trim();
112
140
  if (!name) {
113
141
  await ctx.reply('Usage: <code>/describe &lt;source-name&gt;</code>', { parse_mode: 'HTML' });
142
+ logger?.log({
143
+ kind: 'command',
144
+ user: userOf(ctx),
145
+ cmd: '/describe',
146
+ outcome: 'error',
147
+ latencyMs: Date.now() - start,
148
+ error: 'missing source name',
149
+ });
114
150
  return;
115
151
  }
116
152
  try {
117
153
  const desc = await provider.describeSource(name, 5);
118
154
  if (!desc) {
119
155
  await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: 'HTML' });
156
+ logger?.log({
157
+ kind: 'command',
158
+ user: userOf(ctx),
159
+ cmd: '/describe',
160
+ args: name,
161
+ outcome: 'error',
162
+ latencyMs: Date.now() - start,
163
+ error: 'source not found',
164
+ });
120
165
  return;
121
166
  }
122
167
  const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(', ') : '<i>(none inferred)</i>';
@@ -125,52 +170,141 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
125
170
  `<b>${escapeHtml(name)}</b> — ${desc.status.rowCount.toLocaleString()} rows\n<b>Fields:</b> ${fields}\n\n<b>Sample:</b>\n${sample}`,
126
171
  { parse_mode: 'HTML' },
127
172
  );
173
+ logger?.log({
174
+ kind: 'command',
175
+ user: userOf(ctx),
176
+ cmd: '/describe',
177
+ args: name,
178
+ outcome: 'success',
179
+ rowCount: desc.sample.length,
180
+ latencyMs: Date.now() - start,
181
+ });
128
182
  } catch (err) {
129
183
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
184
+ logger?.log({
185
+ kind: 'command',
186
+ user: userOf(ctx),
187
+ cmd: '/describe',
188
+ args: name,
189
+ outcome: 'error',
190
+ latencyMs: Date.now() - start,
191
+ error: errMessage(err),
192
+ });
130
193
  }
131
194
  });
132
195
 
133
196
  bot.command('endpoints', async (ctx) => {
197
+ const start = Date.now();
134
198
  try {
135
199
  const endpoints = await provider.listEndpoints();
136
200
  if (endpoints.length === 0) {
137
201
  await ctx.reply('<i>No endpoints configured.</i>', { parse_mode: 'HTML' });
138
- return;
202
+ } else {
203
+ const lines = endpoints.map(
204
+ (e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
205
+ );
206
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
139
207
  }
140
- const lines = endpoints.map(
141
- (e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
142
- );
143
- await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
208
+ logger?.log({
209
+ kind: 'command',
210
+ user: userOf(ctx),
211
+ cmd: '/endpoints',
212
+ outcome: 'success',
213
+ rowCount: endpoints.length,
214
+ latencyMs: Date.now() - start,
215
+ });
144
216
  } catch (err) {
145
217
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
218
+ logger?.log({
219
+ kind: 'command',
220
+ user: userOf(ctx),
221
+ cmd: '/endpoints',
222
+ outcome: 'error',
223
+ latencyMs: Date.now() - start,
224
+ error: errMessage(err),
225
+ });
146
226
  }
147
227
  });
148
228
 
149
229
  bot.command('call', async (ctx) => {
230
+ const start = Date.now();
150
231
  const path = ctx.match.trim();
151
232
  if (!path) {
152
233
  await ctx.reply('Usage: <code>/call &lt;endpoint-path&gt;</code>', { parse_mode: 'HTML' });
234
+ logger?.log({
235
+ kind: 'command',
236
+ user: userOf(ctx),
237
+ cmd: '/call',
238
+ outcome: 'error',
239
+ latencyMs: Date.now() - start,
240
+ error: 'missing path',
241
+ });
153
242
  return;
154
243
  }
155
244
  try {
156
245
  const result = await provider.callEndpoint(path);
157
246
  await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
247
+ logger?.log({
248
+ kind: 'command',
249
+ user: userOf(ctx),
250
+ cmd: '/call',
251
+ args: path,
252
+ outcome: 'success',
253
+ rowCount: rowCountOf(result),
254
+ latencyMs: Date.now() - start,
255
+ });
158
256
  } catch (err) {
159
257
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
258
+ logger?.log({
259
+ kind: 'command',
260
+ user: userOf(ctx),
261
+ cmd: '/call',
262
+ args: path,
263
+ outcome: 'error',
264
+ latencyMs: Date.now() - start,
265
+ error: errMessage(err),
266
+ });
160
267
  }
161
268
  });
162
269
 
163
270
  bot.command('query', async (ctx) => {
271
+ const start = Date.now();
164
272
  const expr = ctx.match.trim();
165
273
  if (!expr) {
166
274
  await ctx.reply('Usage: <code>/query &lt;pipequery expression&gt;</code>', { parse_mode: 'HTML' });
275
+ logger?.log({
276
+ kind: 'command',
277
+ user: userOf(ctx),
278
+ cmd: '/query',
279
+ outcome: 'error',
280
+ latencyMs: Date.now() - start,
281
+ error: 'missing expression',
282
+ });
167
283
  return;
168
284
  }
169
285
  try {
170
286
  const result = await provider.runQuery(expr);
171
287
  await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
288
+ logger?.log({
289
+ kind: 'command',
290
+ user: userOf(ctx),
291
+ cmd: '/query',
292
+ args: expr,
293
+ outcome: 'success',
294
+ rowCount: rowCountOf(result),
295
+ latencyMs: Date.now() - start,
296
+ });
172
297
  } catch (err) {
173
298
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
299
+ logger?.log({
300
+ kind: 'command',
301
+ user: userOf(ctx),
302
+ cmd: '/query',
303
+ args: expr,
304
+ outcome: 'error',
305
+ latencyMs: Date.now() - start,
306
+ error: errMessage(err),
307
+ });
174
308
  }
175
309
  });
176
310
 
@@ -180,12 +314,21 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
180
314
  bot.on('message:text', async (ctx) => {
181
315
  const text = ctx.message.text.trim();
182
316
  if (!text || text.startsWith('/')) return;
317
+ const start = Date.now();
183
318
  if (!nl) {
184
319
  await ctx.reply(
185
320
  'I don\'t recognise that. Use <code>/help</code> to see the available commands. ' +
186
321
  'Natural-language queries are disabled — start the bot with <code>--anthropic-key</code> to enable them.',
187
322
  { parse_mode: 'HTML' },
188
323
  );
324
+ logger?.log({
325
+ kind: 'nl',
326
+ user: userOf(ctx),
327
+ text,
328
+ outcome: 'error',
329
+ latencyMs: Date.now() - start,
330
+ error: 'NL translation disabled',
331
+ });
189
332
  return;
190
333
  }
191
334
  try {
@@ -194,13 +337,39 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
194
337
  if (!expression) {
195
338
  const reason = explanation || 'I couldn\'t translate that into a pipequery expression.';
196
339
  await ctx.reply(`🤔 ${escapeHtml(reason)}`, { parse_mode: 'HTML' });
340
+ logger?.log({
341
+ kind: 'nl',
342
+ user: userOf(ctx),
343
+ text,
344
+ explanation,
345
+ outcome: 'not_answerable',
346
+ latencyMs: Date.now() - start,
347
+ });
197
348
  return;
198
349
  }
199
350
  const result = await provider.runQuery(expression);
200
351
  const header = `<i>${escapeHtml(explanation || 'Translated query')}</i>\n<code>${escapeHtml(expression)}</code>\n\n`;
201
352
  await ctx.reply(header + formatResult(result), { parse_mode: 'HTML' });
353
+ logger?.log({
354
+ kind: 'nl',
355
+ user: userOf(ctx),
356
+ text,
357
+ expression,
358
+ explanation,
359
+ outcome: 'success',
360
+ rowCount: rowCountOf(result),
361
+ latencyMs: Date.now() - start,
362
+ });
202
363
  } catch (err) {
203
364
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
365
+ logger?.log({
366
+ kind: 'nl',
367
+ user: userOf(ctx),
368
+ text,
369
+ outcome: 'error',
370
+ latencyMs: Date.now() - start,
371
+ error: errMessage(err),
372
+ });
204
373
  }
205
374
  });
206
375
 
@@ -245,3 +414,20 @@ function trackUnauthSeen(id: number, username?: string): void {
245
414
  `Restart with --allow-user @yourname to lock it down.\n`,
246
415
  );
247
416
  }
417
+
418
+ // ─── Logging helpers ────────────────────────────────────────────────────────
419
+
420
+ function userOf(ctx: Context): UserRef {
421
+ return {
422
+ id: ctx.from?.id,
423
+ username: ctx.from?.username,
424
+ };
425
+ }
426
+
427
+ function rowCountOf(value: unknown): number | undefined {
428
+ return Array.isArray(value) ? value.length : undefined;
429
+ }
430
+
431
+ function errMessage(err: unknown): string {
432
+ return err instanceof Error ? err.message : String(err);
433
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Per-message event logging for the Telegram bot.
3
+ *
4
+ * Two outputs:
5
+ * 1. Pretty-printed line on stderr — colored, scannable, always on. This
6
+ * is what you see when running `pq telegram serve` in the foreground.
7
+ * 2. Optional JSONL file — one JSON object per line, machine-readable.
8
+ * Opted in via `--log-file <path>` on `pq telegram serve`. Useful for
9
+ * `jq`/`grep`/audit-log workflows.
10
+ *
11
+ * Pretty-print example:
12
+ *
13
+ * 21:15:03 ✓ @andreadito /sources 8 rows 12ms
14
+ * 21:15:24 ✓ @andreadito "top 5 paid orders" 5 rows 847ms
15
+ * → orders | where(status == "paid") | sort(total desc) | first(5)
16
+ * 21:15:47 🔒 @bob unauthorized
17
+ * 21:16:02 ✗ @andreadito /query foo parse error
18
+ * 21:17:00 ⚠ @andreadito "drop the orders table" not answerable
19
+ * → PipeQuery is read-only and cannot drop tables.
20
+ *
21
+ * Format design notes:
22
+ * - HH:MM:SS local time (dim) so the eye anchors on the action, not the timestamp.
23
+ * - One outcome glyph: ✓ success, ✗ error, 🔒 unauthorized, ⚠ not-answerable.
24
+ * - Username column padded so things line up at typical widths.
25
+ * - User text/command in white-ish so it's the most-readable element.
26
+ * - Stats (rows + ms) right-aligned in dim — present when relevant.
27
+ * - Translator output (NL → expression OR not-answerable explanation) goes
28
+ * on a continuation line so the primary line stays scannable.
29
+ */
30
+ import { appendFile } from 'node:fs/promises';
31
+ import chalk from 'chalk';
32
+
33
+ export type BotEvent =
34
+ | {
35
+ kind: 'command';
36
+ user: UserRef;
37
+ cmd: string;
38
+ args?: string;
39
+ outcome: 'success' | 'error';
40
+ rowCount?: number;
41
+ latencyMs: number;
42
+ error?: string;
43
+ }
44
+ | {
45
+ kind: 'nl';
46
+ user: UserRef;
47
+ text: string;
48
+ expression?: string;
49
+ explanation?: string;
50
+ outcome: 'success' | 'error' | 'not_answerable';
51
+ rowCount?: number;
52
+ latencyMs: number;
53
+ error?: string;
54
+ }
55
+ | { kind: 'unauthorized'; user: UserRef };
56
+
57
+ export interface UserRef {
58
+ id?: number;
59
+ username?: string;
60
+ }
61
+
62
+ export interface EventLogger {
63
+ log(event: BotEvent): void;
64
+ /** Best-effort flush for the JSONL file. Currently a no-op since
65
+ * appendFile awaits its own write; exposed for future buffering. */
66
+ flush(): Promise<void>;
67
+ }
68
+
69
+ export interface EventLoggerOptions {
70
+ /** Optional JSONL file path. When set, every event is also appended
71
+ * as one JSON object per line. */
72
+ filePath?: string;
73
+ /** Override the stderr writer (for tests). */
74
+ writeStderr?: (line: string) => void;
75
+ /** Override the wall clock (for tests). */
76
+ now?: () => Date;
77
+ }
78
+
79
+ const USER_WIDTH = 14;
80
+ const ACTION_WIDTH = 36;
81
+
82
+ export function createEventLogger(opts: EventLoggerOptions = {}): EventLogger {
83
+ const writeStderr = opts.writeStderr ?? ((line) => process.stderr.write(line));
84
+ const now = opts.now ?? (() => new Date());
85
+ const filePath = opts.filePath;
86
+
87
+ return {
88
+ log(event: BotEvent): void {
89
+ const ts = now();
90
+ writeStderr(formatPretty(event, ts) + '\n');
91
+ if (filePath) {
92
+ const json = JSON.stringify({ ts: ts.toISOString(), ...flattenForJson(event) });
93
+ // Fire-and-forget write; the bot doesn't await this so a slow disk
94
+ // doesn't back-pressure incoming Telegram messages. Errors surface
95
+ // on stderr so a misconfigured path is visible.
96
+ void appendFile(filePath, json + '\n').catch((err) => {
97
+ writeStderr(
98
+ chalk.hex('#ef4444')(' ✗') +
99
+ ' [pipequery-telegram] log-file write failed: ' +
100
+ (err instanceof Error ? err.message : String(err)) +
101
+ '\n',
102
+ );
103
+ });
104
+ }
105
+ },
106
+ async flush(): Promise<void> {
107
+ // Reserved for future buffering. appendFile already awaits internally.
108
+ },
109
+ };
110
+ }
111
+
112
+ // ─── Pretty formatter ───────────────────────────────────────────────────────
113
+
114
+ export function formatPretty(event: BotEvent, ts: Date): string {
115
+ const time = chalk.dim(formatTime(ts));
116
+ const user = chalk.hex('#06b6d4')(padRight(formatUser(event.user), USER_WIDTH));
117
+
118
+ if (event.kind === 'unauthorized') {
119
+ const glyph = chalk.hex('#f59e0b')('🔒');
120
+ return ` ${time} ${glyph} ${user} ${chalk.hex('#f59e0b')('unauthorized')}`;
121
+ }
122
+
123
+ if (event.kind === 'command') {
124
+ const glyph = outcomeGlyph(event.outcome);
125
+ const action = padRight(`${event.cmd}${event.args ? ' ' + truncate(event.args, 24) : ''}`, ACTION_WIDTH);
126
+ const stats = formatStats(event.rowCount, event.latencyMs);
127
+ if (event.outcome === 'error' && event.error) {
128
+ return ` ${time} ${glyph} ${user} ${action} ${chalk.hex('#ef4444')(truncate(event.error, 60))}`;
129
+ }
130
+ return ` ${time} ${glyph} ${user} ${action} ${stats}`;
131
+ }
132
+
133
+ // NL message
134
+ const glyph = nlOutcomeGlyph(event.outcome);
135
+ const action = padRight(`"${truncate(event.text, ACTION_WIDTH - 4)}"`, ACTION_WIDTH);
136
+ let line: string;
137
+ if (event.outcome === 'error' && event.error) {
138
+ line = ` ${time} ${glyph} ${user} ${action} ${chalk.hex('#ef4444')(truncate(event.error, 60))}`;
139
+ } else if (event.outcome === 'not_answerable') {
140
+ line = ` ${time} ${glyph} ${user} ${action} ${chalk.hex('#f59e0b')('not answerable')}`;
141
+ } else {
142
+ line = ` ${time} ${glyph} ${user} ${action} ${formatStats(event.rowCount, event.latencyMs)}`;
143
+ }
144
+ // Continuation line: translated expression (success) or explanation (not_answerable).
145
+ const continuation =
146
+ event.outcome === 'success' && event.expression
147
+ ? chalk.dim('→ ') + chalk.hex('#82aaff')(event.expression)
148
+ : event.outcome === 'not_answerable' && event.explanation
149
+ ? chalk.dim('→ ') + chalk.hex('#f59e0b')(event.explanation)
150
+ : null;
151
+ if (continuation) {
152
+ // Indent continuation under the action column for visual alignment.
153
+ const pad = ' '.repeat(2 + 8 + 1 + 2 + 1 + USER_WIDTH + 2);
154
+ return `${line}\n${pad}${continuation}`;
155
+ }
156
+ return line;
157
+ }
158
+
159
+ function outcomeGlyph(outcome: 'success' | 'error'): string {
160
+ return outcome === 'success' ? chalk.hex('#10b981')('✓') : chalk.hex('#ef4444')('✗');
161
+ }
162
+
163
+ function nlOutcomeGlyph(outcome: 'success' | 'error' | 'not_answerable'): string {
164
+ if (outcome === 'success') return chalk.hex('#10b981')('✓');
165
+ if (outcome === 'error') return chalk.hex('#ef4444')('✗');
166
+ return chalk.hex('#f59e0b')('⚠');
167
+ }
168
+
169
+ function formatStats(rowCount: number | undefined, latencyMs: number): string {
170
+ const parts: string[] = [];
171
+ if (typeof rowCount === 'number') {
172
+ parts.push(`${rowCount.toLocaleString()} ${rowCount === 1 ? 'row' : 'rows'}`);
173
+ }
174
+ parts.push(`${latencyMs}ms`);
175
+ return chalk.dim(parts.join(' '));
176
+ }
177
+
178
+ function formatTime(d: Date): string {
179
+ const hh = String(d.getHours()).padStart(2, '0');
180
+ const mm = String(d.getMinutes()).padStart(2, '0');
181
+ const ss = String(d.getSeconds()).padStart(2, '0');
182
+ return `${hh}:${mm}:${ss}`;
183
+ }
184
+
185
+ function formatUser(u: UserRef): string {
186
+ if (u.username) return `@${u.username}`;
187
+ if (u.id !== undefined) return `id:${u.id}`;
188
+ return '<unknown>';
189
+ }
190
+
191
+ function padRight(s: string, width: number): string {
192
+ // padEnd uses code-unit length, which is fine for ASCII labels we control.
193
+ // Truncate first so we never overflow the column.
194
+ const t = truncate(s, width);
195
+ return t.padEnd(width);
196
+ }
197
+
198
+ function truncate(s: string, max: number): string {
199
+ if (s.length <= max) return s;
200
+ if (max <= 1) return s.slice(0, max);
201
+ return s.slice(0, max - 1) + '…';
202
+ }
203
+
204
+ // ─── JSONL serialization ────────────────────────────────────────────────────
205
+
206
+ function flattenForJson(event: BotEvent): Record<string, unknown> {
207
+ const userOut: Record<string, unknown> = {};
208
+ if (event.user.username) userOut.username = event.user.username;
209
+ if (event.user.id !== undefined) userOut.id = event.user.id;
210
+ const base = { kind: event.kind, user: userOut };
211
+ if (event.kind === 'unauthorized') return base;
212
+ if (event.kind === 'command') {
213
+ return {
214
+ ...base,
215
+ cmd: event.cmd,
216
+ ...(event.args ? { args: event.args } : {}),
217
+ outcome: event.outcome,
218
+ ...(event.rowCount !== undefined ? { rowCount: event.rowCount } : {}),
219
+ latencyMs: event.latencyMs,
220
+ ...(event.error ? { error: event.error } : {}),
221
+ };
222
+ }
223
+ // nl
224
+ return {
225
+ ...base,
226
+ text: event.text,
227
+ ...(event.expression ? { expression: event.expression } : {}),
228
+ ...(event.explanation ? { explanation: event.explanation } : {}),
229
+ outcome: event.outcome,
230
+ ...(event.rowCount !== undefined ? { rowCount: event.rowCount } : {}),
231
+ latencyMs: event.latencyMs,
232
+ ...(event.error ? { error: event.error } : {}),
233
+ };
234
+ }
package/tsup.config.ts CHANGED
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsup';
3
3
  export default defineConfig({
4
4
  entry: ['src/index.ts'],
5
5
  format: ['esm'],
6
- target: 'node18',
6
+ target: 'node24',
7
7
  platform: 'node',
8
8
  clean: true,
9
9
  splitting: false,