@sun-asterisk/sungen 3.2.10 → 3.2.11
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/dist/harness/query-catalog.d.ts +32 -2
- package/dist/harness/query-catalog.d.ts.map +1 -1
- package/dist/harness/query-catalog.js +0 -0
- package/dist/harness/query-catalog.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +15 -5
- package/dist/orchestrator/templates/specs-db.d.ts +201 -11
- package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-db.js +440 -91
- package/dist/orchestrator/templates/specs-db.js.map +1 -1
- package/dist/orchestrator/templates/specs-db.ts +463 -79
- package/dist/orchestrator/templates/specs-test-data.ts +39 -3
- package/package.json +7 -4
- package/src/harness/query-catalog.ts +0 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +15 -5
- package/src/orchestrator/templates/specs-db.ts +463 -79
- package/src/orchestrator/templates/specs-test-data.ts +39 -3
|
@@ -21,9 +21,13 @@ const ident = (s: string): string => {
|
|
|
21
21
|
return s;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// SQL engines: verified via the direct guarded-SELECT path and needing the `$n`→`?` placeholder
|
|
25
|
+
// rewrite. This literal is the single source of truth for that rewrite condition and is quoted, by
|
|
26
|
+
// name, in the sungen-gherkin-syntax skill's "direct SQL verification" note — keep them in sync.
|
|
26
27
|
const SUPPORTED_ENGINES = ['postgres', 'sqlite', 'mysql'] as const;
|
|
28
|
+
// NoSQL engines: implement the Engine contract natively (no SQL rewrite). Tracked separately so the
|
|
29
|
+
// SQL-rewrite guard and its skill-parity test stay pinned to the three SQL engines above.
|
|
30
|
+
const NOSQL_ENGINES = ['cosmos', 'mongodb', 'dynamodb'] as const;
|
|
27
31
|
|
|
28
32
|
interface SshConfig {
|
|
29
33
|
host: string; // jump host reachable from the runner
|
|
@@ -36,7 +40,7 @@ interface SshConfig {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
interface DataSourceConfig {
|
|
39
|
-
engine: 'postgres' | 'mysql' | 'sqlite';
|
|
43
|
+
engine: 'postgres' | 'mysql' | 'sqlite' | 'cosmos' | 'mongodb' | 'dynamodb';
|
|
40
44
|
url: string;
|
|
41
45
|
readonly?: boolean;
|
|
42
46
|
statement_timeout_ms?: number;
|
|
@@ -44,6 +48,17 @@ interface DataSourceConfig {
|
|
|
44
48
|
// Fallback path: tunnel the DB SOCKET through an SSH bastion. DB-only — the browser/E2E
|
|
45
49
|
// still run on the runner; only DB traffic crosses. See docs/spec/sungen_data_driver_ssh_tunnel_spec.md.
|
|
46
50
|
ssh?: SshConfig;
|
|
51
|
+
// Cosmos DB (NoSQL API): connect via endpoint+key (or a connection-string `url`); `database` is the
|
|
52
|
+
// Cosmos database, `container` the default container when a step/catalog names no `[table]`.
|
|
53
|
+
endpoint?: string;
|
|
54
|
+
key?: string;
|
|
55
|
+
database?: string;
|
|
56
|
+
container?: string;
|
|
57
|
+
// DynamoDB: no connection URL — `region` + credentials from the AWS env chain (AWS_* / IAM role).
|
|
58
|
+
// `keySchema` maps each table to its key attributes so declarative steps can route GetItem/Query
|
|
59
|
+
// instead of an unaffordable full-table Scan; a non-key filter is refused (use a raw @query SELECT).
|
|
60
|
+
region?: string;
|
|
61
|
+
keySchema?: Record<string, { partitionKey: string; sortKey?: string | null }>;
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
/**
|
|
@@ -125,37 +140,365 @@ function loadConfig(): Record<string, DataSourceConfig> {
|
|
|
125
140
|
}
|
|
126
141
|
|
|
127
142
|
/**
|
|
128
|
-
* `build()` always emits PG-style `$1..$n` placeholders; mysql2/better-sqlite3 both bind with
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
143
|
+
* `build()` always emits PG-style `$1..$n` placeholders; mysql2/better-sqlite3 both bind with
|
|
144
|
+
* positional `?` (left-to-right). Rewriting `$n`→`?` must therefore ALSO reorder the params to the
|
|
145
|
+
* textual placeholder order: an author-written catalog query `WHERE a = $2 AND b = $1` becomes
|
|
146
|
+
* `a = ? AND b = ?`, so the params must be bound `[params[1], params[0]]` — otherwise the values
|
|
147
|
+
* silently swap and the query returns wrong rows. A repeated `$k` duplicates its value at each `?`.
|
|
148
|
+
* Postgres binds `$n` natively, so it is returned untouched.
|
|
149
|
+
*
|
|
150
|
+
* Only real placeholders are rewritten — a `$1` inside a single-quoted string literal (e.g.
|
|
151
|
+
* `WHERE label = '$1 off'`) is left untouched. SQL escapes an inner quote by doubling it (`''`),
|
|
152
|
+
* which the simple in-string toggle handles (an even number of quotes returns to the same state).
|
|
153
|
+
*
|
|
154
|
+
* DynamoDB PartiQL binds the same positional `?` as mysql2/sqlite, so it shares this path — the
|
|
155
|
+
* reorder is essential there too (an author-written PartiQL `WHERE sk = $2 AND pk = $1`).
|
|
132
156
|
*/
|
|
133
|
-
export function rewritePlaceholders(engine: string, sql: string): string {
|
|
134
|
-
if (engine !== 'sqlite' && engine !== 'mysql') return sql;
|
|
157
|
+
export function rewritePlaceholders(engine: string, sql: string, params: any[]): { sql: string; params: any[] } {
|
|
158
|
+
if (engine !== 'sqlite' && engine !== 'mysql' && engine !== 'dynamodb') return { sql, params };
|
|
135
159
|
let out = '';
|
|
136
160
|
let inStr = false;
|
|
161
|
+
const newParams: any[] = [];
|
|
137
162
|
for (let i = 0; i < sql.length; i++) {
|
|
138
163
|
const c = sql[i];
|
|
139
164
|
if (c === "'") { inStr = !inStr; out += c; continue; }
|
|
140
165
|
if (!inStr && c === '$' && sql[i + 1] >= '0' && sql[i + 1] <= '9') {
|
|
141
166
|
let j = i + 1;
|
|
142
167
|
while (j < sql.length && sql[j] >= '0' && sql[j] <= '9') j++;
|
|
168
|
+
const k = Number(sql.slice(i + 1, j)); // 1-based placeholder index
|
|
143
169
|
out += '?';
|
|
170
|
+
newParams.push(params[k - 1]);
|
|
144
171
|
i = j - 1;
|
|
145
172
|
continue;
|
|
146
173
|
}
|
|
147
174
|
out += c;
|
|
148
175
|
}
|
|
149
|
-
return out;
|
|
176
|
+
return { sql: out, params: newParams };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Engine contract — the assertion surface is engine-agnostic; each engine owns its own dialect so
|
|
181
|
+
* a NoSQL store (Cosmos / MongoDB / DynamoDB) can back the same declarative steps as SQL.
|
|
182
|
+
* findRows(table, filter, limit) — rows matching an equality filter, capped at `limit`
|
|
183
|
+
* countRows(table, filter) — count of rows matching an equality filter
|
|
184
|
+
* runQuery(label, sql, params) — a raw catalog query (read-only). SQL dialects (incl. Cosmos SQL
|
|
185
|
+
* and DynamoDB PartiQL) take a SQL string; MongoDB's non-SQL
|
|
186
|
+
* find/pipeline shapes are dispatched separately via fetchQueryShape.
|
|
187
|
+
*/
|
|
188
|
+
interface Engine {
|
|
189
|
+
findRows(table: string, filter: Record<string, any>, limit: number): Promise<any[]>;
|
|
190
|
+
countRows(table: string, filter: Record<string, any>): Promise<number>;
|
|
191
|
+
runQuery(label: string, sql: string, params: any[]): Promise<any[]>;
|
|
192
|
+
// Non-SQL raw query (MongoDB find/pipeline). Only engines with a non-SQL catalog shape implement it;
|
|
193
|
+
// fetchQueryShape refuses the call on an engine that doesn't.
|
|
194
|
+
runShape?(label: string, shape: ShapeQuery, params: Record<string, any>): Promise<any[]>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A MongoDB catalog entry's runtime shape: exactly one of find/pipeline, with `:param` placeholders bound at runtime. */
|
|
198
|
+
type ShapeQuery = {
|
|
199
|
+
find?: { collection: string; filter?: Record<string, any>; projection?: Record<string, any>; sort?: Record<string, any>; limit?: number };
|
|
200
|
+
pipeline?: { collection: string; stages: any[] };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Read-only guard (second layer): a named SQL query must be a single SELECT/WITH statement. */
|
|
204
|
+
function assertSelectOnly(label: string, sql: string): void {
|
|
205
|
+
const s = sql.trim().replace(/;\s*$/, '');
|
|
206
|
+
if (!/^(SELECT|WITH)\b/i.test(s)) throw new Error(`Data Driver: ${label} is not a read-only SELECT — refused.`);
|
|
207
|
+
if (s.includes(';')) throw new Error(`Data Driver: ${label} contains multiple statements — refused.`);
|
|
208
|
+
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|MERGE|REPLACE|CALL|EXEC|EXECUTE|ATTACH|PRAGMA|VACUUM)\b/i.test(s)) {
|
|
209
|
+
throw new Error(`Data Driver: ${label} contains a write/DDL keyword — refused.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* SQL engine (postgres / mysql / sqlite). Builds the exact guarded, parameterized SELECTs the Data
|
|
215
|
+
* Driver has always emitted — `SELECT * … WHERE col = $n LIMIT`, `SELECT count(*)`, the `$n`→`?`
|
|
216
|
+
* reorder, and the read-only catalog guard — so the query strings are byte-identical to the
|
|
217
|
+
* pre-refactor path. `exec` is the lazy-loaded driver call (pg/mysql2/better-sqlite3).
|
|
218
|
+
*/
|
|
219
|
+
class SqlEngine implements Engine {
|
|
220
|
+
constructor(private engineName: string, private exec: (sql: string, params: any[]) => Promise<any[]>) {}
|
|
221
|
+
|
|
222
|
+
private rewrite(sql: string, params: any[]): { sql: string; params: any[] } {
|
|
223
|
+
return rewritePlaceholders(this.engineName, sql, params);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async findRows(table: string, filter: Record<string, any>, limit: number): Promise<any[]> {
|
|
227
|
+
const cols = Object.keys(filter);
|
|
228
|
+
const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
|
|
229
|
+
const sql = `SELECT * FROM ${ident(table)}${where ? ' WHERE ' + where : ''} LIMIT ${limit}`;
|
|
230
|
+
const q = this.rewrite(sql, cols.map((c) => filter[c]));
|
|
231
|
+
return this.exec(q.sql, q.params);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async countRows(table: string, filter: Record<string, any>): Promise<number> {
|
|
235
|
+
const cols = Object.keys(filter);
|
|
236
|
+
const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
|
|
237
|
+
const sql = `SELECT count(*) AS n FROM ${ident(table)}${where ? ' WHERE ' + where : ''}`;
|
|
238
|
+
const q = this.rewrite(sql, cols.map((c) => filter[c]));
|
|
239
|
+
const rows = await this.exec(q.sql, q.params);
|
|
240
|
+
return Number(rows[0]?.n ?? rows[0]?.['count(*)'] ?? 0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async runQuery(label: string, sql: string, params: any[]): Promise<any[]> {
|
|
244
|
+
assertSelectOnly(label, sql);
|
|
245
|
+
const q = this.rewrite(sql, params);
|
|
246
|
+
return this.exec(q.sql, q.params);
|
|
247
|
+
}
|
|
150
248
|
}
|
|
151
249
|
|
|
152
|
-
|
|
250
|
+
/** Minimal shape of an @azure/cosmos container the engine uses — kept local so the SDK stays a lazy dep. */
|
|
251
|
+
type CosmosContainer = { items: { query(spec: { query: string; parameters: Array<{ name: string; value: any }> }, opts?: any): { fetchAll(): Promise<{ resources: any[] }> } } };
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Azure Cosmos DB (NoSQL API) engine. The Cosmos query API is read-only by construction — `.query()`
|
|
255
|
+
* only ever reads; mutations require item-level APIs this engine never calls — so the SELECT guard is
|
|
256
|
+
* defense-in-depth. Cosmos binds NAMED params (`@p`), so it is immune to the positional-`?` reorder
|
|
257
|
+
* hazard. A step/catalog `[table]` selects the container; `SELECT * FROM c` uses the mandatory `c` alias.
|
|
258
|
+
*/
|
|
259
|
+
export class CosmosEngine implements Engine {
|
|
260
|
+
constructor(
|
|
261
|
+
private getContainer: (name?: string) => CosmosContainer,
|
|
262
|
+
private opts: { timeoutMs: number; maxRows: number },
|
|
263
|
+
) {}
|
|
264
|
+
|
|
265
|
+
private async query(container: CosmosContainer, spec: { query: string; parameters: Array<{ name: string; value: any }> }): Promise<any[]> {
|
|
266
|
+
const ac = new AbortController();
|
|
267
|
+
const timer = setTimeout(() => ac.abort(), this.opts.timeoutMs);
|
|
268
|
+
try {
|
|
269
|
+
const res = await container.items.query(spec, { maxItemCount: this.opts.maxRows, abortSignal: ac.signal }).fetchAll();
|
|
270
|
+
return res.resources.slice(0, this.opts.maxRows);
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Build `c.<col> = @p<i>` equality predicates over the mandatory `c` alias. Columns are identifier-checked. */
|
|
277
|
+
private whereFor(filter: Record<string, any>): { where: string; parameters: Array<{ name: string; value: any }> } {
|
|
278
|
+
const cols = Object.keys(filter);
|
|
279
|
+
const where = cols.map((c, i) => `c.${ident(c)} = @p${i}`).join(' AND ');
|
|
280
|
+
const parameters = cols.map((c, i) => ({ name: `@p${i}`, value: filter[c] }));
|
|
281
|
+
return { where: where ? ` WHERE ${where}` : '', parameters };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async findRows(table: string, filter: Record<string, any>, limit: number): Promise<any[]> {
|
|
285
|
+
const { where, parameters } = this.whereFor(filter);
|
|
286
|
+
// TOP bounds the fetch server-side (like the SQL engine's LIMIT) — without it, fetchAll() would
|
|
287
|
+
// drain every matching row into memory before the client-side slice, wasting RUs + latency.
|
|
288
|
+
const cap = Math.min(limit, this.opts.maxRows);
|
|
289
|
+
const rows = await this.query(this.getContainer(table), { query: `SELECT TOP ${cap} * FROM c${where}`, parameters });
|
|
290
|
+
return rows.slice(0, limit);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async countRows(table: string, filter: Record<string, any>): Promise<number> {
|
|
294
|
+
const { where, parameters } = this.whereFor(filter);
|
|
295
|
+
// SELECT VALUE COUNT(1) returns the scalar count as the single resource.
|
|
296
|
+
const rows = await this.query(this.getContainer(table), { query: `SELECT VALUE COUNT(1) FROM c${where}`, parameters });
|
|
297
|
+
return Number(rows[0] ?? 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async runQuery(label: string, sql: string, params: any[]): Promise<any[]> {
|
|
301
|
+
assertSelectOnly(label, sql); // defense-in-depth; the query API is read-only anyway
|
|
302
|
+
const { query, parameters } = cosmosBindParams(sql, params);
|
|
303
|
+
return this.query(this.getContainer(), { query, parameters });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Rewrite compiled positional `$n` placeholders to Cosmos NAMED params (`@pN`) and collect the
|
|
309
|
+
* matching parameter list. A `$n` inside a single-quoted string literal is left untouched (same
|
|
310
|
+
* in-string toggle as the SQL rewrite); a repeated `$n` reuses its one `@pN` entry.
|
|
311
|
+
*/
|
|
312
|
+
export function cosmosBindParams(sql: string, params: any[]): { query: string; parameters: Array<{ name: string; value: any }> } {
|
|
313
|
+
const parameters: Array<{ name: string; value: any }> = [];
|
|
314
|
+
const seen = new Set<number>();
|
|
315
|
+
let out = '';
|
|
316
|
+
let inStr = false;
|
|
317
|
+
for (let i = 0; i < sql.length; i++) {
|
|
318
|
+
const c = sql[i];
|
|
319
|
+
if (c === "'") { inStr = !inStr; out += c; continue; }
|
|
320
|
+
if (!inStr && c === '$' && sql[i + 1] >= '0' && sql[i + 1] <= '9') {
|
|
321
|
+
let j = i + 1;
|
|
322
|
+
while (j < sql.length && sql[j] >= '0' && sql[j] <= '9') j++;
|
|
323
|
+
const k = Number(sql.slice(i + 1, j));
|
|
324
|
+
out += `@p${k}`;
|
|
325
|
+
if (!seen.has(k)) { seen.add(k); parameters.push({ name: `@p${k}`, value: params[k - 1] }); }
|
|
326
|
+
i = j - 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
out += c;
|
|
330
|
+
}
|
|
331
|
+
return { query: out, parameters };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Minimal shapes of the mongodb driver objects the engine uses — kept local so the SDK stays a lazy dep. */
|
|
335
|
+
type MongoCursor = { project(p: any): MongoCursor; sort(s: any): MongoCursor; limit(n: number): MongoCursor; maxTimeMS(t: number): MongoCursor; toArray(): Promise<any[]> };
|
|
336
|
+
type MongoCollection = { find(filter: any): MongoCursor; countDocuments(filter: any, opts?: any): Promise<number>; aggregate(pipeline: any[], opts?: any): { toArray(): Promise<any[]> } };
|
|
337
|
+
|
|
338
|
+
/** Forbidden MongoDB operators (write-out / server-side JS) — refused at runtime as defense-in-depth. */
|
|
339
|
+
const MONGO_FORBIDDEN = /\$out|\$merge|\$where|\$function|\$accumulator|mapReduce/i;
|
|
340
|
+
|
|
341
|
+
/** Replace every whole-string `:param` in a find/pipeline shape with its bound native value (type-preserving). */
|
|
342
|
+
function substituteShapeParams(obj: any, params: Record<string, any>): any {
|
|
343
|
+
if (typeof obj === 'string') {
|
|
344
|
+
const m = obj.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
345
|
+
return m ? params[m[1]] : obj;
|
|
346
|
+
}
|
|
347
|
+
if (Array.isArray(obj)) return obj.map((v) => substituteShapeParams(v, params));
|
|
348
|
+
if (obj && typeof obj === 'object') {
|
|
349
|
+
const out: Record<string, any> = {};
|
|
350
|
+
for (const [k, v] of Object.entries(obj)) out[k] = substituteShapeParams(v, params);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
return obj;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Refuse a shape whose serialized form contains a write/JS-exec operator (nested or aliased). */
|
|
357
|
+
function assertMongoReadOnly(label: string, shape: any): void {
|
|
358
|
+
const m = JSON.stringify(shape ?? {}).match(MONGO_FORBIDDEN);
|
|
359
|
+
if (m) throw new Error(`Data Driver: ${label} uses forbidden MongoDB operator "${m[0]}" — refused (read-only find/aggregate only).`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* MongoDB engine. There is no SQL — declarative steps map to native find()/countDocuments(), and the
|
|
364
|
+
* raw `@query` path takes a find/pipeline shape (via fetchQueryShape → runShape) rather than a SQL
|
|
365
|
+
* string. Type-strict matching (`{age:30}` ≠ `{age:"30"}`) is why DB params bind via raw() (native
|
|
366
|
+
* type), not the stringifying get(). Read-only is enforced first by the server-side read role, with
|
|
367
|
+
* the operator blocklist as in-driver defense-in-depth. Per-query `maxTimeMS` + row cap bound cost.
|
|
368
|
+
*/
|
|
369
|
+
export class MongoEngine implements Engine {
|
|
370
|
+
constructor(
|
|
371
|
+
private getCollection: (name: string) => MongoCollection,
|
|
372
|
+
private opts: { timeoutMs: number; maxRows: number },
|
|
373
|
+
) {}
|
|
374
|
+
|
|
375
|
+
async findRows(table: string, filter: Record<string, any>, limit: number): Promise<any[]> {
|
|
376
|
+
return this.getCollection(table).find(filter).limit(limit).maxTimeMS(this.opts.timeoutMs).toArray();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async countRows(table: string, filter: Record<string, any>): Promise<number> {
|
|
380
|
+
return this.getCollection(table).countDocuments(filter, { maxTimeMS: this.opts.timeoutMs });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async runQuery(label: string): Promise<any[]> {
|
|
384
|
+
throw new Error(`Data Driver: ${label} — MongoDB has no SQL; author a find:/pipeline: catalog entry instead of sql:.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async runShape(label: string, shape: ShapeQuery, params: Record<string, any>): Promise<any[]> {
|
|
388
|
+
if (shape.find) {
|
|
389
|
+
const f = substituteShapeParams(shape.find, params) as NonNullable<ShapeQuery['find']>;
|
|
390
|
+
assertMongoReadOnly(label, f);
|
|
391
|
+
let cursor = this.getCollection(f.collection).find(f.filter ?? {});
|
|
392
|
+
if (f.projection) cursor = cursor.project(f.projection);
|
|
393
|
+
if (f.sort) cursor = cursor.sort(f.sort);
|
|
394
|
+
cursor = cursor.limit(f.limit ?? this.opts.maxRows).maxTimeMS(this.opts.timeoutMs);
|
|
395
|
+
return cursor.toArray();
|
|
396
|
+
}
|
|
397
|
+
if (shape.pipeline) {
|
|
398
|
+
const p = substituteShapeParams(shape.pipeline, params) as NonNullable<ShapeQuery['pipeline']>;
|
|
399
|
+
assertMongoReadOnly(label, p);
|
|
400
|
+
const rows = await this.getCollection(p.collection).aggregate(p.stages, { maxTimeMS: this.opts.timeoutMs }).toArray();
|
|
401
|
+
return rows.slice(0, this.opts.maxRows);
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`Data Driver: ${label} — catalog shape has neither find: nor pipeline:.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The three read-only DynamoDB operations the engine needs, over the auto-marshalling Document client. */
|
|
408
|
+
type DynamoOps = {
|
|
409
|
+
executeStatement(input: { Statement: string; Parameters?: any[]; Limit?: number; NextToken?: string }, signal?: AbortSignal): Promise<{ Items?: any[]; NextToken?: string }>;
|
|
410
|
+
getItem(input: { TableName: string; Key: Record<string, any> }, signal?: AbortSignal): Promise<{ Item?: any }>;
|
|
411
|
+
query(input: { TableName: string; KeyConditionExpression: string; ExpressionAttributeNames: Record<string, string>; ExpressionAttributeValues: Record<string, any>; Select?: string; Limit?: number; ExclusiveStartKey?: Record<string, any> }, signal?: AbortSignal): Promise<{ Items?: any[]; Count?: number; LastEvaluatedKey?: Record<string, any> }>;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
type KeySchema = Record<string, { partitionKey: string; sortKey?: string | null }>;
|
|
415
|
+
type DynamoRoute = { kind: 'get'; key: Record<string, any> } | { kind: 'query'; pk: string; value: any };
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* DynamoDB engine. The clean primary path is raw `@query` PartiQL SELECT (positional `?`, reordered
|
|
419
|
+
* via the shared placeholder rewrite). Declarative assertRow/count is structurally constrained by
|
|
420
|
+
* DynamoDB's key model: it routes GetItem/Query only for pk / pk+sk filters (via the `keySchema`
|
|
421
|
+
* registry) and REFUSES a non-key filter rather than falling back to an unaffordable full-table Scan.
|
|
422
|
+
* Read-only is primarily IAM (`dynamodb:PartiQLSelect` only); the SELECT guard is defense-in-depth.
|
|
423
|
+
*/
|
|
424
|
+
export class DynamoEngine implements Engine {
|
|
425
|
+
constructor(
|
|
426
|
+
private ops: DynamoOps,
|
|
427
|
+
private keySchema: KeySchema,
|
|
428
|
+
private opts: { timeoutMs: number; maxRows: number },
|
|
429
|
+
) {}
|
|
430
|
+
|
|
431
|
+
private async withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
432
|
+
const ac = new AbortController();
|
|
433
|
+
const timer = setTimeout(() => ac.abort(), this.opts.timeoutMs);
|
|
434
|
+
try { return await fn(ac.signal); } finally { clearTimeout(timer); }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Decide how a flat equality filter maps to a key-based access pattern, or refuse it. */
|
|
438
|
+
private route(table: string, filter: Record<string, any>): DynamoRoute {
|
|
439
|
+
const ks = this.keySchema[table];
|
|
440
|
+
if (!ks) throw new Error(`Data Driver: DynamoDB table "${table}" has no keySchema entry — add it under the datasource's keySchema:, or use a raw @query PartiQL SELECT.`);
|
|
441
|
+
const pk = ks.partitionKey;
|
|
442
|
+
const sk = ks.sortKey || undefined;
|
|
443
|
+
const keyAttrs = sk ? [pk, sk] : [pk];
|
|
444
|
+
const keys = Object.keys(filter);
|
|
445
|
+
const nonKey = keys.filter((k) => !keyAttrs.includes(k));
|
|
446
|
+
if (nonKey.length) throw new Error(`Data Driver: DynamoDB "${table}" filter has non-key attribute(s) [${nonKey.join(', ')}] — routing would require a full-table Scan (refused). Use a raw @query PartiQL SELECT instead.`);
|
|
447
|
+
if (!keys.includes(pk)) throw new Error(`Data Driver: DynamoDB "${table}" filter must include the partition key "${pk}".`);
|
|
448
|
+
if (sk && keys.includes(sk)) return { kind: 'get', key: { [pk]: filter[pk], [sk]: filter[sk] } };
|
|
449
|
+
if (!sk) return { kind: 'get', key: { [pk]: filter[pk] } };
|
|
450
|
+
return { kind: 'query', pk, value: filter[pk] }; // pk only on a table that has a sort key → Query
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async findRows(table: string, filter: Record<string, any>, limit: number): Promise<any[]> {
|
|
454
|
+
const route = this.route(table, filter);
|
|
455
|
+
const cap = Math.min(limit, this.opts.maxRows);
|
|
456
|
+
if (route.kind === 'get') {
|
|
457
|
+
const res = await this.withTimeout((s) => this.ops.getItem({ TableName: table, Key: route.key }, s));
|
|
458
|
+
return res.Item ? [res.Item] : [];
|
|
459
|
+
}
|
|
460
|
+
const res = await this.withTimeout((s) => this.ops.query({ TableName: table, KeyConditionExpression: '#pk = :pk', ExpressionAttributeNames: { '#pk': route.pk }, ExpressionAttributeValues: { ':pk': route.value }, Limit: cap }, s));
|
|
461
|
+
return (res.Items ?? []).slice(0, cap);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async countRows(table: string, filter: Record<string, any>): Promise<number> {
|
|
465
|
+
const route = this.route(table, filter);
|
|
466
|
+
if (route.kind === 'get') {
|
|
467
|
+
const res = await this.withTimeout((s) => this.ops.getItem({ TableName: table, Key: route.key }, s));
|
|
468
|
+
return res.Item ? 1 : 0;
|
|
469
|
+
}
|
|
470
|
+
// A Query with Select:COUNT evaluates at most ~1MB per request and returns a PARTIAL Count with a
|
|
471
|
+
// LastEvaluatedKey for the rest — accumulate across pages so the exact-equality assert is correct.
|
|
472
|
+
let count = 0;
|
|
473
|
+
let startKey: Record<string, any> | undefined;
|
|
474
|
+
do {
|
|
475
|
+
const res = await this.withTimeout((s) => this.ops.query({ TableName: table, KeyConditionExpression: '#pk = :pk', ExpressionAttributeNames: { '#pk': route.pk }, ExpressionAttributeValues: { ':pk': route.value }, Select: 'COUNT', ExclusiveStartKey: startKey }, s));
|
|
476
|
+
count += Number(res.Count ?? 0);
|
|
477
|
+
startKey = res.LastEvaluatedKey;
|
|
478
|
+
} while (startKey);
|
|
479
|
+
return count;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async runQuery(label: string, sql: string, params: any[]): Promise<any[]> {
|
|
483
|
+
assertSelectOnly(label, sql); // defense-in-depth; primary read-only guarantee is IAM PartiQLSelect-only
|
|
484
|
+
const bound = rewritePlaceholders('dynamodb', sql, params); // $n → ? positional, reordered to textual order
|
|
485
|
+
const items: any[] = [];
|
|
486
|
+
let nextToken: string | undefined;
|
|
487
|
+
do {
|
|
488
|
+
const page = await this.withTimeout((s) => this.ops.executeStatement({ Statement: bound.sql, Parameters: bound.params.length ? bound.params : undefined, Limit: this.opts.maxRows, NextToken: nextToken }, s));
|
|
489
|
+
items.push(...(page.Items ?? []));
|
|
490
|
+
nextToken = page.NextToken;
|
|
491
|
+
} while (nextToken && items.length < this.opts.maxRows); // manual pagination, bounded by the row cap
|
|
492
|
+
return items.slice(0, this.opts.maxRows);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
153
495
|
|
|
154
496
|
class DataSource {
|
|
155
497
|
private configs: Record<string, DataSourceConfig> | null = null;
|
|
156
498
|
private engines = new Map<string, Engine>();
|
|
157
499
|
private tunnels: Array<{ close: () => void }> = [];
|
|
158
500
|
private mysqlPools: Array<{ end: () => Promise<void> }> = [];
|
|
501
|
+
private mongoClients: Array<{ close: () => Promise<void> }> = [];
|
|
159
502
|
|
|
160
503
|
private cfg(name?: string): { key: string; conf: DataSourceConfig } {
|
|
161
504
|
if (!this.configs) this.configs = loadConfig();
|
|
@@ -168,8 +511,27 @@ class DataSource {
|
|
|
168
511
|
private async engine(name?: string): Promise<{ engine: Engine; conf: DataSourceConfig }> {
|
|
169
512
|
const { key, conf } = this.cfg(name);
|
|
170
513
|
if (this.engines.has(key)) return { engine: this.engines.get(key)!, conf };
|
|
171
|
-
if (!conf.url) throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
|
|
172
514
|
let engine: Engine;
|
|
515
|
+
if (conf.engine === 'postgres' || conf.engine === 'mysql' || conf.engine === 'sqlite') {
|
|
516
|
+
// url is required for the SQL engines (host-based, or a sqlite file path). NoSQL engines that
|
|
517
|
+
// have no connection URL (e.g. DynamoDB region+creds) supply their own config and skip this.
|
|
518
|
+
if (!conf.url) throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
|
|
519
|
+
engine = new SqlEngine(conf.engine, await this.sqlExec(key, conf));
|
|
520
|
+
} else if (conf.engine === 'cosmos') {
|
|
521
|
+
engine = this.cosmosEngine(key, conf);
|
|
522
|
+
} else if (conf.engine === 'mongodb') {
|
|
523
|
+
engine = this.mongoEngine(key, conf);
|
|
524
|
+
} else if (conf.engine === 'dynamodb') {
|
|
525
|
+
engine = this.dynamoEngine(key, conf);
|
|
526
|
+
} else {
|
|
527
|
+
throw new Error(`Data Driver: engine "${conf.engine}" not supported (supported: ${[...SUPPORTED_ENGINES, ...NOSQL_ENGINES].join(' | ')}). For an unlisted store, verify via @api or @manual.`);
|
|
528
|
+
}
|
|
529
|
+
this.engines.set(key, engine);
|
|
530
|
+
return { engine, conf };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Build the lazy driver-call closure for a SQL engine (pg/mysql/sqlite), incl. the optional SSH-tunnel fallback. */
|
|
534
|
+
private async sqlExec(key: string, conf: DataSourceConfig): Promise<(sql: string, params: any[]) => Promise<any[]>> {
|
|
173
535
|
if (conf.engine === 'postgres') {
|
|
174
536
|
let connectionString = conf.url;
|
|
175
537
|
if (conf.ssh) { // Fallback path: tunnel the DB socket through a bastion
|
|
@@ -181,8 +543,9 @@ class DataSource {
|
|
|
181
543
|
}
|
|
182
544
|
const { Pool } = require('pg');
|
|
183
545
|
const pool = new Pool({ connectionString, max: 2, statement_timeout: conf.statement_timeout_ms ?? 4000 });
|
|
184
|
-
|
|
185
|
-
}
|
|
546
|
+
return async (sql, params) => (await pool.query(sql, params)).rows;
|
|
547
|
+
}
|
|
548
|
+
if (conf.engine === 'mysql') {
|
|
186
549
|
let connectionString = conf.url;
|
|
187
550
|
if (conf.ssh) { // Fallback path: tunnel the DB socket through a bastion
|
|
188
551
|
const u = new URL(conf.url);
|
|
@@ -199,58 +562,89 @@ class DataSource {
|
|
|
199
562
|
// against a timer. On overrun we reject and destroy the connection (removes it from the pool
|
|
200
563
|
// rather than releasing a still-busy socket); on success we release it back.
|
|
201
564
|
const timeoutMs = conf.statement_timeout_ms ?? 4000;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
},
|
|
565
|
+
return async (sql, params) => {
|
|
566
|
+
const conn = await pool.getConnection();
|
|
567
|
+
const qp = conn.query(sql, params);
|
|
568
|
+
qp.catch(() => { /* swallow the post-timeout rejection from the abandoned query */ });
|
|
569
|
+
let timer: NodeJS.Timeout | undefined;
|
|
570
|
+
let timedOut = false;
|
|
571
|
+
const guard = new Promise<never>((_, reject) => {
|
|
572
|
+
timer = setTimeout(() => { timedOut = true; reject(new Error(`Data Driver: mysql query exceeded ${timeoutMs}ms — aborted.`)); }, timeoutMs);
|
|
573
|
+
});
|
|
574
|
+
try {
|
|
575
|
+
const [rows] = await Promise.race([qp, guard]);
|
|
576
|
+
return rows as any[];
|
|
577
|
+
} finally {
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
if (timedOut) { try { conn.destroy(); } catch { /* best-effort */ } }
|
|
580
|
+
else { try { conn.release(); } catch { /* already gone */ } }
|
|
581
|
+
}
|
|
221
582
|
};
|
|
222
|
-
} else if (conf.engine === 'sqlite') {
|
|
223
|
-
if (conf.ssh) console.warn(`Data Driver: datasource "${key}" sets ssh: but engine is sqlite (file-based) — ssh ignored.`);
|
|
224
|
-
const Database = require('better-sqlite3');
|
|
225
|
-
const db = new Database(conf.url.replace(/^sqlite:/, ''), { readonly: conf.readonly !== false });
|
|
226
|
-
engine = { query: async (sql, params) => db.prepare(sql).all(...params) };
|
|
227
|
-
} else {
|
|
228
|
-
throw new Error(`Data Driver: engine "${conf.engine}" not supported (supported: ${SUPPORTED_ENGINES.join(' | ')}). For non-SQL stores (Cosmos NoSQL, Mongo…) verify via @api or @manual.`);
|
|
229
583
|
}
|
|
230
|
-
|
|
231
|
-
|
|
584
|
+
// sqlite
|
|
585
|
+
if (conf.ssh) console.warn(`Data Driver: datasource "${key}" sets ssh: but engine is sqlite (file-based) — ssh ignored.`);
|
|
586
|
+
const Database = require('better-sqlite3');
|
|
587
|
+
const db = new Database(conf.url.replace(/^sqlite:/, ''), { readonly: conf.readonly !== false });
|
|
588
|
+
return async (sql, params) => db.prepare(sql).all(...params);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Build the Cosmos engine — connect via endpoint+key or a connection-string url; resolve containers by name. */
|
|
592
|
+
private cosmosEngine(key: string, conf: DataSourceConfig): Engine {
|
|
593
|
+
const { CosmosClient } = require('@azure/cosmos');
|
|
594
|
+
let client: any;
|
|
595
|
+
if (conf.endpoint && conf.key) client = new CosmosClient({ endpoint: conf.endpoint, key: conf.key });
|
|
596
|
+
else if (conf.url) client = new CosmosClient(conf.url); // AccountEndpoint=…;AccountKey=…; connection string
|
|
597
|
+
else throw new Error(`Data Driver: cosmos datasource "${key}" needs endpoint+key (or a connection-string url).`);
|
|
598
|
+
if (!conf.database) throw new Error(`Data Driver: cosmos datasource "${key}" needs a database name.`);
|
|
599
|
+
const getContainer = (name?: string): CosmosContainer => {
|
|
600
|
+
const cn = name || conf.container;
|
|
601
|
+
if (!cn) throw new Error(`Data Driver: cosmos datasource "${key}" — no container (set container: in config, or name a [table]).`);
|
|
602
|
+
return client.database(conf.database!).container(cn);
|
|
603
|
+
};
|
|
604
|
+
return new CosmosEngine(getContainer, { timeoutMs: conf.statement_timeout_ms ?? 4000, maxRows: conf.max_rows ?? 100 });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Build the MongoDB engine — one reused client per datasource; the db comes from `database` or the URI default. */
|
|
608
|
+
private mongoEngine(key: string, conf: DataSourceConfig): Engine {
|
|
609
|
+
if (!conf.url) throw new Error(`Data Driver: mongodb datasource "${key}" needs a url (mongodb:// URI).`);
|
|
610
|
+
const { MongoClient } = require('mongodb');
|
|
611
|
+
const client = new MongoClient(conf.url); // operations auto-connect; one client is reused
|
|
612
|
+
this.mongoClients.push(client);
|
|
613
|
+
const dbHandle = client.db(conf.database); // undefined → the URI's default database
|
|
614
|
+
return new MongoEngine((name: string) => dbHandle.collection(name), { timeoutMs: conf.statement_timeout_ms ?? 5000, maxRows: conf.max_rows ?? 50 });
|
|
232
615
|
}
|
|
233
616
|
|
|
234
|
-
/**
|
|
617
|
+
/** Build the DynamoDB engine — region + credentials from the AWS env chain; wraps the Document client's three read ops. */
|
|
618
|
+
private dynamoEngine(key: string, conf: DataSourceConfig): Engine {
|
|
619
|
+
if (!conf.region) throw new Error(`Data Driver: dynamodb datasource "${key}" needs a region (DynamoDB has no url).`);
|
|
620
|
+
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
|
621
|
+
const { DynamoDBDocumentClient, ExecuteStatementCommand, GetCommand, QueryCommand } = require('@aws-sdk/lib-dynamodb');
|
|
622
|
+
const base = new DynamoDBClient({ region: conf.region }); // creds resolved from env / IAM role
|
|
623
|
+
const doc = DynamoDBDocumentClient.from(base, { marshallOptions: { removeUndefinedValues: true } });
|
|
624
|
+
const ops: DynamoOps = {
|
|
625
|
+
executeStatement: (input, signal) => doc.send(new ExecuteStatementCommand(input), { abortSignal: signal }),
|
|
626
|
+
getItem: (input, signal) => doc.send(new GetCommand(input), { abortSignal: signal }),
|
|
627
|
+
query: (input, signal) => doc.send(new QueryCommand(input), { abortSignal: signal }),
|
|
628
|
+
};
|
|
629
|
+
return new DynamoEngine(ops, conf.keySchema ?? {}, { timeoutMs: conf.statement_timeout_ms ?? 4000, maxRows: conf.max_rows ?? 1000 });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Close open MySQL pools + Mongo clients + SSH tunnels (optional explicit teardown; tunnels are unref'd so the process exits regardless). */
|
|
235
633
|
close(): void {
|
|
236
634
|
for (const p of this.mysqlPools) { try { void p.end(); } catch { /* best-effort */ } }
|
|
237
635
|
this.mysqlPools = [];
|
|
636
|
+
for (const c of this.mongoClients) { try { void c.close(); } catch { /* best-effort */ } }
|
|
637
|
+
this.mongoClients = [];
|
|
238
638
|
for (const t of this.tunnels) t.close();
|
|
239
639
|
this.tunnels = [];
|
|
240
640
|
}
|
|
241
641
|
|
|
242
|
-
|
|
243
|
-
const cols = Object.keys(filter);
|
|
244
|
-
const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
|
|
245
|
-
const sql = `SELECT * FROM ${ident(table)}${where ? ' WHERE ' + where : ''} LIMIT 50`;
|
|
246
|
-
return { sql, params: cols.map((c) => filter[c]) };
|
|
247
|
-
}
|
|
642
|
+
// --- Engine-agnostic assertions: build nothing, just delegate to the engine + compare here ----
|
|
248
643
|
|
|
249
644
|
/** A row matching `filter` must exist; if `expected` given, assert those columns on the first match. */
|
|
250
645
|
async assertRow(table: string, filter: Record<string, any>, expected?: Record<string, any>, datasource?: string): Promise<void> {
|
|
251
|
-
const { engine
|
|
252
|
-
const
|
|
253
|
-
const rows = await engine.query(this.sqlFor(conf, sql), params);
|
|
646
|
+
const { engine } = await this.engine(datasource);
|
|
647
|
+
const rows = await engine.findRows(table, filter, 50);
|
|
254
648
|
expect(rows.length, `Expected a row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBeGreaterThanOrEqual(1);
|
|
255
649
|
if (expected) {
|
|
256
650
|
const row = rows[0];
|
|
@@ -263,48 +657,38 @@ class DataSource {
|
|
|
263
657
|
|
|
264
658
|
/** No row matching `filter` may exist. */
|
|
265
659
|
async assertNoRow(table: string, filter: Record<string, any>, datasource?: string): Promise<void> {
|
|
266
|
-
const { engine
|
|
267
|
-
const
|
|
268
|
-
const rows = await engine.query(this.sqlFor(conf, sql), params);
|
|
660
|
+
const { engine } = await this.engine(datasource);
|
|
661
|
+
const rows = await engine.findRows(table, filter, 50);
|
|
269
662
|
expect(rows.length, `Expected NO row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBe(0);
|
|
270
663
|
}
|
|
271
664
|
|
|
272
665
|
/** Exactly `count` rows must match `filter`. */
|
|
273
666
|
async assertCount(table: string, filter: Record<string, any>, count: number, datasource?: string): Promise<void> {
|
|
274
|
-
const { engine
|
|
667
|
+
const { engine } = await this.engine(datasource);
|
|
668
|
+
const n = await engine.countRows(table, filter);
|
|
275
669
|
const cols = Object.keys(filter);
|
|
276
|
-
const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
|
|
277
|
-
const sql = `SELECT count(*) AS n FROM ${ident(table)}${where ? ' WHERE ' + where : ''}`;
|
|
278
|
-
const rows = await engine.query(this.sqlFor(conf, sql), cols.map((c) => filter[c]));
|
|
279
|
-
const n = Number(rows[0]?.n ?? rows[0]?.['count(*)'] ?? 0);
|
|
280
670
|
expect(n, `Expected ${count} row(s) in "${table}"${cols.length ? ' where ' + desc(filter) : ''} — found ${n}`).toBe(Number(count));
|
|
281
671
|
}
|
|
282
672
|
|
|
283
|
-
/** Rewrites $1/$2 placeholders to `?` for engines that don't support PG-style positional params. */
|
|
284
|
-
private sqlFor(conf: DataSourceConfig, sql: string): string {
|
|
285
|
-
return rewritePlaceholders(conf.engine, sql);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// --- Named queries (catalog-backed; SQL is resolved + embedded at compile time) -----------
|
|
289
|
-
/** Read-only guard (second layer): a named query must be a single SELECT/WITH statement. */
|
|
290
|
-
private assertSelectOnly(label: string, sql: string): void {
|
|
291
|
-
const s = sql.trim().replace(/;\s*$/, '');
|
|
292
|
-
if (!/^(SELECT|WITH)\b/i.test(s)) throw new Error(`Data Driver: ${label} is not a read-only SELECT — refused.`);
|
|
293
|
-
if (s.includes(';')) throw new Error(`Data Driver: ${label} contains multiple statements — refused.`);
|
|
294
|
-
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|MERGE|REPLACE|CALL|EXEC|EXECUTE|ATTACH|PRAGMA|VACUUM)\b/i.test(s)) {
|
|
295
|
-
throw new Error(`Data Driver: ${label} contains a write/DDL keyword — refused.`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
673
|
/**
|
|
300
674
|
* Run a catalog query (read-only) and return its rows. The result is bound to a `{{name}}`
|
|
301
675
|
* variable via `testData.bind(...)`, so the scenario asserts on it with `expect …` steps and
|
|
302
676
|
* path access (`{{name.count}}`, `{{name.first.col}}`, `{{name[2].col}}`).
|
|
303
677
|
*/
|
|
304
678
|
async fetchQuery(label: string, sql: string, params: any[], datasource?: string): Promise<any[]> {
|
|
305
|
-
this.
|
|
306
|
-
|
|
307
|
-
|
|
679
|
+
const { engine } = await this.engine(datasource);
|
|
680
|
+
return engine.runQuery(label, sql, params);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Run a MongoDB find/pipeline catalog query (read-only) and return its documents. Same binding
|
|
685
|
+
* contract as fetchQuery, but the query is a structured shape (not SQL) with `:param` placeholders
|
|
686
|
+
* bound from `params` at runtime. Refused on an engine that has no non-SQL shape support.
|
|
687
|
+
*/
|
|
688
|
+
async fetchQueryShape(label: string, shape: ShapeQuery, params: Record<string, any>, datasource?: string): Promise<any[]> {
|
|
689
|
+
const { engine } = await this.engine(datasource);
|
|
690
|
+
if (!engine.runShape) throw new Error(`Data Driver: ${label} is a find/pipeline query, but this datasource's engine has no such support (find:/pipeline: is MongoDB-only).`);
|
|
691
|
+
return engine.runShape(label, shape, params);
|
|
308
692
|
}
|
|
309
693
|
}
|
|
310
694
|
|