@vaultgradient/pipequery-cli 0.3.3 → 0.4.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,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
+ }