@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.
- 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 +11 -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
|
@@ -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
|
}
|
package/src/telegram/bot.ts
CHANGED
|
@@ -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 > 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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 <source-name></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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 <endpoint-path></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 <pipequery expression></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
|
+
}
|