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