@vaultgradient/pipequery-cli 0.3.3 → 0.6.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.
@@ -0,0 +1,166 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { Kafka, logLevel, type Consumer, type SASLOptions } from 'kafkajs';
3
+ import type { KafkaSourceConfig } from '../../config/schema.js';
4
+ import type { SourceAdapter, SourceStatus } from './types.js';
5
+
6
+ const DEFAULT_MAX_BUFFER = 1000;
7
+
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
+ function normalizeBrokers(brokers: string | string[]): string[] {
22
+ const list = Array.isArray(brokers) ? brokers : brokers.split(',');
23
+ return list.map((b) => expandEnv(b.trim())).filter(Boolean);
24
+ }
25
+
26
+ export class KafkaSourceAdapter implements SourceAdapter {
27
+ private data: unknown[] = [];
28
+ private kafka: Kafka | null = null;
29
+ private consumer: Consumer | null = null;
30
+ private lastFetch: Date | null = null;
31
+ private error?: string;
32
+ private connected = false;
33
+ private listeners = new Set<(data: unknown[]) => void>();
34
+ private readonly maxBuffer: number;
35
+ private readonly groupId: string;
36
+
37
+ constructor(private config: KafkaSourceConfig) {
38
+ this.maxBuffer = config.maxBuffer ?? DEFAULT_MAX_BUFFER;
39
+ // Auto-generate a per-process consumer group by default so each pipequery
40
+ // instance sees the full firehose. Users who want load-balanced consumption
41
+ // across replicas can set groupId explicitly in the yaml.
42
+ this.groupId = config.groupId ?? `pipequery-${randomBytes(6).toString('hex')}`;
43
+ }
44
+
45
+ async start(): Promise<void> {
46
+ const brokers = normalizeBrokers(this.config.brokers);
47
+ if (brokers.length === 0) {
48
+ throw new Error('Kafka source has no brokers configured');
49
+ }
50
+
51
+ this.kafka = new Kafka({
52
+ clientId: `pipequery-${this.config.topic}`,
53
+ brokers,
54
+ ssl: this.config.ssl ?? false,
55
+ sasl: this.config.sasl
56
+ ? ({
57
+ mechanism: this.config.sasl.mechanism,
58
+ username: expandEnv(this.config.sasl.username),
59
+ password: expandEnv(this.config.sasl.password),
60
+ } as SASLOptions)
61
+ : undefined,
62
+ // Silence kafkajs's own logger — we surface errors via SourceStatus.
63
+ logLevel: logLevel.ERROR,
64
+ });
65
+
66
+ this.consumer = this.kafka.consumer({ groupId: this.groupId });
67
+ await this.consumer.connect();
68
+ await this.consumer.subscribe({
69
+ topic: this.config.topic,
70
+ fromBeginning: this.config.fromBeginning ?? false,
71
+ });
72
+ this.connected = true;
73
+ this.error = undefined;
74
+
75
+ // Fire-and-forget; kafkajs's run() resolves immediately and keeps running
76
+ // in the background until disconnect().
77
+ void this.consumer.run({
78
+ eachMessage: async ({ message, partition }) => {
79
+ try {
80
+ const row = this.decode(message.value, message.key, message.timestamp, partition, message.offset);
81
+ this.data.push(row);
82
+ if (this.data.length > this.maxBuffer) {
83
+ this.data = this.data.slice(-this.maxBuffer);
84
+ }
85
+ this.lastFetch = new Date();
86
+ this.error = undefined;
87
+ this.notify();
88
+ } catch {
89
+ // Skip malformed messages rather than crashing the consumer.
90
+ }
91
+ },
92
+ });
93
+ }
94
+
95
+ stop(): void {
96
+ this.connected = false;
97
+ if (this.consumer) {
98
+ const consumer = this.consumer;
99
+ this.consumer = null;
100
+ // Fire-and-forget disconnect; SourceAdapter.stop() is sync, matching
101
+ // the rest of the adapters.
102
+ void consumer.disconnect().catch(() => {
103
+ // Swallow — we're shutting down anyway.
104
+ });
105
+ }
106
+ this.kafka = null;
107
+ }
108
+
109
+ getData(): unknown[] {
110
+ return this.data;
111
+ }
112
+
113
+ getStatus(): SourceStatus {
114
+ return {
115
+ healthy: this.connected && !this.error,
116
+ rowCount: this.data.length,
117
+ lastFetch: this.lastFetch,
118
+ error: this.error,
119
+ };
120
+ }
121
+
122
+ onUpdate(callback: (data: unknown[]) => void): () => void {
123
+ this.listeners.add(callback);
124
+ return () => this.listeners.delete(callback);
125
+ }
126
+
127
+ private decode(
128
+ value: Buffer | null,
129
+ key: Buffer | null,
130
+ timestamp: string,
131
+ partition: number,
132
+ offset: string,
133
+ ): Record<string, unknown> {
134
+ const base: Record<string, unknown> = {
135
+ _kafka_topic: this.config.topic,
136
+ _kafka_partition: partition,
137
+ _kafka_offset: offset,
138
+ _kafka_timestamp: Number.parseInt(timestamp, 10),
139
+ _kafka_key: key ? key.toString('utf8') : null,
140
+ };
141
+
142
+ if (!value) {
143
+ return { ...base, value: null };
144
+ }
145
+
146
+ const format = this.config.valueFormat ?? 'json';
147
+ if (format === 'string') {
148
+ return { ...base, value: value.toString('utf8') };
149
+ }
150
+ if (format === 'raw') {
151
+ return { ...base, value: value.toString('base64') };
152
+ }
153
+ // json
154
+ const decoded = JSON.parse(value.toString('utf8')) as unknown;
155
+ if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
156
+ // Spread the message body into the top-level row so queries can reference
157
+ // fields directly (e.g. `events | where(event_type == 'order.paid')`).
158
+ return { ...base, ...(decoded as Record<string, unknown>) };
159
+ }
160
+ return { ...base, value: decoded };
161
+ }
162
+
163
+ private notify(): void {
164
+ for (const cb of this.listeners) cb(this.data);
165
+ }
166
+ }
@@ -4,6 +4,10 @@ import { RestSourceAdapter } from './rest.js';
4
4
  import { WebSocketSourceAdapter } from './websocket.js';
5
5
  import { FileSourceAdapter } from './file.js';
6
6
  import { StaticSourceAdapter } from './static.js';
7
+ import { PostgresSourceAdapter } from './postgres.js';
8
+ import { MysqlSourceAdapter } from './mysql.js';
9
+ import { SqliteSourceAdapter } from './sqlite.js';
10
+ import { KafkaSourceAdapter } from './kafka.js';
7
11
 
8
12
  export type DataContext = Record<string, unknown[]>;
9
13
 
@@ -113,6 +117,14 @@ export class SourceManager {
113
117
  return new FileSourceAdapter(config, this.cwd);
114
118
  case 'static':
115
119
  return new StaticSourceAdapter(config);
120
+ case 'postgres':
121
+ return new PostgresSourceAdapter(config);
122
+ case 'mysql':
123
+ return new MysqlSourceAdapter(config);
124
+ case 'sqlite':
125
+ return new SqliteSourceAdapter(config, this.cwd);
126
+ case 'kafka':
127
+ return new KafkaSourceAdapter(config);
116
128
  default:
117
129
  throw new Error(`Unknown source type: ${(config as { type: string }).type}`);
118
130
  }
@@ -0,0 +1,128 @@
1
+ import mysql from 'mysql2/promise';
2
+ import type { MysqlSourceConfig } from '../../config/schema.js';
3
+ import { parseDuration } from '../../utils/parseDuration.js';
4
+ import type { SourceAdapter, SourceStatus } from './types.js';
5
+
6
+ const DEFAULT_INTERVAL_MS = 30_000;
7
+ const DEFAULT_MAX_ROWS = 10_000;
8
+
9
+ function expandEnv(input: string): string {
10
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
11
+ const value = process.env[name];
12
+ if (value === undefined) {
13
+ process.stderr.write(
14
+ `[pipequery] env var "${name}" referenced in mysql source URL is not set\n`,
15
+ );
16
+ return '';
17
+ }
18
+ return value;
19
+ });
20
+ }
21
+
22
+ function resolveSsl(ssl: MysqlSourceConfig['ssl']): mysql.PoolOptions['ssl'] {
23
+ if (ssl === false) return undefined;
24
+ if (ssl === 'no-verify') return { rejectUnauthorized: false };
25
+ return { rejectUnauthorized: true };
26
+ }
27
+
28
+ export class MysqlSourceAdapter implements SourceAdapter {
29
+ private pool: mysql.Pool | null = null;
30
+ private data: unknown[] = [];
31
+ private timer: ReturnType<typeof setInterval> | null = null;
32
+ private lastFetch: Date | null = null;
33
+ private error?: string;
34
+ private listeners = new Set<(data: unknown[]) => void>();
35
+ private readonly intervalMs: number;
36
+ private readonly maxRows: number;
37
+
38
+ constructor(private config: MysqlSourceConfig) {
39
+ this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
40
+ this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
41
+ }
42
+
43
+ async start(): Promise<void> {
44
+ const uri = expandEnv(this.config.url);
45
+ this.pool = mysql.createPool({
46
+ uri,
47
+ ssl: resolveSsl(this.config.ssl),
48
+ connectionLimit: 2,
49
+ waitForConnections: true,
50
+ // mysql2 returns BigInt for large integers by default — stringify so the
51
+ // data serializes cleanly to JSON (which can't encode BigInt).
52
+ supportBigNumbers: true,
53
+ bigNumberStrings: true,
54
+ });
55
+
56
+ await this.fetch();
57
+ this.timer = setInterval(() => {
58
+ void this.fetch();
59
+ }, this.intervalMs);
60
+ }
61
+
62
+ stop(): void {
63
+ if (this.timer) {
64
+ clearInterval(this.timer);
65
+ this.timer = null;
66
+ }
67
+ if (this.pool) {
68
+ void this.pool.end();
69
+ this.pool = null;
70
+ }
71
+ }
72
+
73
+ getData(): unknown[] {
74
+ return this.data;
75
+ }
76
+
77
+ getStatus(): SourceStatus {
78
+ return {
79
+ healthy: !this.error,
80
+ rowCount: this.data.length,
81
+ lastFetch: this.lastFetch,
82
+ error: this.error,
83
+ };
84
+ }
85
+
86
+ onUpdate(callback: (data: unknown[]) => void): () => void {
87
+ this.listeners.add(callback);
88
+ return () => this.listeners.delete(callback);
89
+ }
90
+
91
+ async refresh(): Promise<void> {
92
+ await this.fetch();
93
+ }
94
+
95
+ private async fetch(): Promise<void> {
96
+ if (!this.pool) {
97
+ this.error = 'MySQL pool not initialized';
98
+ return;
99
+ }
100
+ try {
101
+ // Same safety-cap pattern as Postgres: wrap the user's query and LIMIT
102
+ // to maxRows+1 so we can detect overflow without reading unbounded rows.
103
+ const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
104
+ const [rows] = await this.pool.query(capQuery);
105
+ const asArray = Array.isArray(rows) ? (rows as unknown[]) : [];
106
+ if (asArray.length > this.maxRows) {
107
+ throw new Error(
108
+ `Query returned more than maxRows=${this.maxRows}. ` +
109
+ `Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
110
+ );
111
+ }
112
+ this.data = asArray;
113
+ this.lastFetch = new Date();
114
+ this.error = undefined;
115
+ this.notify();
116
+ } catch (err) {
117
+ this.error = err instanceof Error ? err.message : String(err);
118
+ }
119
+ }
120
+
121
+ private notify(): void {
122
+ for (const cb of this.listeners) cb(this.data);
123
+ }
124
+ }
125
+
126
+ function stripTrailingSemicolon(sql: string): string {
127
+ return sql.trim().replace(/;+\s*$/, '');
128
+ }
@@ -0,0 +1,139 @@
1
+ import pg from 'pg';
2
+ import type { PostgresSourceConfig } from '../../config/schema.js';
3
+ import { parseDuration } from '../../utils/parseDuration.js';
4
+ import type { SourceAdapter, SourceStatus } from './types.js';
5
+
6
+ const DEFAULT_INTERVAL_MS = 30_000;
7
+ const DEFAULT_MAX_ROWS = 10_000;
8
+
9
+ /**
10
+ * Resolve ${ENV_VAR} placeholders in a string from process.env.
11
+ * Missing vars expand to empty string (with a stderr warning) so a
12
+ * mis-typed env doesn't silently embed literal "${FOO}" into a
13
+ * Postgres connection string.
14
+ */
15
+ function expandEnv(input: string): string {
16
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
17
+ const value = process.env[name];
18
+ if (value === undefined) {
19
+ process.stderr.write(
20
+ `[pipequery] env var "${name}" referenced in postgres source URL is not set\n`,
21
+ );
22
+ return '';
23
+ }
24
+ return value;
25
+ });
26
+ }
27
+
28
+ function resolveSsl(ssl: PostgresSourceConfig['ssl']): pg.ClientConfig['ssl'] {
29
+ if (ssl === false) return false;
30
+ if (ssl === 'no-verify') return { rejectUnauthorized: false };
31
+ // 'require' (default) — verify certificates.
32
+ return { rejectUnauthorized: true };
33
+ }
34
+
35
+ export class PostgresSourceAdapter implements SourceAdapter {
36
+ private pool: pg.Pool | null = null;
37
+ private data: unknown[] = [];
38
+ private timer: ReturnType<typeof setInterval> | null = null;
39
+ private lastFetch: Date | null = null;
40
+ private error?: string;
41
+ private listeners = new Set<(data: unknown[]) => void>();
42
+ private readonly intervalMs: number;
43
+ private readonly maxRows: number;
44
+
45
+ constructor(private config: PostgresSourceConfig) {
46
+ this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
47
+ this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
48
+ }
49
+
50
+ async start(): Promise<void> {
51
+ const connectionString = expandEnv(this.config.url);
52
+ this.pool = new pg.Pool({
53
+ connectionString,
54
+ ssl: resolveSsl(this.config.ssl),
55
+ // Small pool — we're read-only polling, not a transactional app.
56
+ min: 0,
57
+ max: 2,
58
+ idleTimeoutMillis: 10_000,
59
+ });
60
+
61
+ // Proactively surface connection errors (e.g. bad creds) on stderr
62
+ // so the user doesn't hit silent `healthy: false` with no explanation.
63
+ this.pool.on('error', (err) => {
64
+ this.error = err.message;
65
+ });
66
+
67
+ await this.fetch();
68
+ this.timer = setInterval(() => {
69
+ void this.fetch();
70
+ }, this.intervalMs);
71
+ }
72
+
73
+ stop(): void {
74
+ if (this.timer) {
75
+ clearInterval(this.timer);
76
+ this.timer = null;
77
+ }
78
+ // Fire-and-forget pool shutdown; callers don't await stop().
79
+ if (this.pool) {
80
+ void this.pool.end();
81
+ this.pool = null;
82
+ }
83
+ }
84
+
85
+ getData(): unknown[] {
86
+ return this.data;
87
+ }
88
+
89
+ getStatus(): SourceStatus {
90
+ return {
91
+ healthy: !this.error,
92
+ rowCount: this.data.length,
93
+ lastFetch: this.lastFetch,
94
+ error: this.error,
95
+ };
96
+ }
97
+
98
+ onUpdate(callback: (data: unknown[]) => void): () => void {
99
+ this.listeners.add(callback);
100
+ return () => this.listeners.delete(callback);
101
+ }
102
+
103
+ async refresh(): Promise<void> {
104
+ await this.fetch();
105
+ }
106
+
107
+ private async fetch(): Promise<void> {
108
+ if (!this.pool) {
109
+ this.error = 'Postgres pool not initialized';
110
+ return;
111
+ }
112
+ try {
113
+ // Safety cap: wrap the user's query so a runaway SELECT doesn't
114
+ // eat the process. We add LIMIT maxRows+1 to detect overflow.
115
+ const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
116
+ const res = await this.pool.query(capQuery);
117
+ if (res.rows.length > this.maxRows) {
118
+ throw new Error(
119
+ `Query returned more than maxRows=${this.maxRows}. ` +
120
+ `Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
121
+ );
122
+ }
123
+ this.data = res.rows;
124
+ this.lastFetch = new Date();
125
+ this.error = undefined;
126
+ this.notify();
127
+ } catch (err) {
128
+ this.error = err instanceof Error ? err.message : String(err);
129
+ }
130
+ }
131
+
132
+ private notify(): void {
133
+ for (const cb of this.listeners) cb(this.data);
134
+ }
135
+ }
136
+
137
+ function stripTrailingSemicolon(sql: string): string {
138
+ return sql.trim().replace(/;+\s*$/, '');
139
+ }
@@ -0,0 +1,114 @@
1
+ import { resolve } from 'node:path';
2
+ import Database from 'better-sqlite3';
3
+ import type { SqliteSourceConfig } from '../../config/schema.js';
4
+ import { parseDuration } from '../../utils/parseDuration.js';
5
+ import type { SourceAdapter, SourceStatus } from './types.js';
6
+
7
+ const DEFAULT_INTERVAL_MS = 30_000;
8
+ const DEFAULT_MAX_ROWS = 10_000;
9
+ const IN_MEMORY = ':memory:';
10
+
11
+ export class SqliteSourceAdapter implements SourceAdapter {
12
+ private db: Database.Database | null = null;
13
+ private data: unknown[] = [];
14
+ private timer: ReturnType<typeof setInterval> | null = null;
15
+ private lastFetch: Date | null = null;
16
+ private error?: string;
17
+ private listeners = new Set<(data: unknown[]) => void>();
18
+ private readonly intervalMs: number;
19
+ private readonly maxRows: number;
20
+ private readonly resolvedPath: string;
21
+ private readonly readonly: boolean;
22
+
23
+ constructor(private config: SqliteSourceConfig, cwd: string) {
24
+ this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
25
+ this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
26
+ this.readonly = config.readonly ?? true;
27
+ this.resolvedPath = config.path === IN_MEMORY ? IN_MEMORY : resolve(cwd, config.path);
28
+ }
29
+
30
+ async start(): Promise<void> {
31
+ // In-memory databases can't be read-only and don't share state across
32
+ // connections, so the readonly flag is silently ignored for :memory:.
33
+ const opts: Database.Options =
34
+ this.resolvedPath === IN_MEMORY ? {} : { readonly: this.readonly, fileMustExist: this.readonly };
35
+ this.db = new Database(this.resolvedPath, opts);
36
+ // WAL makes concurrent readers/writers safer when the DB is another
37
+ // process's production file — no effect for :memory:.
38
+ if (this.resolvedPath !== IN_MEMORY && !this.readonly) {
39
+ this.db.pragma('journal_mode = WAL');
40
+ }
41
+
42
+ await this.fetch();
43
+ this.timer = setInterval(() => {
44
+ void this.fetch();
45
+ }, this.intervalMs);
46
+ }
47
+
48
+ stop(): void {
49
+ if (this.timer) {
50
+ clearInterval(this.timer);
51
+ this.timer = null;
52
+ }
53
+ if (this.db) {
54
+ this.db.close();
55
+ this.db = null;
56
+ }
57
+ }
58
+
59
+ getData(): unknown[] {
60
+ return this.data;
61
+ }
62
+
63
+ getStatus(): SourceStatus {
64
+ return {
65
+ healthy: !this.error,
66
+ rowCount: this.data.length,
67
+ lastFetch: this.lastFetch,
68
+ error: this.error,
69
+ };
70
+ }
71
+
72
+ onUpdate(callback: (data: unknown[]) => void): () => void {
73
+ this.listeners.add(callback);
74
+ return () => this.listeners.delete(callback);
75
+ }
76
+
77
+ async refresh(): Promise<void> {
78
+ await this.fetch();
79
+ }
80
+
81
+ private async fetch(): Promise<void> {
82
+ if (!this.db) {
83
+ this.error = 'SQLite database not initialized';
84
+ return;
85
+ }
86
+ try {
87
+ // Same safety-cap pattern as the other DB adapters.
88
+ const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
89
+ const rows = this.db.prepare(capQuery).all();
90
+ if (rows.length > this.maxRows) {
91
+ throw new Error(
92
+ `Query returned more than maxRows=${this.maxRows}. ` +
93
+ `Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
94
+ );
95
+ }
96
+ // better-sqlite3 returns bigints only if defaultSafeIntegers is on;
97
+ // default mapping is already JSON-serializable (numbers, strings, Buffer).
98
+ this.data = rows;
99
+ this.lastFetch = new Date();
100
+ this.error = undefined;
101
+ this.notify();
102
+ } catch (err) {
103
+ this.error = err instanceof Error ? err.message : String(err);
104
+ }
105
+ }
106
+
107
+ private notify(): void {
108
+ for (const cb of this.listeners) cb(this.data);
109
+ }
110
+ }
111
+
112
+ function stripTrailingSemicolon(sql: string): string {
113
+ return sql.trim().replace(/;+\s*$/, '');
114
+ }