@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaultgradient/pipequery-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@anthropic-ai/sdk": "^0.91.1",
|
|
17
|
+
"@clickhouse/client": "^1.18.3",
|
|
17
18
|
"@fastify/cors": "^11.2.0",
|
|
18
19
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
19
20
|
"better-sqlite3": "^12.9.0",
|
|
@@ -25,10 +26,12 @@
|
|
|
25
26
|
"grammy": "^1.42.0",
|
|
26
27
|
"ink": "^7.0.1",
|
|
27
28
|
"kafkajs": "^2.2.4",
|
|
29
|
+
"mongodb": "^7.2.0",
|
|
28
30
|
"mysql2": "^3.22.2",
|
|
29
31
|
"ora": "^9.3.0",
|
|
30
32
|
"pg": "^8.20.0",
|
|
31
33
|
"react": "^19.2.5",
|
|
34
|
+
"snowflake-sdk": "^2.4.0",
|
|
32
35
|
"ws": "^8.20.0",
|
|
33
36
|
"yaml": "^2.8.3",
|
|
34
37
|
"zod": "^4.3.6"
|
|
@@ -43,11 +46,17 @@
|
|
|
43
46
|
"typescript": "~6.0.0",
|
|
44
47
|
"vitest": "^4.1.4"
|
|
45
48
|
},
|
|
49
|
+
"overrides": {
|
|
50
|
+
"gcp-metadata": "^8.0.0"
|
|
51
|
+
},
|
|
46
52
|
"repository": {
|
|
47
53
|
"type": "git",
|
|
48
54
|
"url": "https://github.com/andreadito/pipequery.git",
|
|
49
55
|
"directory": "cli"
|
|
50
56
|
},
|
|
51
57
|
"author": "andreadito",
|
|
52
|
-
"license": "MIT"
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=22"
|
|
61
|
+
}
|
|
53
62
|
}
|
package/src/commands/telegram.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { loadConfig } from '../config/loader.js';
|
|
2
3
|
import { log } from '../utils/logger.js';
|
|
3
4
|
import { LocalProvider, AttachedProvider, type Provider } from '../mcp/provider.js';
|
|
4
5
|
import { buildBot } from '../telegram/bot.js';
|
|
5
6
|
import { createNLTranslator } from '../telegram/nl.js';
|
|
7
|
+
import { createEventLogger } from '../telegram/event-log.js';
|
|
6
8
|
|
|
7
9
|
interface TelegramServeOptions {
|
|
8
10
|
botToken?: string;
|
|
9
11
|
attach?: string;
|
|
10
12
|
allowUser?: string[];
|
|
11
13
|
anthropicKey?: string;
|
|
14
|
+
logFile?: string;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export async function telegramServeCommand(opts: TelegramServeOptions): Promise<void> {
|
|
@@ -26,9 +29,13 @@ export async function telegramServeCommand(opts: TelegramServeOptions): Promise<
|
|
|
26
29
|
? createNLTranslator(provider, { apiKey: anthropicKey })
|
|
27
30
|
: undefined;
|
|
28
31
|
|
|
32
|
+
const logFilePath = opts.logFile ? resolve(process.cwd(), opts.logFile) : undefined;
|
|
33
|
+
const logger = createEventLogger({ filePath: logFilePath });
|
|
34
|
+
|
|
29
35
|
const bot = buildBot(token, provider, {
|
|
30
36
|
allowUsers: opts.allowUser ?? [],
|
|
31
37
|
nl,
|
|
38
|
+
logger,
|
|
32
39
|
});
|
|
33
40
|
|
|
34
41
|
const me = await bot.api.getMe();
|
|
@@ -38,6 +45,9 @@ export async function telegramServeCommand(opts: TelegramServeOptions): Promise<
|
|
|
38
45
|
} else {
|
|
39
46
|
log.dim('Natural-language translation disabled. Pass --anthropic-key or set ANTHROPIC_API_KEY to enable.');
|
|
40
47
|
}
|
|
48
|
+
if (logFilePath) {
|
|
49
|
+
log.info(`Event log → ${logFilePath} (JSONL)`);
|
|
50
|
+
}
|
|
41
51
|
if (!opts.allowUser || opts.allowUser.length === 0) {
|
|
42
52
|
log.warn('No --allow-user set: anyone with the bot username can query. Add allowlist for production.');
|
|
43
53
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -26,7 +26,10 @@ export type SourceConfig =
|
|
|
26
26
|
| PostgresSourceConfig
|
|
27
27
|
| MysqlSourceConfig
|
|
28
28
|
| SqliteSourceConfig
|
|
29
|
-
| KafkaSourceConfig
|
|
29
|
+
| KafkaSourceConfig
|
|
30
|
+
| SnowflakeSourceConfig
|
|
31
|
+
| ClickhouseSourceConfig
|
|
32
|
+
| MongoSourceConfig;
|
|
30
33
|
|
|
31
34
|
export interface RestSourceConfig {
|
|
32
35
|
type: 'rest';
|
|
@@ -35,12 +38,34 @@ export interface RestSourceConfig {
|
|
|
35
38
|
headers?: Record<string, string>;
|
|
36
39
|
dataPath?: string;
|
|
37
40
|
interval?: string;
|
|
41
|
+
/** Optional auth helper. Currently supports `bearer`; more kinds (basic,
|
|
42
|
+
* apiKeyHeader, hmac-*) tracked in issue #37. The token is interpolated
|
|
43
|
+
* through ${ENV_VAR} like every other string field. */
|
|
44
|
+
auth?: BearerAuth;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BearerAuth {
|
|
48
|
+
kind: 'bearer';
|
|
49
|
+
token: string;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
export interface WebSocketSourceConfig {
|
|
41
53
|
type: 'websocket';
|
|
42
54
|
url: string;
|
|
43
55
|
maxBuffer?: number;
|
|
56
|
+
/** Payload(s) to send to the server immediately after the WebSocket
|
|
57
|
+
* opens. Most exchange feeds (Binance, Coinbase, Kraken, OKX, Bybit, …)
|
|
58
|
+
* require this — without it, no data is delivered. Each payload is
|
|
59
|
+
* JSON-stringified and sent in order. Re-sent on every reconnect. */
|
|
60
|
+
subscribe?: unknown | unknown[];
|
|
61
|
+
/** Optional keepalive: sends `payload` to the server every `interval`
|
|
62
|
+
* while the socket is open. Most exchanges close idle sockets within
|
|
63
|
+
* a few minutes; a periodic ping keeps the stream alive. */
|
|
64
|
+
heartbeat?: {
|
|
65
|
+
payload: unknown;
|
|
66
|
+
/** Duration like "30s", "1m". */
|
|
67
|
+
interval: string;
|
|
68
|
+
};
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
export interface FileSourceConfig {
|
|
@@ -223,3 +248,67 @@ export interface TelegramNotifyConfig {
|
|
|
223
248
|
*/
|
|
224
249
|
message?: string;
|
|
225
250
|
}
|
|
251
|
+
|
|
252
|
+
// ─── Snowflake / ClickHouse / MongoDB ───────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export interface SnowflakeSourceConfig {
|
|
255
|
+
type: 'snowflake';
|
|
256
|
+
/** Snowflake account locator, e.g. `myorg-myaccount`. Supports `${ENV_VAR}`. */
|
|
257
|
+
account: string;
|
|
258
|
+
/** Username. Supports `${ENV_VAR}`. */
|
|
259
|
+
username: string;
|
|
260
|
+
/** Password. Supports `${ENV_VAR}`. (Key-pair / OAuth out of scope for v1.) */
|
|
261
|
+
password: string;
|
|
262
|
+
/** Database to USE. Supports `${ENV_VAR}`. */
|
|
263
|
+
database?: string;
|
|
264
|
+
/** Schema to USE. Supports `${ENV_VAR}`. */
|
|
265
|
+
schema?: string;
|
|
266
|
+
/** Compute warehouse to USE. Supports `${ENV_VAR}`. */
|
|
267
|
+
warehouse?: string;
|
|
268
|
+
/** Optional role. Supports `${ENV_VAR}`. */
|
|
269
|
+
role?: string;
|
|
270
|
+
/** SELECT query polled on every interval. */
|
|
271
|
+
query: string;
|
|
272
|
+
/** Poll interval, e.g. "60s". Default: 60s. */
|
|
273
|
+
interval?: string;
|
|
274
|
+
/** Hard cap on rows per fetch (default 10000). */
|
|
275
|
+
maxRows?: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ClickhouseSourceConfig {
|
|
279
|
+
type: 'clickhouse';
|
|
280
|
+
/** ClickHouse HTTP/HTTPS endpoint, e.g. `https://host:8443`. Supports `${ENV_VAR}`. */
|
|
281
|
+
url: string;
|
|
282
|
+
/** Username. Supports `${ENV_VAR}`. */
|
|
283
|
+
username?: string;
|
|
284
|
+
/** Password. Supports `${ENV_VAR}`. */
|
|
285
|
+
password?: string;
|
|
286
|
+
/** Database to query. Defaults to `default`. */
|
|
287
|
+
database?: string;
|
|
288
|
+
/** SELECT query polled on every interval. */
|
|
289
|
+
query: string;
|
|
290
|
+
/** Poll interval, e.g. "30s". Default: 30s. */
|
|
291
|
+
interval?: string;
|
|
292
|
+
/** Hard cap on rows per fetch (default 10000). */
|
|
293
|
+
maxRows?: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface MongoSourceConfig {
|
|
297
|
+
type: 'mongodb';
|
|
298
|
+
/** MongoDB connection URI. Supports `${ENV_VAR}`. */
|
|
299
|
+
url: string;
|
|
300
|
+
/** Database name. Supports `${ENV_VAR}`. */
|
|
301
|
+
database: string;
|
|
302
|
+
/** Collection to read from. */
|
|
303
|
+
collection: string;
|
|
304
|
+
/**
|
|
305
|
+
* Optional default filter applied on every poll (Mongo `$match` stage).
|
|
306
|
+
* Plain JSON object — `{ active: true }` etc. The filter is `AND`-merged
|
|
307
|
+
* with any push-down `where()` predicates the engine compiles in.
|
|
308
|
+
*/
|
|
309
|
+
filter?: Record<string, unknown>;
|
|
310
|
+
/** Poll interval, e.g. "30s". Default: 30s. */
|
|
311
|
+
interval?: string;
|
|
312
|
+
/** Hard cap on documents per fetch (default 10000). */
|
|
313
|
+
maxRows?: number;
|
|
314
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -71,7 +71,7 @@ source
|
|
|
71
71
|
source
|
|
72
72
|
.command('add <name>')
|
|
73
73
|
.description('Add a data source')
|
|
74
|
-
.requiredOption('-t, --type <type>', 'Source type: rest, websocket, file, postgres, mysql, sqlite, kafka')
|
|
74
|
+
.requiredOption('-t, --type <type>', 'Source type: rest, websocket, file, postgres, mysql, sqlite, kafka, snowflake, clickhouse, mongodb')
|
|
75
75
|
.option('-u, --url <url>', 'Connection URL for REST / WebSocket / Postgres / MySQL sources')
|
|
76
76
|
.option('-i, --interval <interval>', 'Poll interval (e.g., 30s, 5m)', '30s')
|
|
77
77
|
.option('-d, --data-path <path>', 'Dot-path to extract data from REST response')
|
|
@@ -185,6 +185,7 @@ telegram
|
|
|
185
185
|
.option('--attach <url>', 'Attach to a running `pq serve` instance instead of loading pipequery.yaml locally')
|
|
186
186
|
.option('--allow-user <handle>', 'Allowlist a user (numeric id or @username); repeatable. With no allowlist, anyone with the bot username can query.', collectRepeated, [])
|
|
187
187
|
.option('--anthropic-key <key>', 'Anthropic API key (or set $ANTHROPIC_API_KEY) — enables natural-language → pipequery translation on plain-text messages via claude-haiku-4-5.')
|
|
188
|
+
.option('--log-file <path>', 'Append every bot event (commands, NL queries, unauthorized attempts) as JSONL to <path>. Stderr always carries a colored, human-readable copy.')
|
|
188
189
|
.action(telegramServeCommand);
|
|
189
190
|
|
|
190
191
|
// ─── pq watch ────────────────────────────────────────────────────────────────
|
package/src/mcp/provider.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { DataContext } from '../engine.js';
|
|
|
2
2
|
import type { EndpointConfig, PipeQueryConfig } from '../config/schema.js';
|
|
3
3
|
import type { SourceStatus } from '../server/sources/types.js';
|
|
4
4
|
import { SourceManager } from '../server/sources/manager.js';
|
|
5
|
-
import { query } from '../engine.js';
|
|
6
5
|
|
|
7
6
|
export interface ProviderSourceInfo {
|
|
8
7
|
name: string;
|
|
@@ -80,14 +79,12 @@ export class LocalProvider implements Provider {
|
|
|
80
79
|
if (!endpoint) {
|
|
81
80
|
throw new Error(`Endpoint "${path}" not found`);
|
|
82
81
|
}
|
|
83
|
-
|
|
84
|
-
return query(context, endpoint.query);
|
|
82
|
+
return this.sourceManager.runQuery(endpoint.query);
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
async runQuery(expression: string): Promise<unknown> {
|
|
88
86
|
await this.ready;
|
|
89
|
-
|
|
90
|
-
return query(context, expression);
|
|
87
|
+
return this.sourceManager.runQuery(expression);
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
async dispose(): Promise<void> {
|
|
@@ -123,11 +120,11 @@ class AttachedLocalProvider implements Provider {
|
|
|
123
120
|
async callEndpoint(path: string): Promise<unknown> {
|
|
124
121
|
const endpoint = this.endpoints.get(path);
|
|
125
122
|
if (!endpoint) throw new Error(`Endpoint "${path}" not found`);
|
|
126
|
-
return
|
|
123
|
+
return this.sourceManager.runQuery(endpoint.query);
|
|
127
124
|
}
|
|
128
125
|
|
|
129
126
|
async runQuery(expression: string): Promise<unknown> {
|
|
130
|
-
return
|
|
127
|
+
return this.sourceManager.runQuery(expression);
|
|
131
128
|
}
|
|
132
129
|
|
|
133
130
|
async dispose(): Promise<void> {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
-
import { query } from '../../engine.js';
|
|
3
2
|
import type { SourceConfig, EndpointConfig } from '../../config/schema.js';
|
|
4
3
|
import type { SourceManager } from '../sources/manager.js';
|
|
5
4
|
|
|
@@ -89,9 +88,8 @@ export function registerControlRoutes(
|
|
|
89
88
|
|
|
90
89
|
app.post<{ Body: { query: string } }>('/api/_control/query', async (req) => {
|
|
91
90
|
const { query: expr } = req.body;
|
|
92
|
-
const context = sourceManager.getContext();
|
|
93
91
|
try {
|
|
94
|
-
const result =
|
|
92
|
+
const result = await sourceManager.runQuery(expr);
|
|
95
93
|
return { ok: true, result };
|
|
96
94
|
} catch (err) {
|
|
97
95
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
-
import { query } from '../../engine.js';
|
|
3
2
|
import type { EndpointConfig } from '../../config/schema.js';
|
|
4
3
|
import type { SourceManager } from '../sources/manager.js';
|
|
5
4
|
import { ResponseCache } from '../cache.js';
|
|
@@ -32,10 +31,9 @@ export function registerDynamicRoutes(
|
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// Execute query
|
|
36
|
-
const context = sourceManager.getContext();
|
|
34
|
+
// Execute query (push-down preferred when the source supports it)
|
|
37
35
|
try {
|
|
38
|
-
const result =
|
|
36
|
+
const result = await sourceManager.runQuery(config.query);
|
|
39
37
|
|
|
40
38
|
if (config.cache) {
|
|
41
39
|
cache.set(path, result, parseDuration(config.cache));
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createClient, type ClickHouseClient } from '@clickhouse/client';
|
|
2
|
+
import type { ClickhouseSourceConfig } from '../../config/schema.js';
|
|
3
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
5
|
+
import { parseQuery } from '../../engine.js';
|
|
6
|
+
import { compileClickhousePushdown } from './pushdown/clickhouse.js';
|
|
7
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
10
|
+
const DEFAULT_MAX_ROWS = 10_000;
|
|
11
|
+
|
|
12
|
+
export class ClickhouseSourceAdapter implements SourceAdapter {
|
|
13
|
+
private client: ClickHouseClient | null = null;
|
|
14
|
+
private data: unknown[] = [];
|
|
15
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
16
|
+
private lastFetch: Date | null = null;
|
|
17
|
+
private error?: string;
|
|
18
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
19
|
+
private readonly intervalMs: number;
|
|
20
|
+
private readonly maxRows: number;
|
|
21
|
+
|
|
22
|
+
constructor(private config: ClickhouseSourceConfig) {
|
|
23
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
24
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start(): Promise<void> {
|
|
28
|
+
this.client = createClient({
|
|
29
|
+
url: expandEnv(this.config.url, 'clickhouse source URL'),
|
|
30
|
+
...(this.config.username
|
|
31
|
+
? { username: expandEnv(this.config.username, 'clickhouse source username') }
|
|
32
|
+
: {}),
|
|
33
|
+
...(this.config.password
|
|
34
|
+
? { password: expandEnv(this.config.password, 'clickhouse source password') }
|
|
35
|
+
: {}),
|
|
36
|
+
...(this.config.database ? { database: this.config.database } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await this.fetch();
|
|
40
|
+
this.timer = setInterval(() => {
|
|
41
|
+
void this.fetch();
|
|
42
|
+
}, this.intervalMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop(): void {
|
|
46
|
+
if (this.timer) {
|
|
47
|
+
clearInterval(this.timer);
|
|
48
|
+
this.timer = null;
|
|
49
|
+
}
|
|
50
|
+
if (this.client) {
|
|
51
|
+
void this.client.close();
|
|
52
|
+
this.client = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getData(): unknown[] {
|
|
57
|
+
return this.data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getStatus(): SourceStatus {
|
|
61
|
+
return {
|
|
62
|
+
healthy: !this.error,
|
|
63
|
+
rowCount: this.data.length,
|
|
64
|
+
lastFetch: this.lastFetch,
|
|
65
|
+
error: this.error,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
70
|
+
this.listeners.add(callback);
|
|
71
|
+
return () => this.listeners.delete(callback);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async refresh(): Promise<void> {
|
|
75
|
+
await this.fetch();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Adapter-native push-down. ClickHouse dialect inlines literals (vs.
|
|
80
|
+
* Postgres / MySQL / Snowflake which bind via placeholders) so the
|
|
81
|
+
* `params` array returned in the success result is always empty;
|
|
82
|
+
* the SQL is fully self-contained.
|
|
83
|
+
*/
|
|
84
|
+
async runPushdown(expression: string): Promise<
|
|
85
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
86
|
+
| { ok: false; reason: string }
|
|
87
|
+
> {
|
|
88
|
+
if (!this.client) {
|
|
89
|
+
return { ok: false, reason: 'ClickHouse client not initialized — call start() first' };
|
|
90
|
+
}
|
|
91
|
+
let pipeline;
|
|
92
|
+
try {
|
|
93
|
+
pipeline = parseQuery(expression);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
96
|
+
}
|
|
97
|
+
const compiled = compileClickhousePushdown(pipeline, this.config.query);
|
|
98
|
+
if (!compiled.ok) {
|
|
99
|
+
return { ok: false, reason: compiled.reason };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const rows = await this.executeSql(compiled.compiled.sql);
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
rows,
|
|
106
|
+
sql: compiled.compiled.sql,
|
|
107
|
+
params: compiled.compiled.params,
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, reason: `clickhouse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async fetch(): Promise<void> {
|
|
115
|
+
if (!this.client) {
|
|
116
|
+
this.error = 'ClickHouse client not initialized';
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
121
|
+
const rows = await this.executeSql(capQuery);
|
|
122
|
+
if (rows.length > this.maxRows) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Query returned more than maxRows=${this.maxRows}. ` +
|
|
125
|
+
`Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
this.data = rows;
|
|
129
|
+
this.lastFetch = new Date();
|
|
130
|
+
this.error = undefined;
|
|
131
|
+
this.notify();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async executeSql(sql: string): Promise<unknown[]> {
|
|
138
|
+
const result = await this.client!.query({
|
|
139
|
+
query: sql,
|
|
140
|
+
format: 'JSONEachRow',
|
|
141
|
+
});
|
|
142
|
+
const rows = await result.json();
|
|
143
|
+
return Array.isArray(rows) ? rows : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private notify(): void {
|
|
147
|
+
for (const cb of this.listeners) cb(this.data);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
152
|
+
return sql.trim().replace(/;+\s*$/, '');
|
|
153
|
+
}
|
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { Kafka, logLevel, type Consumer, type SASLOptions } from 'kafkajs';
|
|
3
3
|
import type { KafkaSourceConfig } from '../../config/schema.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
4
5
|
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_MAX_BUFFER = 1000;
|
|
7
8
|
|
|
8
|
-
function expandEnv(input: string): string {
|
|
9
|
-
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
|
|
10
|
-
const value = process.env[name];
|
|
11
|
-
if (value === undefined) {
|
|
12
|
-
process.stderr.write(
|
|
13
|
-
`[pipequery] env var "${name}" referenced in kafka source is not set\n`,
|
|
14
|
-
);
|
|
15
|
-
return '';
|
|
16
|
-
}
|
|
17
|
-
return value;
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
9
|
function normalizeBrokers(brokers: string | string[]): string[] {
|
|
22
10
|
const list = Array.isArray(brokers) ? brokers : brokers.split(',');
|
|
23
|
-
return list.map((b) => expandEnv(b.trim())).filter(Boolean);
|
|
11
|
+
return list.map((b) => expandEnv(b.trim(), 'kafka source brokers')).filter(Boolean);
|
|
24
12
|
}
|
|
25
13
|
|
|
26
14
|
export class KafkaSourceAdapter implements SourceAdapter {
|
|
@@ -55,8 +43,8 @@ export class KafkaSourceAdapter implements SourceAdapter {
|
|
|
55
43
|
sasl: this.config.sasl
|
|
56
44
|
? ({
|
|
57
45
|
mechanism: this.config.sasl.mechanism,
|
|
58
|
-
username: expandEnv(this.config.sasl.username),
|
|
59
|
-
password: expandEnv(this.config.sasl.password),
|
|
46
|
+
username: expandEnv(this.config.sasl.username, 'kafka source SASL username'),
|
|
47
|
+
password: expandEnv(this.config.sasl.password, 'kafka source SASL password'),
|
|
60
48
|
} as SASLOptions)
|
|
61
49
|
: undefined,
|
|
62
50
|
// Silence kafkajs's own logger — we surface errors via SourceStatus.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SourceConfig } from '../../config/schema.js';
|
|
2
|
-
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
2
|
+
import type { PushdownResult, SourceAdapter, SourceStatus } from './types.js';
|
|
3
3
|
import { RestSourceAdapter } from './rest.js';
|
|
4
4
|
import { WebSocketSourceAdapter } from './websocket.js';
|
|
5
5
|
import { FileSourceAdapter } from './file.js';
|
|
@@ -8,6 +8,10 @@ import { PostgresSourceAdapter } from './postgres.js';
|
|
|
8
8
|
import { MysqlSourceAdapter } from './mysql.js';
|
|
9
9
|
import { SqliteSourceAdapter } from './sqlite.js';
|
|
10
10
|
import { KafkaSourceAdapter } from './kafka.js';
|
|
11
|
+
import { SnowflakeSourceAdapter } from './snowflake.js';
|
|
12
|
+
import { ClickhouseSourceAdapter } from './clickhouse.js';
|
|
13
|
+
import { MongoSourceAdapter } from './mongodb.js';
|
|
14
|
+
import { parseQuery, query } from '../../engine.js';
|
|
11
15
|
|
|
12
16
|
export type DataContext = Record<string, unknown[]>;
|
|
13
17
|
|
|
@@ -98,6 +102,66 @@ export class SourceManager {
|
|
|
98
102
|
);
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Run a pipe expression, preferring adapter-native push-down when the
|
|
107
|
+
* source supports it.
|
|
108
|
+
*
|
|
109
|
+
* Decision tree:
|
|
110
|
+
* 1. Parse the expression. If it can't parse, fall straight through to
|
|
111
|
+
* the in-process engine, which will throw the same parse error a
|
|
112
|
+
* caller would have seen before push-down existed (no behaviour
|
|
113
|
+
* change for malformed input).
|
|
114
|
+
* 2. Look up the source named in the pipeline. If it exists and its
|
|
115
|
+
* adapter exposes `runPushdown`, try it. On `{ ok: true }` we're
|
|
116
|
+
* done — return the rows. On `{ ok: false }` (decline) or any
|
|
117
|
+
* thrown error, fall back to in-process.
|
|
118
|
+
* 3. In-process: query(getContext(), expression) — the existing path.
|
|
119
|
+
*
|
|
120
|
+
* The push-down attempt is best-effort and *invisible to callers*: same
|
|
121
|
+
* return type as `query()`, no new failure modes. Telemetry (which
|
|
122
|
+
* branch ran, latency, source-engine SQL) belongs in the audit log when
|
|
123
|
+
* Phase 5 lands; not threaded through here.
|
|
124
|
+
*/
|
|
125
|
+
async runQuery(expression: string): Promise<unknown> {
|
|
126
|
+
const pushed = await this.tryPushdown(expression);
|
|
127
|
+
if (pushed && pushed.ok) return pushed.rows;
|
|
128
|
+
return query(this.getContext(), expression);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Inspect the pipeline AST and dispatch to an adapter's runPushdown when
|
|
133
|
+
* eligible. Returns `null` when push-down isn't even attempted (no parse,
|
|
134
|
+
* no source, no capability). Returns a PushdownResult when it was tried —
|
|
135
|
+
* `{ ok: true }` for success, `{ ok: false, reason }` for decline.
|
|
136
|
+
*
|
|
137
|
+
* Exposed on the manager (rather than tucked inside runQuery) so the
|
|
138
|
+
* future `event.audit` channel can record `pushed_down: bool` and the
|
|
139
|
+
* `source_engine_query` SQL string for governance. Today only Postgres
|
|
140
|
+
* implements runPushdown; MySQL / SQLite / ClickHouse will follow the
|
|
141
|
+
* same interface.
|
|
142
|
+
*/
|
|
143
|
+
async tryPushdown(expression: string): Promise<PushdownResult | null> {
|
|
144
|
+
let pipeline;
|
|
145
|
+
try {
|
|
146
|
+
pipeline = parseQuery(expression);
|
|
147
|
+
} catch {
|
|
148
|
+
// Bad expression — let the in-process engine raise the canonical error.
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const entry = this.sources.get(pipeline.source);
|
|
152
|
+
if (!entry) return null;
|
|
153
|
+
if (!entry.adapter.runPushdown) return null;
|
|
154
|
+
try {
|
|
155
|
+
return await entry.adapter.runPushdown(expression);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// A throw from runPushdown is treated as a decline: same fallback
|
|
158
|
+
// path as `{ ok: false }`. We don't surface the thrown error because
|
|
159
|
+
// the in-process retry will produce a more useful one (or succeed).
|
|
160
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return { ok: false, reason: `pushdown threw: ${reason}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
async dispose(): Promise<void> {
|
|
102
166
|
this.globalListeners.clear();
|
|
103
167
|
for (const entry of this.sources.values()) {
|
|
@@ -125,6 +189,12 @@ export class SourceManager {
|
|
|
125
189
|
return new SqliteSourceAdapter(config, this.cwd);
|
|
126
190
|
case 'kafka':
|
|
127
191
|
return new KafkaSourceAdapter(config);
|
|
192
|
+
case 'snowflake':
|
|
193
|
+
return new SnowflakeSourceAdapter(config);
|
|
194
|
+
case 'clickhouse':
|
|
195
|
+
return new ClickhouseSourceAdapter(config);
|
|
196
|
+
case 'mongodb':
|
|
197
|
+
return new MongoSourceAdapter(config);
|
|
128
198
|
default:
|
|
129
199
|
throw new Error(`Unknown source type: ${(config as { type: string }).type}`);
|
|
130
200
|
}
|