@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
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';
|
|
@@ -245,3 +248,67 @@ export interface TelegramNotifyConfig {
|
|
|
245
248
|
*/
|
|
246
249
|
message?: string;
|
|
247
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,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
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { MongoClient, type Db, type Collection } from 'mongodb';
|
|
2
|
+
import type { MongoSourceConfig } 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 { compileMongoPushdown, type MongoPlan } from './pushdown/mongodb.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 MongoSourceAdapter implements SourceAdapter {
|
|
13
|
+
private client: MongoClient | null = null;
|
|
14
|
+
private db: Db | null = null;
|
|
15
|
+
private collection: Collection | null = null;
|
|
16
|
+
private data: unknown[] = [];
|
|
17
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
18
|
+
private lastFetch: Date | null = null;
|
|
19
|
+
private error?: string;
|
|
20
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
21
|
+
private readonly intervalMs: number;
|
|
22
|
+
private readonly maxRows: number;
|
|
23
|
+
|
|
24
|
+
constructor(private config: MongoSourceConfig) {
|
|
25
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
26
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async start(): Promise<void> {
|
|
30
|
+
const uri = expandEnv(this.config.url, 'mongodb source URL');
|
|
31
|
+
this.client = new MongoClient(uri);
|
|
32
|
+
await this.client.connect();
|
|
33
|
+
this.db = this.client.db(expandEnv(this.config.database, 'mongodb source database'));
|
|
34
|
+
this.collection = this.db.collection(this.config.collection);
|
|
35
|
+
|
|
36
|
+
await this.fetch();
|
|
37
|
+
this.timer = setInterval(() => {
|
|
38
|
+
void this.fetch();
|
|
39
|
+
}, this.intervalMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
stop(): void {
|
|
43
|
+
if (this.timer) {
|
|
44
|
+
clearInterval(this.timer);
|
|
45
|
+
this.timer = null;
|
|
46
|
+
}
|
|
47
|
+
if (this.client) {
|
|
48
|
+
void this.client.close();
|
|
49
|
+
this.client = null;
|
|
50
|
+
this.db = null;
|
|
51
|
+
this.collection = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getData(): unknown[] {
|
|
56
|
+
return this.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getStatus(): SourceStatus {
|
|
60
|
+
return {
|
|
61
|
+
healthy: !this.error,
|
|
62
|
+
rowCount: this.data.length,
|
|
63
|
+
lastFetch: this.lastFetch,
|
|
64
|
+
error: this.error,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
69
|
+
this.listeners.add(callback);
|
|
70
|
+
return () => this.listeners.delete(callback);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async refresh(): Promise<void> {
|
|
74
|
+
await this.fetch();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Adapter-native push-down to MongoDB. The compiler emits either a
|
|
79
|
+
* `find()` plan (when the pipeline only uses where/sort/first/select)
|
|
80
|
+
* or an `aggregate()` pipeline (when grouping is involved).
|
|
81
|
+
*
|
|
82
|
+
* The success result's `sql` field carries a JSON serialization of the
|
|
83
|
+
* compiled plan (Mongo doesn't have SQL); `params` is empty since
|
|
84
|
+
* Mongo plans bind values inline as part of the BSON document.
|
|
85
|
+
*/
|
|
86
|
+
async runPushdown(expression: string): Promise<
|
|
87
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
88
|
+
| { ok: false; reason: string }
|
|
89
|
+
> {
|
|
90
|
+
if (!this.collection) {
|
|
91
|
+
return { ok: false, reason: 'Mongo collection not initialized — call start() first' };
|
|
92
|
+
}
|
|
93
|
+
let pipeline;
|
|
94
|
+
try {
|
|
95
|
+
pipeline = parseQuery(expression);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
98
|
+
}
|
|
99
|
+
const compiled = compileMongoPushdown(pipeline, this.config.filter);
|
|
100
|
+
if (!compiled.ok) {
|
|
101
|
+
return { ok: false, reason: compiled.reason };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const rows = await this.runPlan(compiled.compiled);
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
rows,
|
|
108
|
+
sql: JSON.stringify(compiled.compiled),
|
|
109
|
+
params: [],
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { ok: false, reason: `mongo error: ${err instanceof Error ? err.message : String(err)}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async runPlan(plan: MongoPlan): Promise<unknown[]> {
|
|
117
|
+
const coll = this.collection!;
|
|
118
|
+
if (plan.kind === 'find') {
|
|
119
|
+
let cursor = coll.find(plan.filter);
|
|
120
|
+
if (plan.projection) cursor = cursor.project(plan.projection);
|
|
121
|
+
if (plan.sort) cursor = cursor.sort(plan.sort);
|
|
122
|
+
if (plan.limit !== undefined) cursor = cursor.limit(plan.limit);
|
|
123
|
+
return cursor.toArray();
|
|
124
|
+
}
|
|
125
|
+
// aggregate
|
|
126
|
+
return coll.aggregate(plan.pipeline).toArray();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async fetch(): Promise<void> {
|
|
130
|
+
if (!this.collection) {
|
|
131
|
+
this.error = 'Mongo collection not initialized';
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
// Apply the optional default filter (yaml-level) plus the maxRows cap.
|
|
136
|
+
const filter = this.config.filter ?? {};
|
|
137
|
+
const docs = await this.collection
|
|
138
|
+
.find(filter)
|
|
139
|
+
.limit(this.maxRows + 1)
|
|
140
|
+
.toArray();
|
|
141
|
+
if (docs.length > this.maxRows) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Query returned more than maxRows=${this.maxRows}. ` +
|
|
144
|
+
`Tighten the filter, or raise maxRows in the source config.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
// Strip Mongo's BSON _id by default — most pipequery users don't want it
|
|
148
|
+
// surfacing in result rows. The original is still queryable in source
|
|
149
|
+
// queries that explicitly project it.
|
|
150
|
+
this.data = docs.map((d) => {
|
|
151
|
+
const { _id, ...rest } = d as Record<string, unknown>;
|
|
152
|
+
return rest;
|
|
153
|
+
});
|
|
154
|
+
this.lastFetch = new Date();
|
|
155
|
+
this.error = undefined;
|
|
156
|
+
this.notify();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private notify(): void {
|
|
163
|
+
for (const cb of this.listeners) cb(this.data);
|
|
164
|
+
}
|
|
165
|
+
}
|