litedbmodel 2.0.2 → 2.2.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.
Files changed (193) hide show
  1. package/README.md +7 -0
  2. package/dist/DBConditions.d.ts.map +1 -1
  3. package/dist/DBConditions.js +16 -1
  4. package/dist/DBConditions.js.map +1 -1
  5. package/dist/DBHandler.d.ts.map +1 -1
  6. package/dist/DBHandler.js +6 -0
  7. package/dist/DBHandler.js.map +1 -1
  8. package/dist/DBModel.d.ts +84 -2
  9. package/dist/DBModel.d.ts.map +1 -1
  10. package/dist/DBModel.js +312 -29
  11. package/dist/DBModel.js.map +1 -1
  12. package/dist/decorators.d.ts +52 -0
  13. package/dist/decorators.d.ts.map +1 -1
  14. package/dist/decorators.js +127 -24
  15. package/dist/decorators.js.map +1 -1
  16. package/dist/drivers/mysql.d.ts.map +1 -1
  17. package/dist/drivers/mysql.js +12 -2
  18. package/dist/drivers/mysql.js.map +1 -1
  19. package/dist/drivers/postgres.d.ts.map +1 -1
  20. package/dist/drivers/postgres.js +13 -2
  21. package/dist/drivers/postgres.js.map +1 -1
  22. package/dist/drivers/sqlite.d.ts +2 -0
  23. package/dist/drivers/sqlite.d.ts.map +1 -1
  24. package/dist/drivers/sqlite.js +49 -4
  25. package/dist/drivers/sqlite.js.map +1 -1
  26. package/dist/index.cjs +10741 -0
  27. package/dist/index.d.cts +1 -0
  28. package/dist/scp/coltype.d.ts +108 -0
  29. package/dist/scp/coltype.d.ts.map +1 -1
  30. package/dist/scp/coltype.js +293 -6
  31. package/dist/scp/coltype.js.map +1 -1
  32. package/dist/scp/connection-routing.d.ts +312 -0
  33. package/dist/scp/connection-routing.d.ts.map +1 -0
  34. package/dist/scp/connection-routing.js +394 -0
  35. package/dist/scp/connection-routing.js.map +1 -0
  36. package/dist/scp/dbmodel-runtime.d.ts +101 -0
  37. package/dist/scp/dbmodel-runtime.d.ts.map +1 -0
  38. package/dist/scp/dbmodel-runtime.js +174 -0
  39. package/dist/scp/dbmodel-runtime.js.map +1 -0
  40. package/dist/scp/decorator-adapter.d.ts +196 -0
  41. package/dist/scp/decorator-adapter.d.ts.map +1 -0
  42. package/dist/scp/decorator-adapter.js +286 -0
  43. package/dist/scp/decorator-adapter.js.map +1 -0
  44. package/dist/scp/emit/emitter.d.ts +108 -0
  45. package/dist/scp/emit/emitter.d.ts.map +1 -0
  46. package/dist/scp/emit/emitter.js +782 -0
  47. package/dist/scp/emit/emitter.js.map +1 -0
  48. package/dist/scp/emit/endpoint.d.ts +248 -0
  49. package/dist/scp/emit/endpoint.d.ts.map +1 -0
  50. package/dist/scp/emit/endpoint.js +20 -0
  51. package/dist/scp/emit/endpoint.js.map +1 -0
  52. package/dist/scp/emit/index.d.ts +11 -0
  53. package/dist/scp/emit/index.d.ts.map +1 -0
  54. package/dist/scp/emit/index.js +13 -0
  55. package/dist/scp/emit/index.js.map +1 -0
  56. package/dist/scp/errors.d.ts +45 -1
  57. package/dist/scp/errors.d.ts.map +1 -1
  58. package/dist/scp/errors.js +47 -1
  59. package/dist/scp/errors.js.map +1 -1
  60. package/dist/scp/exec-context.d.ts +390 -0
  61. package/dist/scp/exec-context.d.ts.map +1 -0
  62. package/dist/scp/exec-context.js +540 -0
  63. package/dist/scp/exec-context.js.map +1 -0
  64. package/dist/scp/grouping.d.ts +32 -0
  65. package/dist/scp/grouping.d.ts.map +1 -0
  66. package/dist/scp/grouping.js +0 -0
  67. package/dist/scp/grouping.js.map +1 -0
  68. package/dist/scp/index.cjs +6432 -17579
  69. package/dist/scp/index.d.cts +1 -0
  70. package/dist/scp/index.d.mts +1 -0
  71. package/dist/scp/index.d.ts +51 -52
  72. package/dist/scp/index.d.ts.map +1 -1
  73. package/dist/scp/index.js +189 -152
  74. package/dist/scp/index.js.map +1 -1
  75. package/dist/scp/index.mjs +6067 -3659
  76. package/dist/scp/leaf-transport.d.ts +50 -0
  77. package/dist/scp/leaf-transport.d.ts.map +1 -0
  78. package/dist/scp/leaf-transport.js +92 -0
  79. package/dist/scp/leaf-transport.js.map +1 -0
  80. package/dist/scp/leaves.d.ts +174 -0
  81. package/dist/scp/leaves.d.ts.map +1 -0
  82. package/dist/scp/leaves.js +287 -0
  83. package/dist/scp/leaves.js.map +1 -0
  84. package/dist/scp/limit-config.d.ts +118 -0
  85. package/dist/scp/limit-config.d.ts.map +1 -0
  86. package/dist/scp/limit-config.js +126 -0
  87. package/dist/scp/limit-config.js.map +1 -0
  88. package/dist/scp/makesql/compile-relation.d.ts +49 -23
  89. package/dist/scp/makesql/compile-relation.d.ts.map +1 -1
  90. package/dist/scp/makesql/compile-relation.js +101 -76
  91. package/dist/scp/makesql/compile-relation.js.map +1 -1
  92. package/dist/scp/makesql/compile-select.d.ts +45 -10
  93. package/dist/scp/makesql/compile-select.d.ts.map +1 -1
  94. package/dist/scp/makesql/compile-select.js +50 -10
  95. package/dist/scp/makesql/compile-select.js.map +1 -1
  96. package/dist/scp/makesql/handler.d.ts +1 -1
  97. package/dist/scp/makesql/handler.d.ts.map +1 -1
  98. package/dist/scp/makesql/index.d.ts +7 -10
  99. package/dist/scp/makesql/index.d.ts.map +1 -1
  100. package/dist/scp/makesql/index.js +19 -30
  101. package/dist/scp/makesql/index.js.map +1 -1
  102. package/dist/scp/makesql/json-array.d.ts +65 -0
  103. package/dist/scp/makesql/json-array.d.ts.map +1 -1
  104. package/dist/scp/makesql/json-array.js +99 -10
  105. package/dist/scp/makesql/json-array.js.map +1 -1
  106. package/dist/scp/makesql/json-batch.d.ts.map +1 -1
  107. package/dist/scp/makesql/json-batch.js +21 -19
  108. package/dist/scp/makesql/json-batch.js.map +1 -1
  109. package/dist/scp/makesql/makesql.d.ts +1 -1
  110. package/dist/scp/makesql/makesql.d.ts.map +1 -1
  111. package/dist/scp/makesql/mysql-returning.d.ts +93 -0
  112. package/dist/scp/makesql/mysql-returning.d.ts.map +1 -0
  113. package/dist/scp/makesql/mysql-returning.js +215 -0
  114. package/dist/scp/makesql/mysql-returning.js.map +1 -0
  115. package/dist/scp/makesql/outtype.d.ts +75 -34
  116. package/dist/scp/makesql/outtype.d.ts.map +1 -1
  117. package/dist/scp/makesql/outtype.js +91 -118
  118. package/dist/scp/makesql/outtype.js.map +1 -1
  119. package/dist/scp/makesql/pool-executor.d.ts +135 -13
  120. package/dist/scp/makesql/pool-executor.d.ts.map +1 -1
  121. package/dist/scp/makesql/pool-executor.js +229 -21
  122. package/dist/scp/makesql/pool-executor.js.map +1 -1
  123. package/dist/scp/makesql/tx.d.ts +107 -22
  124. package/dist/scp/makesql/tx.d.ts.map +1 -1
  125. package/dist/scp/makesql/tx.js +451 -97
  126. package/dist/scp/makesql/tx.js.map +1 -1
  127. package/dist/scp/makesql/writeouttype.d.ts +7 -8
  128. package/dist/scp/makesql/writeouttype.d.ts.map +1 -1
  129. package/dist/scp/makesql/writeouttype.js +8 -7
  130. package/dist/scp/makesql/writeouttype.js.map +1 -1
  131. package/dist/scp/middleware.d.ts +287 -0
  132. package/dist/scp/middleware.d.ts.map +1 -0
  133. package/dist/scp/middleware.js +358 -0
  134. package/dist/scp/middleware.js.map +1 -0
  135. package/dist/scp/relation.d.ts +80 -9
  136. package/dist/scp/relation.d.ts.map +1 -1
  137. package/dist/scp/relation.js +198 -78
  138. package/dist/scp/relation.js.map +1 -1
  139. package/dist/scp/tx-options.d.ts +136 -0
  140. package/dist/scp/tx-options.d.ts.map +1 -0
  141. package/dist/scp/tx-options.js +184 -0
  142. package/dist/scp/tx-options.js.map +1 -0
  143. package/dist/scp/write-bundle.d.ts +131 -0
  144. package/dist/scp/write-bundle.d.ts.map +1 -0
  145. package/dist/scp/write-bundle.js +160 -0
  146. package/dist/scp/write-bundle.js.map +1 -0
  147. package/dist/types.d.ts +7 -0
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/types.js.map +1 -1
  150. package/docs/BENCHMARK.md +58 -56
  151. package/docs/benchmark-chart.svg +166 -166
  152. package/docs/design/phase-a-execution-context.md +139 -0
  153. package/package.json +16 -9
  154. package/dist/scp/authoring-sql.d.ts +0 -134
  155. package/dist/scp/authoring-sql.d.ts.map +0 -1
  156. package/dist/scp/authoring-sql.js +0 -191
  157. package/dist/scp/authoring-sql.js.map +0 -1
  158. package/dist/scp/authoring.d.ts +0 -155
  159. package/dist/scp/authoring.d.ts.map +0 -1
  160. package/dist/scp/authoring.js +0 -162
  161. package/dist/scp/authoring.js.map +0 -1
  162. package/dist/scp/catalog.d.ts +0 -94
  163. package/dist/scp/catalog.d.ts.map +0 -1
  164. package/dist/scp/catalog.js +0 -234
  165. package/dist/scp/catalog.js.map +0 -1
  166. package/dist/scp/codegen.d.ts +0 -189
  167. package/dist/scp/codegen.d.ts.map +0 -1
  168. package/dist/scp/codegen.js +0 -421
  169. package/dist/scp/codegen.js.map +0 -1
  170. package/dist/scp/find-filter-guard.d.ts +0 -68
  171. package/dist/scp/find-filter-guard.d.ts.map +0 -1
  172. package/dist/scp/find-filter-guard.js +0 -88
  173. package/dist/scp/find-filter-guard.js.map +0 -1
  174. package/dist/scp/guard.d.ts +0 -43
  175. package/dist/scp/guard.d.ts.map +0 -1
  176. package/dist/scp/guard.js +0 -134
  177. package/dist/scp/guard.js.map +0 -1
  178. package/dist/scp/makesql/authoring-compile.d.ts +0 -90
  179. package/dist/scp/makesql/authoring-compile.d.ts.map +0 -1
  180. package/dist/scp/makesql/authoring-compile.js +0 -494
  181. package/dist/scp/makesql/authoring-compile.js.map +0 -1
  182. package/dist/scp/makesql/ir.d.ts +0 -42
  183. package/dist/scp/makesql/ir.d.ts.map +0 -1
  184. package/dist/scp/makesql/ir.js +0 -66
  185. package/dist/scp/makesql/ir.js.map +0 -1
  186. package/dist/scp/makesql/static-bundle.d.ts +0 -187
  187. package/dist/scp/makesql/static-bundle.d.ts.map +0 -1
  188. package/dist/scp/makesql/static-bundle.js +0 -1113
  189. package/dist/scp/makesql/static-bundle.js.map +0 -1
  190. package/dist/scp/runtime.d.ts +0 -242
  191. package/dist/scp/runtime.d.ts.map +0 -1
  192. package/dist/scp/runtime.js +0 -400
  193. package/dist/scp/runtime.js.map +0 -1
@@ -35,19 +35,31 @@
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.IN_SENTINEL = void 0;
37
37
  exports.literalize = literalize;
38
- exports.mysqlPkHint = mysqlPkHint;
39
- exports.stripMysqlPkHint = stripMysqlPkHint;
38
+ exports.pgTypeSpecimen = pgTypeSpecimen;
39
+ exports.pkPort = pkPort;
40
40
  exports.compileWriteNode = compileWriteNode;
41
41
  exports.deriveTransactionPlan = deriveTransactionPlan;
42
42
  exports.deriveBatchPlan = deriveBatchPlan;
43
43
  exports.executeTransaction = executeTransaction;
44
44
  exports.countingDriver = countingDriver;
45
45
  exports.renderTxStatement = renderTxStatement;
46
- const behavior_contracts_1 = require("behavior-contracts");
46
+ exports.executeTransactionAsync = executeTransactionAsync;
47
+ const runtime_1 = require("behavior-contracts/runtime");
47
48
  const makesql_1 = require("./makesql");
49
+ const json_batch_1 = require("./json-batch");
50
+ const json_array_1 = require("./json-array");
51
+ const PostgresSqlBuilder_1 = require("../../drivers/PostgresSqlBuilder");
52
+ const coltype_1 = require("../coltype");
48
53
  const handler_1 = require("./handler");
49
54
  const compile_1 = require("./compile");
50
55
  const errors_1 = require("../errors");
56
+ // The mysql pk-hint writer. `mysql-returning` imports `TxOp` from here TYPE-only (erased), so this
57
+ // runtime import closes no cycle.
58
+ const mysql_returning_1 = require("./mysql-returning");
59
+ const exec_context_1 = require("../exec-context");
60
+ const leaves_1 = require("../leaves");
61
+ const tx_options_1 = require("../tx-options");
62
+ const connection_errors_1 = require("../../connection-errors");
51
63
  const DBConditions_1 = require("../../DBConditions");
52
64
  const writes_1 = require("../writes");
53
65
  /**
@@ -73,6 +85,16 @@ function literalize(value) {
73
85
  return null;
74
86
  if (Array.isArray(value))
75
87
  return { arr: value.map((e) => literalize(e)) };
88
+ // A bc `int` is a BigInt on the TS plane. `evaluateExpression` accepts an integer only as a plain
89
+ // JS number (safe range) or the `{int:"…"}` literal — a RAW BigInt node is rejected ("invalid node").
90
+ // A value read back from an integer column now arrives as a BigInt (bc's int model), and using it in a
91
+ // later write (e.g. the id from a `create({returning:true})` fed to a nested `create`) sent that BigInt
92
+ // straight into `evaluateExpression`, which threw. Encode it as bc's own canonical int literal
93
+ // (`{int: v.toString()}`, exactly what bc emits for an out-of-safe-range int) — exact, no rounding,
94
+ // and no value inference. MUST be before the `typeof === 'object'` branch would ever see it (a bigint
95
+ // is a primitive, so it would fall through to the bare `return value`).
96
+ if (typeof value === 'bigint')
97
+ return { int: value.toString() };
76
98
  if (typeof value === 'object') {
77
99
  const obj = {};
78
100
  for (const [k, v] of Object.entries(value))
@@ -306,49 +328,175 @@ function v1EqualityWhereText(columns) {
306
328
  conditions[c] = PROBE;
307
329
  return v1ConditionText(conditions);
308
330
  }
309
- /** Lower one where-member Expression node → a `<sql, params>` WHERE fragment (deferred params). */
310
- function lowerWhereMember(node, at) {
331
+ /**
332
+ * Lower one where-member Expression node → a `<sql, params, columns>` WHERE fragment (deferred
333
+ * params). `columns[i]` is the table column the i-th emitted param binds — parallel to `params` — so
334
+ * the native-codegen chain types each WHERE-bound `?` from its column (see {@link TxOp.writeMeta}).
335
+ */
336
+ function lowerWhereMember(node, at, dialect) {
311
337
  const op = opKey(node);
312
338
  if (op === undefined)
313
339
  throw new Error(`compileWriteNode: ${at}: a where member must be a single-operator Expression node`);
340
+ if (op === 'in') {
341
+ // Key-set membership — the SAME static single-param predicate the read IN-list uses
342
+ // ({@link inListPredicate}: PG `= ANY(?)`, MySQL/SQLite a JSON subquery), so a `deleteMany`'s
343
+ // statement comes from THIS compiler rather than being assembled by its caller.
344
+ const [col, val] = binOperands(node, op, at);
345
+ const column = columnOf(col, at);
346
+ return { sql: v1ConditionText({ [(0, json_array_1.inListPredicate)(dialect, column)]: PROBE }), params: [val], columns: [column] };
347
+ }
314
348
  if (op === 'eq') {
315
349
  const [col, val] = binOperands(node, op, at);
316
350
  const column = columnOf(col, at);
317
351
  if (val === null)
318
- return { sql: v1ConditionText({ [column]: null }), params: [] };
319
- return { sql: v1ConditionText({ [column]: PROBE }), params: [val] };
352
+ return { sql: v1ConditionText({ [column]: null }), params: [], columns: [] };
353
+ return { sql: v1ConditionText({ [column]: PROBE }), params: [val], columns: [column] };
320
354
  }
321
355
  if (op in CMP_OPS) {
322
356
  const [col, val] = binOperands(node, op, at);
323
357
  const column = columnOf(col, at);
324
358
  if (op === 'ne' && val === null)
325
- return { sql: v1ConditionText({ [`${column} IS NOT NULL`]: true }), params: [] };
326
- return { sql: v1ConditionText({ [`${column} ${CMP_OPS[op]} ?`]: PROBE }), params: [val] };
359
+ return { sql: v1ConditionText({ [`${column} IS NOT NULL`]: true }), params: [], columns: [] };
360
+ return { sql: v1ConditionText({ [`${column} ${CMP_OPS[op]} ?`]: PROBE }), params: [val], columns: [column] };
327
361
  }
328
362
  throw new Error(`compileWriteNode: ${at}: unsupported where operator '${op}' (write path supports eq/ne/lt/le/gt/ge)`);
329
363
  }
330
- function lowerWherePort(ports, at) {
364
+ function lowerWherePort(ports, at, dialect) {
331
365
  const v = ports.where;
332
366
  if (v === undefined)
333
- return { sql: '', params: [] };
367
+ return { sql: '', params: [], columns: [] };
334
368
  if (typeof v !== 'object' || v === null || !('arr' in v) || !Array.isArray(v.arr)) {
335
369
  throw new Error(`compileWriteNode: ${at}: 'where' must be an {arr:[…]} literal`);
336
370
  }
337
371
  const members = v.arr;
338
372
  const parts = [];
339
373
  const params = [];
374
+ const columns = [];
340
375
  members.forEach((m, i) => {
341
- const f = lowerWhereMember(m, `${at}.where[${i}]`);
376
+ const f = lowerWhereMember(m, `${at}.where[${i}]`, dialect);
342
377
  parts.push(f.sql);
343
378
  params.push(...f.params);
379
+ columns.push(...f.columns);
344
380
  });
345
381
  // Join with the SAME ` AND ` connector `DBConditions.compile` uses (parts.join(' AND ')).
346
- return { sql: parts.join(' AND '), params };
382
+ return { sql: parts.join(' AND '), params, columns };
383
+ }
384
+ /** The RETURNING column names (`['id','author_id']`), or `[]` when the op has no RETURNING clause. */
385
+ function returningColumns(ports) {
386
+ const r = stringPort(ports, 'returning');
387
+ return r === undefined ? [] : r.split(',').map((c) => c.trim()).filter((c) => c.length > 0);
347
388
  }
348
389
  function returningTail(ports) {
349
390
  const r = stringPort(ports, 'returning');
350
391
  return r === undefined ? '' : ` RETURNING ${r}`;
351
392
  }
393
+ /**
394
+ * The upsert `ON CONFLICT` / `ON DUPLICATE KEY` tail of an `Insert`, from the `onConflict` (the
395
+ * conflict-target column list) + `onConflictAction` (`'update'` default / `'ignore'`) ports. Absent
396
+ * `onConflict` ⇒ a plain INSERT (no tail). Per-dialect verbs, byte-matching the JSON-batch upsert
397
+ * form (`sqliteInsertJson`): pg/sqlite `ON CONFLICT (k) DO UPDATE SET c = excluded.c` / `DO NOTHING`;
398
+ * mysql `ON DUPLICATE KEY UPDATE c = VALUES(c)` (mysql ignores the target list). The DO-UPDATE sets
399
+ * every inserted column to its excluded value (`onConflictUpdate:'all'` — the v1 builder fallback);
400
+ * setting the key column to itself is a harmless no-op. This shared compiler is what BOTH the runtime
401
+ * (`executeStaticWrite`) and codegen read, so an authored upsert executes AND bakes identically.
402
+ */
403
+ /** Map the `onConflict`/`onConflictAction` ports to the {@link JsonInsertOptions} upsert fields (the
404
+ * batch json_each builder's own shape) — so a batch UPSERT (upsertMany) reuses the SAME conflict verbs
405
+ * as the single upsert (E2). Absent `onConflict` ⇒ a plain batch INSERT. */
406
+ function onConflictJsonOpts(ports, cols) {
407
+ const conflict = stringPort(ports, 'onConflict');
408
+ if (conflict === undefined)
409
+ return {};
410
+ const onConflict = conflict.split(',').map((c) => c.trim()).filter((c) => c.length > 0);
411
+ const action = stringPort(ports, 'onConflictAction') ?? 'update';
412
+ void cols;
413
+ return action === 'ignore' ? { onConflict, onConflictIgnore: true } : { onConflict, onConflictUpdate: 'all' };
414
+ }
415
+ function onConflictTail(dialect, ports, cols) {
416
+ const conflict = stringPort(ports, 'onConflict');
417
+ if (conflict === undefined)
418
+ return '';
419
+ const action = stringPort(ports, 'onConflictAction') ?? 'update';
420
+ const conflictCols = conflict.split(',').map((c) => c.trim()).filter((c) => c.length > 0);
421
+ if (action === 'ignore') {
422
+ return dialect === 'mysql'
423
+ ? ` ON DUPLICATE KEY UPDATE ${conflictCols[0]} = ${conflictCols[0]}` // mysql no-op update = IGNORE-equivalent
424
+ : ` ON CONFLICT (${conflictCols.join(', ')}) DO NOTHING`;
425
+ }
426
+ if (dialect === 'mysql') {
427
+ return ` ON DUPLICATE KEY UPDATE ${cols.map((c) => `${c} = VALUES(${c})`).join(', ')}`;
428
+ }
429
+ return ` ON CONFLICT (${conflictCols.join(', ')}) DO UPDATE SET ${cols.map((c) => `${c} = excluded.${c}`).join(', ')}`;
430
+ }
431
+ /**
432
+ * A per-column raw VALUE SPECIMEN whose {@link import('../../drivers/PostgresSqlBuilder').inferPgType}
433
+ * matches the column's SCHEMA SQL type, so the PG batch UNNEST cast (`?::<pgType>[]`) the original
434
+ * `buildInsert`/`buildUpdateMany` emits is derived from the schema SoT — NOT from the runtime values
435
+ * the symbolic codegen path never sees. int32→number / int64→bigint / bool→boolean / date→Date /
436
+ * float→non-integer number / string(+decimal/uuid/json)→string, reproducing each `inferPgType` branch.
437
+ * Ambiguous only for a REAL column whose live value is integer-valued (v1 would infer `int`, not
438
+ * `numeric`); the bench columns (text/int/bigint) are unambiguous. Unknown SQL types are a hard error
439
+ * (fail-closed) via the §4.1 classifiers.
440
+ */
441
+ function pgTypeSpecimen(sqlType) {
442
+ const klass = (0, coltype_1.sqlTypeToMaterializeClass)(sqlType);
443
+ if (klass === 'int32')
444
+ return 0;
445
+ if (klass === 'int64')
446
+ return 0n;
447
+ if (klass === 'bool')
448
+ return false;
449
+ if (klass === 'date')
450
+ return new Date(0);
451
+ // passthrough: float / decimal(→string) / text / uuid / json — split by the bc scalar.
452
+ const scalar = (0, coltype_1.sqlTypeToBcScalar)(sqlType);
453
+ if (scalar === 'float')
454
+ return 0.5; // a non-integer number ⇒ inferPgType 'numeric'
455
+ return ''; // string family (text / varchar / uuid / decimal / json) ⇒ inferPgType 'text'
456
+ }
457
+ /**
458
+ * Compile a PG BATCH Insert/Update to its byte-identical v1 UNNEST form for the NATIVE codegen path,
459
+ * by driving the ORIGINAL `postgresSqlBuilder` (never a re-roll) with schema-typed specimen records
460
+ * (so the emitted `?::<pgType>[]` casts come from the schema SoT). Each `?` binds a per-column
461
+ * {@link BatchArrayMarker} — the PG (v1) twin of the sqlite/mysql (v2) `{__batchRows}` JSON marker:
462
+ * `refs[i]` is the WHOLE array for column `columns[i]`. codegen types the SAME array-input head off
463
+ * both markers (one shared path); the per-driver seam binds each PG marker as a `<elem>[]` array.
464
+ * Length-independent (the UNNEST text depends only on columns + types + onConflict/returning), so
465
+ * FIXED and bakeable.
466
+ */
467
+ function pgBatchArrayParams(cols, refFor, dialect) {
468
+ return cols.map((c) => ({ __batchArray: { column: c, ref: refFor(c), dialect } }));
469
+ }
470
+ function pgBatchInsert(table, sorted, values, ports, resolve, dialect) {
471
+ const specimen = Object.fromEntries(sorted.map((c) => [c, pgTypeSpecimen(resolve(table, c))]));
472
+ const records = [specimen, specimen]; // 2 rows ⇒ UNNEST branch (records.length > 1)
473
+ const { sql } = PostgresSqlBuilder_1.postgresSqlBuilder.buildInsert({
474
+ tableName: table, columns: sorted, records, rawRecords: records,
475
+ ...onConflictJsonOpts(ports, sorted),
476
+ ...(stringPort(ports, 'returning') !== undefined ? { returning: stringPort(ports, 'returning') } : {}),
477
+ });
478
+ return { sql, params: pgBatchArrayParams(sorted, (c) => values[c], dialect) };
479
+ }
480
+ function pgBatchUpdate(table, keyCols, updateCols, key, set, ports, resolve, dialect) {
481
+ const allCols = [...keyCols, ...updateCols];
482
+ const specimen = Object.fromEntries(allCols.map((c) => [c, pgTypeSpecimen(resolve(table, c))]));
483
+ const records = [specimen, specimen];
484
+ // The pg batch UPDATE aliases the table `AS t` and the value source `AS v(keyCols…)`, so a BARE
485
+ // RETURNING column that is also a key column (in `v`) is ambiguous. v1's `DBModel.updateMany`
486
+ // qualifies RETURNING with the `t` alias via `buildReturning(table, cols, 't')` — reuse the SAME
487
+ // builder so the qualified RETURNING is byte-identical to v1 (never a hand-roll).
488
+ const returningPort = stringPort(ports, 'returning');
489
+ const returning = returningPort === undefined
490
+ ? undefined
491
+ : PostgresSqlBuilder_1.postgresSqlBuilder.buildReturning(table, returningPort.split(',').map((c) => c.trim()).filter((c) => c.length > 0), 't');
492
+ const { sql } = PostgresSqlBuilder_1.postgresSqlBuilder.buildUpdateMany({
493
+ tableName: table, keyColumns: keyCols, updateColumns: updateCols, records, rawRecords: records,
494
+ ...(returning !== undefined ? { returning } : {}),
495
+ });
496
+ // One array param per UNNEST column in [keyCols…, updateCols…] order (matches buildUpdateMany).
497
+ const refFor = (c) => (keyCols.includes(c) ? key[c] : set[c]);
498
+ return { sql, params: pgBatchArrayParams(allCols, refFor, dialect) };
499
+ }
352
500
  /**
353
501
  * Read the optional PRIMARY KEY descriptor ports of an Insert node (for the MySQL RETURNING
354
502
  * emulation). `pk` is a comma-separated column list (`'doc_id'` / `'order_id,line_no'`); `autoInc`
@@ -366,34 +514,22 @@ function pkPort(ports) {
366
514
  const ai = stringPort(ports, 'autoInc');
367
515
  return { columns, autoInc: ai ?? null };
368
516
  }
369
- /** The strip-before-execute PK-hint comment marker the MySQL RETURNING emulation reads. */
370
- const MYSQL_PK_HINT_RE = /\s*\/\*scp:pk=[^*]*\*\//;
371
- /**
372
- * Serialize a {@link TxOp.pk} descriptor into a strip-before-execute SQL comment appended to an
373
- * INSERT…RETURNING op, so the MySQL driver emulation can re-select by the REAL primary key. The
374
- * comment is STRIPPED (with the RETURNING clause) before the INSERT executes, so the executed SQL
375
- * stays byte-clean; it is emitted ONLY into the mysql-dialect bundle (PG/SQLite keep native
376
- * RETURNING and never see it). Format: ` /*scp:pk=col1,col2;ai=<autoIncCol|>* /`.
377
- */
378
- function mysqlPkHint(op) {
379
- if (op.pk === undefined)
380
- return op;
381
- if (!/\breturning\b/i.test(op.sql))
382
- return op;
383
- const hint = ` /*scp:pk=${op.pk.columns.join(',')};ai=${op.pk.autoInc ?? ''}*/`;
384
- return { ...op, sql: op.sql + hint };
385
- }
386
- /** Strip a trailing MySQL PK-hint comment from a rendered SQL (defensive; runtimes strip too). */
387
- function stripMysqlPkHint(sql) {
388
- return sql.replace(MYSQL_PK_HINT_RE, '');
389
- }
390
517
  /**
391
518
  * Compile ONE authored catalog write node (`Insert`/`Update`/`Delete`) into a makeSQL {@link TxOp}
392
519
  * — complete tuned SQL text + DEFERRED Expression-IR params. This is the makeSQL re-expression of
393
520
  * the reduced bridge's `compileNode` for the write path (the tx-DAG base writes). INSERT columns
394
521
  * are CANONICAL (alphabetical) sorted — the v2 write-path SSoT (matches `DBModel._insert`).
395
522
  */
396
- function compileWriteNode(node, dialect = 'sqlite') {
523
+ function compileWriteNode(node, dialect = 'sqlite', resolveColumnType) {
524
+ const op = compileWriteNodeSql(node, dialect, resolveColumnType);
525
+ // MySQL parses no RETURNING: the strip-before-execute pk hint rides the compiled SQL so the mysql
526
+ // connection adapter can re-select the written rows by the REAL key. It is applied HERE, at the one
527
+ // write compiler, rather than by each producer — the producers that forgot the ritual are exactly
528
+ // how a UUID / client-supplied / composite PK came to return no rows at all.
529
+ return dialect === 'mysql' ? (0, mysql_returning_1.mysqlPkHint)(op) : op;
530
+ }
531
+ /** {@link compileWriteNode}'s SQL body — the dialect-neutral descriptor → statement compile. */
532
+ function compileWriteNodeSql(node, dialect, resolveColumnType) {
397
533
  const { component, ports } = node;
398
534
  const table = stringPort(ports, 'table');
399
535
  if (table === undefined)
@@ -407,32 +543,103 @@ function compileWriteNode(node, dialect = 'sqlite') {
407
543
  if (cols.length === 0)
408
544
  throw new Error(`compileWriteNode: Insert requires at least one 'values.<field>' port`);
409
545
  const sorted = [...cols].sort();
546
+ // E3 (#118) BATCH insert (createMany / upsertMany): a `batch:'true'` marker means each
547
+ // `values.<col>` port is a PARALLEL ARRAY of that column's values (bc has no Vec<struct>, so the
548
+ // records ride as one scalar array per column). Reuse the EXISTING json_each batch f_sql (its
549
+ // text depends only on the columns + onConflict/returning — value-length-independent, so FIXED
550
+ // and bakeable); the ONE JSON `?` binds a `{__batchRows}` marker the runtime/codegen build from
551
+ // the parallel arrays at execute time (NOT literalized). One statement for N records.
552
+ if (stringPort(ports, 'batch') === 'true') {
553
+ if (dialect === 'postgres') {
554
+ if (resolveColumnType === undefined)
555
+ throw new Error(`compileWriteNode: batch insert on postgres needs the column-type resolver (schema SoT) to derive the UNNEST element casts — pass it through compileCreateManyBundle/compileWriteBundle (the decorator-adapter write path).`);
556
+ return pgBatchInsert(table, sorted, values, ports, resolveColumnType, dialect);
557
+ }
558
+ const shapeOpts = { tableName: table, columns: sorted, records: [], ...onConflictJsonOpts(ports, sorted), ...(stringPort(ports, 'returning') !== undefined ? { returning: stringPort(ports, 'returning') } : {}) };
559
+ const shape = dialect === 'mysql' ? (0, json_batch_1.mysqlInsertJson)(shapeOpts) : (0, json_batch_1.sqliteInsertJson)(shapeOpts);
560
+ // The ONE json param = a deferred marker carrying the columns + their parallel array refs.
561
+ const marker = { __batchRows: { columns: sorted, refs: sorted.map((c) => values[c]), dialect } };
562
+ // Carry `pk` + `onConflict` (the SAME pkPort/onConflict SSoT the single INSERT path uses) so the
563
+ // mysql RETURNING re-select recovers ALL N written rows by the real key (auto-inc range, or the
564
+ // conflict key for upsertMany). `batch:true` keeps the native tx-chain lowering rejecting this op.
565
+ const pk = pkPort(ports);
566
+ const onConflictCols = stringPort(ports, 'onConflict');
567
+ const writeMeta = { table, bindColumns: sorted, returning: returningColumns(ports), batch: true, ...(onConflictCols !== undefined ? { onConflict: onConflictCols } : {}) };
568
+ return { sql: shape.sql, params: [marker], ...(pk !== undefined ? { pk } : {}), writeMeta };
569
+ }
410
570
  // v1 `DBModel._insert` emits `?::<sqlCast>` PER COLUMN on Postgres (skipping timestamp/date);
411
571
  // the placeholder list is thus per-column, NOT a uniform `?` join (the latent H1 divergence).
412
572
  const placeholders = sorted.map((c) => castPlaceholder(dialect, sqlCastMap, c)).join(', ');
413
- const sql = `INSERT INTO ${table} (${sorted.join(', ')}) VALUES (${placeholders})${returningTail(ports)}`;
573
+ const sql = `INSERT INTO ${table} (${sorted.join(', ')}) VALUES (${placeholders})${onConflictTail(dialect, ports, sorted)}${returningTail(ports)}`;
414
574
  const pk = pkPort(ports);
415
- return { sql, params: sorted.map((c) => values[c]), ...(pk !== undefined ? { pk } : {}) };
575
+ // The `?`s bind the value columns in sorted order (the ON CONFLICT / RETURNING tails add no `?`).
576
+ // `onConflict` (the conflict-target column list) rides on writeMeta ADDITIVELY (no SQL change) so a
577
+ // downstream mysql RETURNING re-select can recover an upserted row by its conflict key (mysql does
578
+ // not report the conflicted-row id) — used by the tx-chain codegen lowering.
579
+ const onConflictCols = stringPort(ports, 'onConflict');
580
+ const writeMeta = { table, bindColumns: sorted, returning: returningColumns(ports), ...(onConflictCols !== undefined ? { onConflict: onConflictCols } : {}) };
581
+ return { sql, params: sorted.map((c) => values[c]), ...(pk !== undefined ? { pk } : {}), writeMeta };
416
582
  }
417
583
  case 'Update': {
418
584
  const set = collectFamily(ports, 'set');
419
585
  const setCols = Object.keys(set);
420
586
  if (setCols.length === 0)
421
587
  throw new Error(`compileWriteNode: Update requires at least one 'set.<field>' port`);
422
- const where = lowerWherePort(ports, 'Update');
423
- if (where.sql === '')
424
- throw new Error(`compileWriteNode: Update requires a 'where' port`);
588
+ // E3 (#118) BATCH update (updateMany): `batch:'true'` — the `key.<col>` family names the match
589
+ // key(s) (parallel arrays), the `set.<col>` family the columns to set (parallel arrays). Reuse
590
+ // the EXISTING json_each/JSON_TABLE batch UPDATE (`sqliteUpdateManyJson`): its text depends only
591
+ // on the key + update columns, so it's FIXED and bakeable. It binds the ONE records-JSON to
592
+ // MULTIPLE `?` (one per SET clause + the WHERE) — each is the SAME `__batchRows` marker, so the
593
+ // runtime evalSpec (and the codegen seam) build the SAME JSON per `?`. ONE statement for N rows.
594
+ if (stringPort(ports, 'batch') === 'true') {
595
+ const key = collectFamily(ports, 'key');
596
+ const keyCols = Object.keys(key).sort();
597
+ if (keyCols.length === 0)
598
+ throw new Error(`compileWriteNode: batch Update requires at least one 'key.<field>' port`);
599
+ const updateCols = [...setCols].sort();
600
+ if (dialect === 'postgres') {
601
+ if (resolveColumnType === undefined)
602
+ throw new Error(`compileWriteNode: batch update on postgres needs the column-type resolver (schema SoT) to derive the UNNEST element casts — pass it through compileCreateManyBundle/compileWriteBundle (the decorator-adapter write path).`);
603
+ return pgBatchUpdate(table, keyCols, updateCols, key, set, ports, resolveColumnType, dialect);
604
+ }
605
+ const shapeOpts = { tableName: table, keyColumns: keyCols, updateColumns: updateCols, records: [], ...(stringPort(ports, 'returning') !== undefined ? { returning: stringPort(ports, 'returning') } : {}) };
606
+ const shape = dialect === 'mysql' ? (0, json_batch_1.mysqlUpdateManyJson)(shapeOpts) : (0, json_batch_1.sqliteUpdateManyJson)(shapeOpts);
607
+ // The JSON carries BOTH the key + update columns (in that order); one marker per `?`.
608
+ const columns = [...keyCols, ...updateCols];
609
+ const refs = [...keyCols.map((c) => key[c]), ...updateCols.map((c) => set[c])];
610
+ const nQ = (shape.sql.match(/\?/g) ?? []).length;
611
+ const marker = { __batchRows: { columns, refs, dialect } };
612
+ // Carry `pk` (the SAME pkPort SSoT) so the mysql RETURNING re-select orders the recovered rows by
613
+ // the real key (matching pg/sqlite RETURNING order). `batch:true` keeps the tx-chain rejecting it.
614
+ const pk = pkPort(ports);
615
+ const writeMeta = { table, bindColumns: columns, returning: returningColumns(ports), batch: true };
616
+ return { sql: shape.sql, params: Array.from({ length: nQ }, () => marker), ...(pk !== undefined ? { pk } : {}), writeMeta };
617
+ }
618
+ // An absent `where` port is an UNCONDITIONAL update — `UPDATE … SET …` with no tail. The port
619
+ // carries the structured `{arr:[…]}` member list and is lowered INLINE here; nothing appends a
620
+ // WHERE afterwards.
621
+ const where = lowerWherePort(ports, 'Update', dialect);
425
622
  // v1 `DBModel._update` emits `<c> = ?::<sqlCast>` PER COLUMN on Postgres (skipping timestamp/date).
426
623
  const setClauses = setCols.map((c) => `${c} = ${castPlaceholder(dialect, sqlCastMap, c)}`).join(', ');
427
- const sql = `UPDATE ${table} SET ${setClauses} WHERE ${where.sql}${returningTail(ports)}`;
428
- return { sql, params: [...setCols.map((c) => set[c]), ...where.params] };
624
+ const whereTail = where.sql === '' ? '' : ` WHERE ${where.sql}`;
625
+ const sql = `UPDATE ${table} SET ${setClauses}${whereTail}${returningTail(ports)}`;
626
+ // The `?`s bind the SET columns (in setCols order) then the WHERE columns (`where.columns`).
627
+ // `pk` rides along (the SAME pkPort SSoT the INSERT paths use) so a dialect without native
628
+ // RETURNING orders its recovered rows by the real key — without it a multi-row UPDATE…RETURNING
629
+ // comes back in the re-select's own order, which need not match PG's.
630
+ const writeMeta = { table, bindColumns: [...setCols, ...where.columns], returning: returningColumns(ports) };
631
+ const pk = pkPort(ports);
632
+ return { sql, params: [...setCols.map((c) => set[c]), ...where.params], ...(pk !== undefined ? { pk } : {}), writeMeta };
429
633
  }
430
634
  case 'Delete': {
431
- const where = lowerWherePort(ports, 'Delete');
432
- if (where.sql === '')
433
- throw new Error(`compileWriteNode: Delete requires a 'where' port`);
434
- const sql = `DELETE FROM ${table} WHERE ${where.sql}${returningTail(ports)}`;
435
- return { sql, params: where.params };
635
+ // As Update: the `where` port is lowered inline. Absent ⇒ an unconditional `DELETE FROM t`.
636
+ const where = lowerWherePort(ports, 'Delete', dialect);
637
+ const sql = `DELETE FROM ${table}${where.sql === '' ? '' : ` WHERE ${where.sql}`}${returningTail(ports)}`;
638
+ // The `?`s bind the WHERE columns; a DELETE has no SET/VALUES params. `pk` rides along so the
639
+ // pre-image re-select is ordered by the real key (as Update above).
640
+ const writeMeta = { table, bindColumns: where.columns, returning: returningColumns(ports) };
641
+ const pk = pkPort(ports);
642
+ return { sql, params: where.params, ...(pk !== undefined ? { pk } : {}), writeMeta };
436
643
  }
437
644
  default:
438
645
  throw new Error(`compileWriteNode: catalog component '${component}' has no write compile (SQL writes: Insert/Update/Delete)`);
@@ -669,40 +876,57 @@ function collectRefHeads(node, heads) {
669
876
  for (const k of keys)
670
877
  collectRefHeads(node[k], heads);
671
878
  }
879
+ /** Coerce a `SqliteDb | ExecutionContext` argument to a ctx (raw driver ⇒ backward-compat wrapper). */
880
+ function asContext(dbOrCtx) {
881
+ return 'connectionFor' in dbOrCtx ? dbOrCtx : (0, exec_context_1.contextForDriver)(dbOrCtx);
882
+ }
672
883
  /** bc evaluates ints to bigint; convert a rendered param to a driver-bindable value. */
673
- function toDriverParam(v) {
884
+ function toDriverParam(v, dialect) {
674
885
  if (typeof v === 'bigint') {
675
886
  if (v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER))
676
887
  return Number(v);
677
888
  return v;
678
889
  }
679
- // An emit payload evaluates to a plain object (`{obj:{…}}`); serialize it to the outbox JSON text.
890
+ // An emit payload evaluates to a plain object (`{obj:{…}}`); serialize it to the outbox JSON text
891
+ // through the ONE JSON-param encoder, so a field that came off a read (a bc `int`, i.e. a BigInt that
892
+ // `JSON.stringify` refuses) is handled the same way the batch and array params handle it.
680
893
  if (v !== null && typeof v === 'object' && !Array.isArray(v))
681
- return JSON.stringify(v);
894
+ return (0, json_array_1.encodeJsonParam)(dialect, v);
682
895
  return v;
683
896
  }
684
897
  /**
685
- * Render a statement's makeSQL op against the tx scope: evaluate each deferred Expression-IR
686
- * param to a concrete value (bc `evaluateExpression`), build a concrete `makeSQL`, then
687
- * assemble + render to the dialect placeholder form. This is the SAME assemble/render the read
688
- * path uses — only the param values come from the tx scope, not a compile-time input.
898
+ * Evaluate a statement's makeSQL op against the tx scope to RAW `?` SQL + driver-coerced params:
899
+ * evaluate each deferred Expression-IR param to a concrete value (bc `evaluateExpression`), assemble
900
+ * the concrete `makeSQL` (splice nested makeSQL), and coerce each param to the driver form
901
+ * ({@link toDriverParam}). Placeholder render (`?`→`$N`) is NOT done here — it is the transport leaf's
902
+ * job (`executeSQL`) so a tx body statement rides the SAME op-independent transport as every read/write.
903
+ * This is the single eval/assemble/coerce for both the sync leaf exec ({@link execStatement}) and the
904
+ * placeholder-rendered {@link renderStatement} (async path + golden export).
905
+ */
906
+ function evalAssemble(op, scope, dialect) {
907
+ const concrete = op.params.map((p) => (0, runtime_1.evaluateExpression)(p, scope));
908
+ const assembled = (0, makesql_1.assembleMakeSQL)({ sql: op.sql, params: concrete });
909
+ return { sql: assembled.sql, params: assembled.params.map((p) => toDriverParam(p, dialect)) };
910
+ }
911
+ /**
912
+ * Render a statement's makeSQL op against the tx scope to the dialect placeholder form ({@link
913
+ * evalAssemble} + `?`→`$N`). Used by the ASYNC tx path ({@link execStatementAsync}) and the golden
914
+ * export {@link renderTxStatement}; the SYNC path renders via the transport leaf ({@link execStatement}).
689
915
  */
690
916
  function renderStatement(op, scope, dialect) {
691
- const concrete = op.params.map((p) => (0, behavior_contracts_1.evaluateExpression)(p, scope));
692
- const node = { sql: op.sql, params: concrete };
693
- const assembled = (0, makesql_1.assembleMakeSQL)(node);
694
- return { sql: (0, handler_1.renderPlaceholders)(assembled.sql, dialect), params: assembled.params.map((p) => toDriverParam(p)) };
695
- }
696
- function execStatement(db, op, scope, dialect) {
697
- const { sql, params } = renderStatement(op, scope, dialect);
698
- const stmt = db.prepare(sql);
917
+ const { sql, params } = evalAssemble(op, scope, dialect);
918
+ return { sql: (0, handler_1.renderPlaceholders)(sql, dialect), params };
919
+ }
920
+ function execStatement(ctx, op, scope, dialect) {
921
+ // A tx body statement rides the SAME op-independent transport as every read/write: the `executeSQL`
922
+ // leaf (placeholder render + param encode + central seam). The tx scope-eval/assemble/coerce is done
923
+ // first (`evalAssemble`); the leaf receives raw `?` SQL + concrete params. `write:true` ⇒ the tx-owned
924
+ // connection resolves via `connectionFor` (§3); a SELECT/RETURNING statement reads rows (`returning`
925
+ // ⇒ the row-returning seam), a bare write runs (the `[{changes,…}]` summary).
926
+ const { sql, params } = evalAssemble(op, scope, dialect);
699
927
  const hasReturn = /\bselect\b/i.test(sql.slice(0, 8)) || /\breturning\b/i.test(sql);
700
- if (hasReturn) {
701
- const rows = stmt.all(...params);
702
- return { rows, changes: rows.length };
703
- }
704
- const info = stmt.run(...params);
705
- return { rows: [], changes: info.changes };
928
+ const out = (0, leaves_1.executeSQL)({ sql, params, write: true, returning: hasReturn, bigint: false }, { exec: ctx, dialect });
929
+ return hasReturn ? { rows: out, changes: out.length } : { rows: [], changes: Number(out[0]?.changes ?? 0) };
706
930
  }
707
931
  function gateShortCircuit(gate, result) {
708
932
  switch (gate) {
@@ -719,9 +943,17 @@ function gateShortCircuit(gate, result) {
719
943
  throw new Error(`scp write: unknown gate rule '${String(gate)}'`);
720
944
  }
721
945
  }
722
- /** Execute a derived {@link TransactionPlan} as ONE real SQLite transaction with gate-first. */
946
+ /**
947
+ * Execute a derived {@link TransactionPlan} as ONE real SQLite transaction with gate-first
948
+ * short-circuit. Accepts a raw {@link SqliteDb} (wrapped via {@link contextForDriver}) or a full
949
+ * {@link ExecutionContext}. The transaction derives a tx-scoped ctx (`withConnection(conn, true)`)
950
+ * that PINS one connection so BEGIN, every body statement, and COMMIT/ROLLBACK all run on the SAME
951
+ * connection (§3, per-execution ownership). For the single-DB SQLite driver the pinned connection
952
+ * is the sole connection; the ownership shows its teeth on the pooled async path
953
+ * ({@link import('../exec-context').withTransactionAsync}), which this mirrors.
954
+ */
723
955
  function executeTransaction(db, plan, input, dialect = 'sqlite') {
724
- db.prepare('BEGIN').run();
956
+ const outer = asContext(db);
725
957
  const executed = [];
726
958
  const scope = { ...input };
727
959
  let entity = null;
@@ -732,38 +964,36 @@ function executeTransaction(db, plan, input, dialect = 'sqlite') {
732
964
  const isBatch = plan.entityFrom === null &&
733
965
  plan.statements.every((s) => s.gate === undefined && s.binds === undefined && s.role === 'body');
734
966
  const returnedRows = [];
967
+ // The tx boundary is the sync `ctx.transaction` ({@link withTransactionSync}: pin conn → BEGIN →
968
+ // body → COMMIT/ROLLBACK, inside `runInTransactionScope`). The body runs the derived plan's
969
+ // statements in order via the `executeSQL` transport leaf ({@link execStatement}); a gate
970
+ // short-circuit signals `{commit:false}` so the boundary ROLLBACKs but still returns the reason.
735
971
  try {
736
- for (const stmt of plan.statements) {
737
- const result = execStatement(db, stmt.op, scope, dialect);
738
- executed.push(stmt.id);
739
- if (stmt.gate !== undefined) {
740
- const reason = gateShortCircuit(stmt.gate, result);
741
- if (reason !== null) {
742
- db.prepare('ROLLBACK').run();
743
- return { committed: false, shortCircuit: { statementId: stmt.id, reason }, entity: null, executed };
972
+ return (0, exec_context_1.withTransactionSync)(outer, (ctx) => {
973
+ for (const stmt of plan.statements) {
974
+ const result = execStatement(ctx, stmt.op, scope, dialect);
975
+ executed.push(stmt.id);
976
+ if (stmt.gate !== undefined) {
977
+ const reason = gateShortCircuit(stmt.gate, result);
978
+ if (reason !== null) {
979
+ return { commit: false, value: { committed: false, shortCircuit: { statementId: stmt.id, reason }, entity: null, executed } };
980
+ }
744
981
  }
982
+ if (stmt.id === plan.entityFrom) {
983
+ entity = result.rows.length > 0 ? result.rows[0] : null;
984
+ if (entity !== null)
985
+ scope[writes_1.ENTITY_ROOT] = entity;
986
+ }
987
+ if (stmt.binds !== undefined && result.rows.length > 0) {
988
+ scope[stmt.binds] = result.rows[0];
989
+ }
990
+ if (isBatch && stmt.role === 'body' && result.rows.length > 0)
991
+ returnedRows.push(result.rows);
745
992
  }
746
- if (stmt.id === plan.entityFrom) {
747
- entity = result.rows.length > 0 ? result.rows[0] : null;
748
- if (entity !== null)
749
- scope[writes_1.ENTITY_ROOT] = entity;
750
- }
751
- if (stmt.binds !== undefined && result.rows.length > 0) {
752
- scope[stmt.binds] = result.rows[0];
753
- }
754
- if (isBatch && stmt.role === 'body' && result.rows.length > 0)
755
- returnedRows.push(result.rows);
756
- }
757
- db.prepare('COMMIT').run();
758
- return { committed: true, entity, executed, ...(returnedRows.length > 0 ? { returnedRows } : {}) };
993
+ return { commit: true, value: { committed: true, entity, executed, ...(returnedRows.length > 0 ? { returnedRows } : {}) } };
994
+ }, dialect);
759
995
  }
760
996
  catch (e) {
761
- try {
762
- db.prepare('ROLLBACK').run();
763
- }
764
- catch {
765
- /* ROLLBACK best-effort; surface the original failure below */
766
- }
767
997
  throw (0, errors_1.mapSqliteError)(e);
768
998
  }
769
999
  }
@@ -785,4 +1015,128 @@ function countingDriver(db) {
785
1015
  function renderTxStatement(op, scope, dialect = 'sqlite') {
786
1016
  return renderStatement(op, scope, dialect);
787
1017
  }
1018
+ // ============================================================================
1019
+ // Phase A (#75) — ASYNC transaction runtime (live PG / MySQL) with PER-EXECUTION
1020
+ // CONNECTION OWNERSHIP. The async twin of `executeTransaction`: it runs the derived
1021
+ // TransactionPlan through `withTransactionAsync`, which acquires ONE pooled connection,
1022
+ // pins it in the ALS ctx, and runs BEGIN…COMMIT on it. Concurrent transactions each own a
1023
+ // DISTINCT connection ⇒ isolated (no shared-slot cross-talk). This is the production async
1024
+ // write-tx path the concurrent-tx isolation test exercises.
1025
+ // ============================================================================
1026
+ /**
1027
+ * Run ONE rendered tx statement through the async seam — the SAME op-independent `executeSQL`
1028
+ * transport leaf every read/write rides. Returns `{ rows, changes }`.
1029
+ *
1030
+ * MySQL's missing RETURNING is NOT handled here: it is a property of the CONNECTION, and the mysql
1031
+ * connection adapter ({@link import('./pool-executor').mysqlConnectionPool}) owns it — so the mode-2
1032
+ * plan executor and a generated (codegen) write reach the identical write→re-select through one
1033
+ * seam, and cannot disagree about the rows a write returns.
1034
+ */
1035
+ async function execStatementAsync(ctx, op, scope, dialect) {
1036
+ // Eval value-specs + assemble to RAW `?` SQL + coerced params (the SSoT `evalAssemble`, shared with
1037
+ // the sync path). The leaf renders `?`→`$N` per dialect after the final SQL is assembled.
1038
+ const { sql, params } = evalAssemble(op, scope, dialect);
1039
+ // A SELECT/RETURNING reads rows; a bare write returns the `[{changes,…}]` summary.
1040
+ const hasReturn = /\bselect\b/i.test(sql.slice(0, 8)) || /\breturning\b/i.test(sql);
1041
+ const out = await (0, leaves_1.executeSQLAsync)({ sql, params, write: true, returning: hasReturn, bigint: false }, { execAsync: ctx, dialect });
1042
+ return hasReturn ? { rows: out, changes: out.length } : { rows: [], changes: Number(out[0]?.changes ?? 0) };
1043
+ }
1044
+ /**
1045
+ * Execute a derived {@link TransactionPlan} on a live PG / MySQL connection with gate-first
1046
+ * short-circuit and **per-execution connection ownership** (§3). The live-DB WRITE entry (#86).
1047
+ *
1048
+ * ## Ambient-tx JOIN vs. its own envelope (the #86 core)
1049
+ *
1050
+ * `withTransactionAsync` (:495) decides the envelope:
1051
+ * - **inside a user `transaction(fn)`** (an outer connection is pinned in the ALS) → the write
1052
+ * JOINS the outer: its statements run on the outer's owned connection with NO new BEGIN/COMMIT,
1053
+ * so N writes in one boundary are ONE physical transaction (one BEGIN, one COMMIT, one conn);
1054
+ * - **outside any transaction** → it opens its OWN BEGIN…COMMIT on a freshly-acquired owned
1055
+ * connection (the per-execution auto-tx; concurrent calls each own a DISTINCT connection ⇒
1056
+ * isolated).
1057
+ *
1058
+ * ## write=tx guard (#86, wired here — fires at runtime, not a standalone helper)
1059
+ *
1060
+ * With `options.guard` (DEFAULT true), a write with NO ambient user tx is REJECTED via {@link
1061
+ * checkWriteAllowed} BEFORE any SQL: `WriteOutsideTransactionError` (no active tx) /
1062
+ * `WriteInReadOnlyContextError` (read-only scope). The check runs at ENTRY — before
1063
+ * `withTransactionAsync` would open the write's own envelope — so it sees the CALLER's scope, exactly
1064
+ * mirroring v1 `DBModel._checkWriteAllowed` (:886, called at the public write entry, not the plan
1065
+ * executor). Inside a `transaction(fn)` the ambient marker is set ⇒ the guard passes and the write
1066
+ * joins. The structured {@link TransactionResult} is identical to the sync {@link executeTransaction}.
1067
+ */
1068
+ function executeTransactionAsync(ctx, plan, input, dialect = 'sqlite', options = {}) {
1069
+ // write=tx guard (#86), enforced at ENTRY so it sees the CALLER's scope — a write inside a user
1070
+ // `transaction(fn)` has the ambient "inside a tx" marker set (⇒ passes + JOINS the outer); a bare
1071
+ // write outside any boundary has no marker (⇒ WriteOutsideTransactionError). Reject as a REJECTED
1072
+ // promise (never a synchronous throw) since this entry is async. Tx-control statements the runtime
1073
+ // itself issues (BEGIN/COMMIT) never pass through here — only data-write plans do.
1074
+ if (options.guard !== false) {
1075
+ // Run the guard, then the plan, as a REJECTED promise on failure (never a synchronous throw).
1076
+ // `checkWriteAllowed` mirrors v1 ordering (:886): read-only is rejected FIRST
1077
+ // (`WriteInReadOnlyContextError`), then a missing active tx (`WriteOutsideTransactionError`).
1078
+ // Inside a user `transaction(fn)` the ambient marker is set ⇒ neither fires and the write JOINS
1079
+ // the outer; outside any boundary the no-active-tx branch throws.
1080
+ return Promise.resolve().then(() => {
1081
+ (0, tx_options_1.checkWriteAllowed)('WRITE', plan.statements[0]?.id);
1082
+ return runTransactionPlanAsync(ctx, plan, input, dialect, options);
1083
+ });
1084
+ }
1085
+ return runTransactionPlanAsync(ctx, plan, input, dialect, options);
1086
+ }
1087
+ /** The plan-executor body of {@link executeTransactionAsync}, split so the guard runs at entry. */
1088
+ function runTransactionPlanAsync(ctx, plan, input, dialect, options) {
1089
+ const isBatch = plan.entityFrom === null &&
1090
+ plan.statements.every((s) => s.gate === undefined && s.binds === undefined && s.role === 'body');
1091
+ return (0, exec_context_1.withTransactionAsync)(ctx, async (txCtx) => {
1092
+ const executed = [];
1093
+ const scope = { ...input };
1094
+ let entity = null;
1095
+ const returnedRows = [];
1096
+ for (const stmt of plan.statements) {
1097
+ const result = await execStatementAsync(txCtx, stmt.op, scope, dialect);
1098
+ executed.push(stmt.id);
1099
+ if (stmt.gate !== undefined) {
1100
+ const reason = gateShortCircuit(stmt.gate, result);
1101
+ if (reason !== null) {
1102
+ // A gate short-circuit ROLLBACKs the whole tx (atomicity): throw the sentinel so
1103
+ // `withTransactionAsync` runs ROLLBACK on the owned connection, then translate to the
1104
+ // structured non-committed result at the boundary.
1105
+ throw new GateShortCircuit(stmt.id, reason, executed);
1106
+ }
1107
+ }
1108
+ if (stmt.id === plan.entityFrom) {
1109
+ entity = result.rows.length > 0 ? result.rows[0] : null;
1110
+ if (entity !== null)
1111
+ scope[writes_1.ENTITY_ROOT] = entity;
1112
+ }
1113
+ if (stmt.binds !== undefined && result.rows.length > 0) {
1114
+ scope[stmt.binds] = result.rows[0];
1115
+ }
1116
+ if (isBatch && stmt.role === 'body' && result.rows.length > 0)
1117
+ returnedRows.push(result.rows);
1118
+ }
1119
+ return { committed: true, entity, executed, ...(returnedRows.length > 0 ? { returnedRows } : {}) };
1120
+ }, options, dialect === 'sqlite' ? 'postgres' : dialect, connection_errors_1.isConnectionError).catch((e) => {
1121
+ // A gate short-circuit is NOT a failure — it ROLLBACKs and reports `committed:false`. Any other
1122
+ // error is a real driver failure (already rolled back by withTransactionAsync) → re-surface.
1123
+ if (e instanceof GateShortCircuit) {
1124
+ return { committed: false, shortCircuit: { statementId: e.statementId, reason: e.reason }, entity: null, executed: e.executed };
1125
+ }
1126
+ throw (0, errors_1.mapSqliteError)(e);
1127
+ });
1128
+ }
1129
+ /** Internal sentinel: a gate short-circuit inside an async tx (ROLLBACK, then report non-committed). */
1130
+ class GateShortCircuit extends Error {
1131
+ statementId;
1132
+ reason;
1133
+ executed;
1134
+ constructor(statementId, reason, executed) {
1135
+ super(`scp write: gate short-circuit at '${statementId}' (${reason})`);
1136
+ this.statementId = statementId;
1137
+ this.reason = reason;
1138
+ this.executed = executed;
1139
+ this.name = 'GateShortCircuit';
1140
+ }
1141
+ }
788
1142
  //# sourceMappingURL=tx.js.map