@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.
- 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__/rest-source.test.ts +191 -0
- package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
- package/__tests__/websocket-source.test.ts +187 -0
- package/dist/index.js +3570 -2044
- package/package.json +11 -2
- package/src/commands/telegram.ts +10 -0
- package/src/config/schema.ts +90 -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/kafka.ts +4 -16
- package/src/server/sources/manager.ts +71 -1
- package/src/server/sources/mongodb.ts +165 -0
- package/src/server/sources/mysql.ts +46 -14
- package/src/server/sources/postgres.ts +52 -20
- 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/rest.ts +33 -5
- package/src/server/sources/snowflake.ts +158 -0
- package/src/server/sources/types.ts +27 -0
- package/src/server/sources/websocket.ts +73 -9
- package/src/telegram/bot.ts +198 -12
- package/src/telegram/event-log.ts +234 -0
- 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
|
+
}
|