@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.6.1",
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
  }
@@ -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
  }
@@ -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 ────────────────────────────────────────────────────────────────
@@ -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
- const context = this.sourceManager.getContext();
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
- const context = this.sourceManager.getContext();
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 query(this.sourceManager.getContext(), endpoint.query);
123
+ return this.sourceManager.runQuery(endpoint.query);
127
124
  }
128
125
 
129
126
  async runQuery(expression: string): Promise<unknown> {
130
- return query(this.sourceManager.getContext(), expression);
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 = query(context, expr);
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 = query(context, config.query);
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
  }