@vaultgradient/pipequery-cli 0.7.0 → 0.10.1

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 (35) 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__/snowflake-pushdown-compile.test.ts +74 -0
  11. package/dist/index.js +3642 -2172
  12. package/package.json +12 -2
  13. package/src/commands/telegram.ts +10 -0
  14. package/src/config/schema.ts +68 -1
  15. package/src/index.ts +2 -1
  16. package/src/mcp/provider.ts +4 -7
  17. package/src/server/routes/control.ts +1 -3
  18. package/src/server/routes/dynamic.ts +2 -4
  19. package/src/server/sources/clickhouse.ts +153 -0
  20. package/src/server/sources/manager.ts +71 -1
  21. package/src/server/sources/mongodb.ts +165 -0
  22. package/src/server/sources/mysql.ts +44 -0
  23. package/src/server/sources/postgres.ts +50 -0
  24. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  25. package/src/server/sources/pushdown/compile.ts +328 -0
  26. package/src/server/sources/pushdown/dialect.ts +88 -0
  27. package/src/server/sources/pushdown/mongodb.ts +450 -0
  28. package/src/server/sources/pushdown/mysql.ts +17 -0
  29. package/src/server/sources/pushdown/postgres.ts +19 -0
  30. package/src/server/sources/pushdown/snowflake.ts +17 -0
  31. package/src/server/sources/snowflake.ts +158 -0
  32. package/src/server/sources/types.ts +27 -0
  33. package/src/telegram/bot.ts +198 -12
  34. package/src/telegram/event-log.ts +234 -0
  35. package/tsup.config.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.7.0",
3
+ "version": "0.10.1",
4
4
  "description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,11 +9,13 @@
9
9
  "scripts": {
10
10
  "build": "tsup",
11
11
  "dev": "tsup --watch",
12
+ "typecheck": "tsc",
12
13
  "test": "vitest run",
13
14
  "test:watch": "vitest"
14
15
  },
15
16
  "dependencies": {
16
17
  "@anthropic-ai/sdk": "^0.91.1",
18
+ "@clickhouse/client": "^1.18.3",
17
19
  "@fastify/cors": "^11.2.0",
18
20
  "@modelcontextprotocol/sdk": "^1.29.0",
19
21
  "better-sqlite3": "^12.9.0",
@@ -25,10 +27,12 @@
25
27
  "grammy": "^1.42.0",
26
28
  "ink": "^7.0.1",
27
29
  "kafkajs": "^2.2.4",
30
+ "mongodb": "^7.2.0",
28
31
  "mysql2": "^3.22.2",
29
32
  "ora": "^9.3.0",
30
33
  "pg": "^8.20.0",
31
34
  "react": "^19.2.5",
35
+ "snowflake-sdk": "^2.4.0",
32
36
  "ws": "^8.20.0",
33
37
  "yaml": "^2.8.3",
34
38
  "zod": "^4.3.6"
@@ -43,11 +47,17 @@
43
47
  "typescript": "~6.0.0",
44
48
  "vitest": "^4.1.4"
45
49
  },
50
+ "overrides": {
51
+ "gcp-metadata": "^8.0.0"
52
+ },
46
53
  "repository": {
47
54
  "type": "git",
48
55
  "url": "https://github.com/andreadito/pipequery.git",
49
56
  "directory": "cli"
50
57
  },
51
58
  "author": "andreadito",
52
- "license": "MIT"
59
+ "license": "MIT",
60
+ "engines": {
61
+ "node": ">=24"
62
+ }
53
63
  }
@@ -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';
@@ -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 ────────────────────────────────────────────────────────────────
@@ -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,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
+ }