@vaultgradient/pipequery-cli 0.6.1 → 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 (40) 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__/rest-source.test.ts +191 -0
  11. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  12. package/__tests__/websocket-source.test.ts +187 -0
  13. package/dist/index.js +3570 -2044
  14. package/package.json +11 -2
  15. package/src/commands/telegram.ts +10 -0
  16. package/src/config/schema.ts +90 -1
  17. package/src/index.ts +2 -1
  18. package/src/mcp/provider.ts +4 -7
  19. package/src/server/routes/control.ts +1 -3
  20. package/src/server/routes/dynamic.ts +2 -4
  21. package/src/server/sources/clickhouse.ts +153 -0
  22. package/src/server/sources/kafka.ts +4 -16
  23. package/src/server/sources/manager.ts +71 -1
  24. package/src/server/sources/mongodb.ts +165 -0
  25. package/src/server/sources/mysql.ts +46 -14
  26. package/src/server/sources/postgres.ts +52 -20
  27. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  28. package/src/server/sources/pushdown/compile.ts +328 -0
  29. package/src/server/sources/pushdown/dialect.ts +88 -0
  30. package/src/server/sources/pushdown/mongodb.ts +450 -0
  31. package/src/server/sources/pushdown/mysql.ts +17 -0
  32. package/src/server/sources/pushdown/postgres.ts +19 -0
  33. package/src/server/sources/pushdown/snowflake.ts +17 -0
  34. package/src/server/sources/rest.ts +33 -5
  35. package/src/server/sources/snowflake.ts +158 -0
  36. package/src/server/sources/types.ts +27 -0
  37. package/src/server/sources/websocket.ts +73 -9
  38. package/src/telegram/bot.ts +198 -12
  39. package/src/telegram/event-log.ts +234 -0
  40. package/src/utils/expandEnv.ts +23 -0
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolve `${ENV_VAR}` placeholders in a string from process.env.
3
+ *
4
+ * Missing vars expand to empty string and emit a stderr warning so a
5
+ * mistyped env doesn't silently embed literal "${FOO}" into a connection
6
+ * string, header, URL, etc.
7
+ *
8
+ * `context` is included in the warning to help users locate the source
9
+ * of the misconfiguration (e.g. "postgres source URL", "rest source
10
+ * Authorization header"). Pass a short, human-readable string.
11
+ */
12
+ export function expandEnv(input: string, context: string): string {
13
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
14
+ const value = process.env[name];
15
+ if (value === undefined) {
16
+ process.stderr.write(
17
+ `[pipequery] env var "${name}" referenced in ${context} is not set\n`,
18
+ );
19
+ return '';
20
+ }
21
+ return value;
22
+ });
23
+ }