litedbmodel 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scp/authoring-sql.d.ts +78 -0
- package/dist/scp/authoring-sql.d.ts.map +1 -1
- package/dist/scp/authoring-sql.js +110 -2
- package/dist/scp/authoring-sql.js.map +1 -1
- package/dist/scp/catalog.d.ts +5 -2
- package/dist/scp/catalog.d.ts.map +1 -1
- package/dist/scp/catalog.js +32 -1
- package/dist/scp/catalog.js.map +1 -1
- package/dist/scp/codegen.d.ts +143 -85
- package/dist/scp/codegen.d.ts.map +1 -1
- package/dist/scp/codegen.js +374 -90
- package/dist/scp/codegen.js.map +1 -1
- package/dist/scp/coltype.d.ts +41 -0
- package/dist/scp/coltype.d.ts.map +1 -0
- package/dist/scp/coltype.js +188 -0
- package/dist/scp/coltype.js.map +1 -0
- package/dist/scp/dialect.d.ts +12 -3
- package/dist/scp/dialect.d.ts.map +1 -1
- package/dist/scp/dialect.js +12 -3
- package/dist/scp/dialect.js.map +1 -1
- package/dist/scp/find-filter-guard.d.ts +68 -0
- package/dist/scp/find-filter-guard.d.ts.map +1 -0
- package/dist/scp/find-filter-guard.js +88 -0
- package/dist/scp/find-filter-guard.js.map +1 -0
- package/dist/scp/fragment/assemble.d.ts +61 -0
- package/dist/scp/fragment/assemble.d.ts.map +1 -0
- package/dist/scp/fragment/assemble.js +156 -0
- package/dist/scp/fragment/assemble.js.map +1 -0
- package/dist/scp/fragment/compile-pg.d.ts +107 -0
- package/dist/scp/fragment/compile-pg.d.ts.map +1 -0
- package/dist/scp/fragment/compile-pg.js +142 -0
- package/dist/scp/fragment/compile-pg.js.map +1 -0
- package/dist/scp/fragment/index.d.ts +16 -0
- package/dist/scp/fragment/index.d.ts.map +1 -0
- package/dist/scp/fragment/index.js +34 -0
- package/dist/scp/fragment/index.js.map +1 -0
- package/dist/scp/fragment/model.d.ts +119 -0
- package/dist/scp/fragment/model.d.ts.map +1 -0
- package/dist/scp/fragment/model.js +66 -0
- package/dist/scp/fragment/model.js.map +1 -0
- package/dist/scp/fragment/pg-array-type.d.ts +13 -0
- package/dist/scp/fragment/pg-array-type.d.ts.map +1 -0
- package/dist/scp/fragment/pg-array-type.js +34 -0
- package/dist/scp/fragment/pg-array-type.js.map +1 -0
- package/dist/scp/guard.d.ts +2 -6
- package/dist/scp/guard.d.ts.map +1 -1
- package/dist/scp/guard.js +19 -22
- package/dist/scp/guard.js.map +1 -1
- package/dist/scp/index.cjs +18695 -3921
- package/dist/scp/index.d.ts +41 -24
- package/dist/scp/index.d.ts.map +1 -1
- package/dist/scp/index.js +134 -49
- package/dist/scp/index.js.map +1 -1
- package/dist/scp/index.mjs +4830 -1432
- package/dist/scp/makesql/authoring-compile.d.ts +90 -0
- package/dist/scp/makesql/authoring-compile.d.ts.map +1 -0
- package/dist/scp/makesql/authoring-compile.js +494 -0
- package/dist/scp/makesql/authoring-compile.js.map +1 -0
- package/dist/scp/makesql/compile-crud.d.ts +133 -0
- package/dist/scp/makesql/compile-crud.d.ts.map +1 -0
- package/dist/scp/makesql/compile-crud.js +288 -0
- package/dist/scp/makesql/compile-crud.js.map +1 -0
- package/dist/scp/makesql/compile-relation.d.ts +157 -0
- package/dist/scp/makesql/compile-relation.d.ts.map +1 -0
- package/dist/scp/makesql/compile-relation.js +425 -0
- package/dist/scp/makesql/compile-relation.js.map +1 -0
- package/dist/scp/makesql/compile-select.d.ts +52 -0
- package/dist/scp/makesql/compile-select.d.ts.map +1 -0
- package/dist/scp/makesql/compile-select.js +58 -0
- package/dist/scp/makesql/compile-select.js.map +1 -0
- package/dist/scp/makesql/compile.d.ts +63 -0
- package/dist/scp/makesql/compile.d.ts.map +1 -0
- package/dist/scp/makesql/compile.js +99 -0
- package/dist/scp/makesql/compile.js.map +1 -0
- package/dist/scp/makesql/handler.d.ts +58 -0
- package/dist/scp/makesql/handler.d.ts.map +1 -0
- package/dist/scp/makesql/handler.js +113 -0
- package/dist/scp/makesql/handler.js.map +1 -0
- package/dist/scp/makesql/index.d.ts +33 -0
- package/dist/scp/makesql/index.d.ts.map +1 -0
- package/dist/scp/makesql/index.js +114 -0
- package/dist/scp/makesql/index.js.map +1 -0
- package/dist/scp/makesql/ir.d.ts +42 -0
- package/dist/scp/makesql/ir.d.ts.map +1 -0
- package/dist/scp/makesql/ir.js +66 -0
- package/dist/scp/makesql/ir.js.map +1 -0
- package/dist/scp/makesql/json-array.d.ts +84 -0
- package/dist/scp/makesql/json-array.d.ts.map +1 -0
- package/dist/scp/makesql/json-array.js +118 -0
- package/dist/scp/makesql/json-array.js.map +1 -0
- package/dist/scp/makesql/json-batch.d.ts +102 -0
- package/dist/scp/makesql/json-batch.d.ts.map +1 -0
- package/dist/scp/makesql/json-batch.js +269 -0
- package/dist/scp/makesql/json-batch.js.map +1 -0
- package/dist/scp/makesql/makesql.d.ts +99 -0
- package/dist/scp/makesql/makesql.d.ts.map +1 -0
- package/dist/scp/makesql/makesql.js +122 -0
- package/dist/scp/makesql/makesql.js.map +1 -0
- package/dist/scp/makesql/outtype.d.ts +44 -0
- package/dist/scp/makesql/outtype.d.ts.map +1 -0
- package/dist/scp/makesql/outtype.js +197 -0
- package/dist/scp/makesql/outtype.js.map +1 -0
- package/dist/scp/makesql/pool-executor.d.ts +47 -0
- package/dist/scp/makesql/pool-executor.d.ts.map +1 -0
- package/dist/scp/makesql/pool-executor.js +50 -0
- package/dist/scp/makesql/pool-executor.js.map +1 -0
- package/dist/scp/makesql/static-bundle.d.ts +187 -0
- package/dist/scp/makesql/static-bundle.d.ts.map +1 -0
- package/dist/scp/makesql/static-bundle.js +1113 -0
- package/dist/scp/makesql/static-bundle.js.map +1 -0
- package/dist/scp/makesql/tx.d.ts +216 -0
- package/dist/scp/makesql/tx.d.ts.map +1 -0
- package/dist/scp/makesql/tx.js +788 -0
- package/dist/scp/makesql/tx.js.map +1 -0
- package/dist/scp/makesql/writeouttype.d.ts +66 -0
- package/dist/scp/makesql/writeouttype.d.ts.map +1 -0
- package/dist/scp/makesql/writeouttype.js +195 -0
- package/dist/scp/makesql/writeouttype.js.map +1 -0
- package/dist/scp/relation.d.ts +86 -74
- package/dist/scp/relation.d.ts.map +1 -1
- package/dist/scp/relation.js +186 -120
- package/dist/scp/relation.js.map +1 -1
- package/dist/scp/runtime.d.ts +158 -211
- package/dist/scp/runtime.d.ts.map +1 -1
- package/dist/scp/runtime.js +250 -515
- package/dist/scp/runtime.js.map +1 -1
- package/dist/scp/typed-object.d.ts +12 -1
- package/dist/scp/typed-object.d.ts.map +1 -1
- package/dist/scp/typed-object.js +25 -4
- package/dist/scp/typed-object.js.map +1 -1
- package/docs/proposal/litedbmodel-v2-scp-architecture.md +51 -0
- package/docs/proposal/v0-coverage-matrix.md +157 -0
- package/docs/proposal/v2-completeness-plan.md +90 -0
- package/docs/proposal/v2-sql-parity-checklist.md +162 -0
- package/package.json +3 -3
- package/dist/scp/bridge.d.ts +0 -64
- package/dist/scp/bridge.d.ts.map +0 -1
- package/dist/scp/bridge.js +0 -329
- package/dist/scp/bridge.js.map +0 -1
- package/dist/scp/compile-dialect.d.ts +0 -39
- package/dist/scp/compile-dialect.d.ts.map +0 -1
- package/dist/scp/compile-dialect.js +0 -62
- package/dist/scp/compile-dialect.js.map +0 -1
- package/dist/scp/compile-sqlite.d.ts +0 -114
- package/dist/scp/compile-sqlite.d.ts.map +0 -1
- package/dist/scp/compile-sqlite.js +0 -149
- package/dist/scp/compile-sqlite.js.map +0 -1
- package/dist/scp/ir.d.ts +0 -92
- package/dist/scp/ir.d.ts.map +0 -1
- package/dist/scp/ir.js +0 -28
- package/dist/scp/ir.js.map +0 -1
- package/dist/scp/render.d.ts +0 -40
- package/dist/scp/render.d.ts.map +0 -1
- package/dist/scp/render.js +0 -170
- package/dist/scp/render.js.map +0 -1
- package/dist/scp/write-plan.d.ts +0 -161
- package/dist/scp/write-plan.d.ts.map +0 -1
- package/dist/scp/write-plan.js +0 -528
- package/dist/scp/write-plan.js.map +0 -1
- package/dist/scp/write-runtime.d.ts +0 -80
- package/dist/scp/write-runtime.d.ts.map +0 -1
- package/dist/scp/write-runtime.js +0 -167
- package/dist/scp/write-runtime.js.map +0 -1
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* litedbmodel v2 SCP — write-time relations → ordered SQL transaction plan, re-expressed on
|
|
4
|
+
* the `makeSQL` model (epic #43/#45 Phase B; spec §6 / §14). This REPLACES the reduced-spine
|
|
5
|
+
* `../write-plan.ts` + `../write-runtime.ts` (`CompiledOperation` + `renderOperation` +
|
|
6
|
+
* FragmentTree). No reduced IR is emitted anywhere: every statement's SQL is COMPLETE tuned
|
|
7
|
+
* text (byte-identical to what the v1 write path sends — `SELECT 1 …`, `INSERT … ON CONFLICT
|
|
8
|
+
* DO NOTHING`, `UPDATE … SET c = c + ?`, the outbox INSERT), and its `params` are closed-set
|
|
9
|
+
* bc Expression IR refs resolved at execute time against the accumulated transaction scope.
|
|
10
|
+
*
|
|
11
|
+
* A statement op is exactly a `makeSQL` template: `{ sql, params }` where `sql` carries `?`
|
|
12
|
+
* placeholders and `params` are Expression IR (`{ref:[…]}` / a literal / a `{obj:{…}}` payload)
|
|
13
|
+
* that bc evaluates per statement — then the concrete values assemble + render + bind through
|
|
14
|
+
* the SAME `assembleMakeSQL` / `renderPlaceholders` the read path uses.
|
|
15
|
+
*
|
|
16
|
+
* ## Gate-first is a real execution behavior (spec §6 "Gate First")
|
|
17
|
+
*
|
|
18
|
+
* Each gate statement carries a {@link GateRule} the runtime evaluates AFTER executing it: a
|
|
19
|
+
* failing gate short-circuits — the remaining statements never execute and the tx ROLLBACKs.
|
|
20
|
+
*
|
|
21
|
+
* ## Path lowering (spec §6) — `$.input.*` / `$.entity.*` / `$.ref.<w>.*` → closed-set refs
|
|
22
|
+
*
|
|
23
|
+
* `$.input.<f>` → `{ref:['<f>']}` (bc flat input scope)
|
|
24
|
+
* `$.entity.<f>` → `{ref:['__entity','<f>']}` (the sole body write's RETURNING row)
|
|
25
|
+
* `$.ref.<w>.<f>` → `{ref:['<w>','<f>']}` (a named upstream write's RETURNING row)
|
|
26
|
+
*
|
|
27
|
+
* ## tx-DAG derivation (spec §6 / §14) — gate-first + data-dependency topo order
|
|
28
|
+
*
|
|
29
|
+
* Statements form a data-dependency DAG (`$.ref.<w>.*` consumes an earlier write's RETURNING
|
|
30
|
+
* row via `TxStatement.binds`) with a gate-first constraint. A deterministic Kahn topo sort
|
|
31
|
+
* (stable ascending declaration `seq` tie-break) orders them into a single-transaction plan.
|
|
32
|
+
* Underivable shapes (cycle, dangling `$.ref`, referenced write without RETURNING, duplicate
|
|
33
|
+
* bind, composite `$.entity`) are LOUD rejects — never a silently mis-ordered plan.
|
|
34
|
+
*/
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IN_SENTINEL = void 0;
|
|
37
|
+
exports.literalize = literalize;
|
|
38
|
+
exports.mysqlPkHint = mysqlPkHint;
|
|
39
|
+
exports.stripMysqlPkHint = stripMysqlPkHint;
|
|
40
|
+
exports.compileWriteNode = compileWriteNode;
|
|
41
|
+
exports.deriveTransactionPlan = deriveTransactionPlan;
|
|
42
|
+
exports.deriveBatchPlan = deriveBatchPlan;
|
|
43
|
+
exports.executeTransaction = executeTransaction;
|
|
44
|
+
exports.countingDriver = countingDriver;
|
|
45
|
+
exports.renderTxStatement = renderTxStatement;
|
|
46
|
+
const behavior_contracts_1 = require("behavior-contracts");
|
|
47
|
+
const makesql_1 = require("./makesql");
|
|
48
|
+
const handler_1 = require("./handler");
|
|
49
|
+
const compile_1 = require("./compile");
|
|
50
|
+
const errors_1 = require("../errors");
|
|
51
|
+
const DBConditions_1 = require("../../DBConditions");
|
|
52
|
+
const writes_1 = require("../writes");
|
|
53
|
+
/**
|
|
54
|
+
* Encode a CONCRETE value into a bc Expression IR node that `evaluateExpression` returns VERBATIM.
|
|
55
|
+
*
|
|
56
|
+
* A batch-write op (createMany / updateMany / deleteMany) is compiled by driving the v1 builders
|
|
57
|
+
* (compile-crud), so its `params` are already CONCRETE grouped values — NOT deferred Expression IR.
|
|
58
|
+
* But the tx runtime renders every statement param through bc `evaluateExpression` (spec §6 /
|
|
59
|
+
* `renderStatement`), which fail-closes on a BARE array ("bare array is not an expression") and on a
|
|
60
|
+
* multi-key plain object. The batch INSERT on Postgres binds REAL arrays as single params
|
|
61
|
+
* (`UNNEST($1::int[], …)` → `[[7,8], …]`), so those concrete params must be wrapped in the bc
|
|
62
|
+
* literal-carrier ops so they survive evaluation unchanged:
|
|
63
|
+
* - array → `{arr:[literalize(e)…]}` (bc evaluates each element, so wrap recursively)
|
|
64
|
+
* - object → `{obj:{k:literalize(v)…}}` (a plain map, e.g. a JSON payload object)
|
|
65
|
+
* - scalar → the value itself (null/bool/string/number pass through evaluateExpression verbatim;
|
|
66
|
+
* an integral number becomes a bigint, normalized back at the driver boundary).
|
|
67
|
+
* This reuses ONLY vocabulary every language runtime's bc already implements (`{arr}`/`{obj}` — the
|
|
68
|
+
* emit outbox payload already rides `{obj}` on live PG+MySQL), so a batch write executes through the
|
|
69
|
+
* SAME per-statement tx loop in all five runtimes with NO runtime change.
|
|
70
|
+
*/
|
|
71
|
+
function literalize(value) {
|
|
72
|
+
if (value === null || value === undefined)
|
|
73
|
+
return null;
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return { arr: value.map((e) => literalize(e)) };
|
|
76
|
+
if (typeof value === 'object') {
|
|
77
|
+
const obj = {};
|
|
78
|
+
for (const [k, v] of Object.entries(value))
|
|
79
|
+
obj[k] = literalize(v);
|
|
80
|
+
return { obj };
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
// ── Path → Expression IR ref (closed-set only) ────────────────────────────────
|
|
85
|
+
function pathToRef(value) {
|
|
86
|
+
const p = (0, writes_1.parseEffectPath)(value);
|
|
87
|
+
if (p.root === 'input')
|
|
88
|
+
return { ref: [p.field] };
|
|
89
|
+
if (p.root === 'entity')
|
|
90
|
+
return { ref: [writes_1.ENTITY_ROOT, p.field] };
|
|
91
|
+
return { ref: [p.writeName, p.field] };
|
|
92
|
+
}
|
|
93
|
+
// ── Per-dialect guard INSERT text (the ON CONFLICT DO NOTHING / INSERT IGNORE SSoT) ──
|
|
94
|
+
/**
|
|
95
|
+
* The tuned guard INSERT text for a gate-first `idempotency`/`unique` statement — byte-identical
|
|
96
|
+
* to the v1 write path: SQLite/Postgres emit `INSERT INTO … VALUES (…) ON CONFLICT DO NOTHING`;
|
|
97
|
+
* MySQL emits `INSERT IGNORE INTO … VALUES (…)`.
|
|
98
|
+
*/
|
|
99
|
+
function guardInsert(dialect, table, columns, placeholders) {
|
|
100
|
+
const cols = `(${columns.join(', ')})`;
|
|
101
|
+
if (dialect === 'mysql')
|
|
102
|
+
return `INSERT IGNORE INTO ${table} ${cols} VALUES (${placeholders})`;
|
|
103
|
+
return `INSERT INTO ${table} ${cols} VALUES (${placeholders}) ON CONFLICT DO NOTHING`;
|
|
104
|
+
}
|
|
105
|
+
function makeIdGen() {
|
|
106
|
+
let n = 0;
|
|
107
|
+
return (role) => `tx_${role}_${n++}`;
|
|
108
|
+
}
|
|
109
|
+
/** `requires` → a gate-first existence probe: `SELECT 1 FROM <table> WHERE k1 = ? AND …`. */
|
|
110
|
+
function compileRequires(e, nextId) {
|
|
111
|
+
const cols = Object.keys(e.keys);
|
|
112
|
+
if (cols.length === 0)
|
|
113
|
+
throw new Error(`write-plan: requires on '${e.table}' declares no keys`);
|
|
114
|
+
const whereSql = v1EqualityWhereText(cols);
|
|
115
|
+
const op = {
|
|
116
|
+
sql: `SELECT 1 FROM ${e.table} WHERE ${whereSql}`,
|
|
117
|
+
params: cols.map((c) => pathToRef(e.keys[c])),
|
|
118
|
+
};
|
|
119
|
+
return { id: nextId('requires'), role: 'gate:requires', op, gate: 'existsElseRollback', label: `requires ${e.table}` };
|
|
120
|
+
}
|
|
121
|
+
/** `idempotency` → a gate-first token INSERT ON CONFLICT DO NOTHING (duplicate short-circuits). */
|
|
122
|
+
function compileIdempotency(e, nextId, dialect) {
|
|
123
|
+
const op = {
|
|
124
|
+
sql: guardInsert(dialect, e.table, [e.column], '?'),
|
|
125
|
+
params: [pathToRef(e.token)],
|
|
126
|
+
};
|
|
127
|
+
return { id: nextId('idem'), role: 'gate:idempotency', op, gate: 'insertedElseNoop', label: `idempotency ${e.table}` };
|
|
128
|
+
}
|
|
129
|
+
/** `unique` → a gate-first guard-row INSERT ON CONFLICT DO NOTHING (collision → ROLLBACK). */
|
|
130
|
+
function compileUnique(e, nextId, dialect) {
|
|
131
|
+
const scopeCols = e.scope.map((_, i) => `s${i}`);
|
|
132
|
+
const fieldCols = e.fields.map((_, i) => `f${i}`);
|
|
133
|
+
const cols = ['name', ...scopeCols, ...fieldCols];
|
|
134
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
135
|
+
const params = [
|
|
136
|
+
e.name, // literal discriminator (a closed-set string literal)
|
|
137
|
+
...e.scope.map(pathToRef),
|
|
138
|
+
...e.fields.map(pathToRef),
|
|
139
|
+
];
|
|
140
|
+
const op = { sql: guardInsert(dialect, e.guardTable, cols, placeholders), params };
|
|
141
|
+
return { id: nextId('unique'), role: 'gate:unique', op, gate: 'insertedElseRollback', label: `unique ${e.name}` };
|
|
142
|
+
}
|
|
143
|
+
/** `derive` → a cascade counter update: `UPDATE <table> SET <attr> = <attr> + ? WHERE k = ?`. */
|
|
144
|
+
function compileDerive(e, nextId) {
|
|
145
|
+
const keyCols = Object.keys(e.keys);
|
|
146
|
+
if (keyCols.length === 0)
|
|
147
|
+
throw new Error(`write-plan: derive on '${e.table}' declares no keys`);
|
|
148
|
+
const whereSql = v1EqualityWhereText(keyCols);
|
|
149
|
+
const op = {
|
|
150
|
+
sql: `UPDATE ${e.table} SET ${e.attribute} = ${e.attribute} + ? WHERE ${whereSql}`,
|
|
151
|
+
// SET amount is the first param (before the WHERE keys), matching the v1 param order.
|
|
152
|
+
params: [e.amount, ...keyCols.map((c) => pathToRef(e.keys[c]))],
|
|
153
|
+
};
|
|
154
|
+
return { id: nextId('derive'), role: 'derive', op, label: `derive ${e.table}.${e.attribute}` };
|
|
155
|
+
}
|
|
156
|
+
/** `edges` → M:N intermediate INSERT/DELETE or 1:N FK UPDATE (spec §6 table row `edges`). */
|
|
157
|
+
function compileEdge(e, nextId) {
|
|
158
|
+
if (e.relation === 'm2m') {
|
|
159
|
+
const cols = Object.keys(e.columns);
|
|
160
|
+
if (e.action === 'set') {
|
|
161
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
162
|
+
const op = {
|
|
163
|
+
sql: `INSERT INTO ${e.table} (${cols.join(', ')}) VALUES (${placeholders})`,
|
|
164
|
+
params: cols.map((c) => pathToRef(e.columns[c])),
|
|
165
|
+
};
|
|
166
|
+
return { id: nextId('edge'), role: 'edge', op, label: `edge m2m link ${e.table}` };
|
|
167
|
+
}
|
|
168
|
+
const whereSql = v1EqualityWhereText(cols);
|
|
169
|
+
const op = {
|
|
170
|
+
sql: `DELETE FROM ${e.table} WHERE ${whereSql}`,
|
|
171
|
+
params: cols.map((c) => pathToRef(e.columns[c])),
|
|
172
|
+
};
|
|
173
|
+
return { id: nextId('edge'), role: 'edge', op, label: `edge m2m unlink ${e.table}` };
|
|
174
|
+
}
|
|
175
|
+
// fk: UPDATE <related> SET <fkCol> = ? (or NULL) WHERE <where keys…>
|
|
176
|
+
const setCols = Object.keys(e.columns);
|
|
177
|
+
const whereCols = Object.keys(e.where);
|
|
178
|
+
const setClauses = setCols.map((c) => (e.action === 'set' ? `${c} = ?` : `${c} = NULL`));
|
|
179
|
+
const setParams = e.action === 'set' ? setCols.map((c) => pathToRef(e.columns[c])) : [];
|
|
180
|
+
const whereSql = v1EqualityWhereText(whereCols);
|
|
181
|
+
const op = {
|
|
182
|
+
sql: `UPDATE ${e.table} SET ${setClauses.join(', ')} WHERE ${whereSql}`,
|
|
183
|
+
params: [...setParams, ...whereCols.map((c) => pathToRef(e.where[c]))],
|
|
184
|
+
};
|
|
185
|
+
return { id: nextId('edge'), role: 'edge', op, label: `edge fk ${e.action} ${e.table}` };
|
|
186
|
+
}
|
|
187
|
+
/** `emits` → an outbox INSERT (same tx): `INSERT INTO <outbox>(type, payload) VALUES(?, ?)`. */
|
|
188
|
+
function compileEmit(e, nextId) {
|
|
189
|
+
const payloadObj = {};
|
|
190
|
+
for (const [k, v] of Object.entries(e.payload))
|
|
191
|
+
payloadObj[k] = pathToRef(v);
|
|
192
|
+
const op = {
|
|
193
|
+
sql: `INSERT INTO ${e.outboxTable} (type, payload) VALUES (?, ?)`,
|
|
194
|
+
params: [e.name, { obj: payloadObj }],
|
|
195
|
+
};
|
|
196
|
+
return { id: nextId('emit'), role: 'emit', op, label: `emit ${e.name}` };
|
|
197
|
+
}
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Authored write node → makeSQL TxOp (the makeSQL re-expression of the bridge's
|
|
200
|
+
// `compileNode` for the write path — reads ports STRUCTURALLY, emits complete SQL
|
|
201
|
+
// text + DEFERRED Expression-IR params resolved at tx execute time).
|
|
202
|
+
// ============================================================================
|
|
203
|
+
/** The reserved column-ref path head that marks an IN-list membership (mirrors the bridge). */
|
|
204
|
+
exports.IN_SENTINEL = '@in';
|
|
205
|
+
function stringPort(ports, name) {
|
|
206
|
+
const v = ports[name];
|
|
207
|
+
if (v === undefined)
|
|
208
|
+
return undefined;
|
|
209
|
+
if (typeof v !== 'string')
|
|
210
|
+
throw new Error(`compileWriteNode: port '${name}' must be a literal string in the IR (got ${JSON.stringify(v)})`);
|
|
211
|
+
return v;
|
|
212
|
+
}
|
|
213
|
+
/** `<prefix>.<field>` ports → an ordered `Record<field, ExprNode>` (declaration order). */
|
|
214
|
+
function collectFamily(ports, prefix) {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const k of Object.keys(ports)) {
|
|
217
|
+
if (k.startsWith(`${prefix}.`))
|
|
218
|
+
out[k.slice(prefix.length + 1)] = ports[k];
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Collect the `sqlCast.<field>` port family → `Map<column, sqlCastType>` (the PG per-column cast
|
|
224
|
+
* types, e.g. `jsonb`/`uuid`/`int[]`). This mirrors the `sqlCastMap` v1 `DBModel._insert`/`_update`
|
|
225
|
+
* read from the column metadata (`getSqlCastMap`) to emit `?::<sqlCast>` on Postgres. A write node
|
|
226
|
+
* that declares no cast ports yields an empty map (no cast columns — the common case).
|
|
227
|
+
*/
|
|
228
|
+
function collectSqlCast(ports) {
|
|
229
|
+
const map = new Map();
|
|
230
|
+
for (const k of Object.keys(ports)) {
|
|
231
|
+
if (!k.startsWith('sqlCast.'))
|
|
232
|
+
continue;
|
|
233
|
+
const v = ports[k];
|
|
234
|
+
if (typeof v !== 'string') {
|
|
235
|
+
throw new Error(`compileWriteNode: port '${k}' (a sqlCast type) must be a literal string in the IR (got ${JSON.stringify(v)})`);
|
|
236
|
+
}
|
|
237
|
+
map.set(k.slice('sqlCast.'.length), v);
|
|
238
|
+
}
|
|
239
|
+
return map;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The placeholder text for one written column's value, applying the v1 PER-COLUMN cast on Postgres.
|
|
243
|
+
* Byte-identical to v1 `DBModel._insert` (`src/drivers/PostgresSqlBuilder.ts:289-296`) and `_update`
|
|
244
|
+
* (`src/DBModel.ts:1058-1063`): a PG cast column emits `?::<sqlCast>` via the SAME dialect cast
|
|
245
|
+
* formatter (`formatterFor('postgres')`), SKIPPING `timestamp`/`date` (the pg driver serializes Date
|
|
246
|
+
* objects itself — an explicit cast interferes). MySQL/SQLite emit a bare `?` (v1's dialect-aware
|
|
247
|
+
* `SqlCastFormatter` is identity there — the .rs `::type` leak is NOT reproduced). The tx-write path
|
|
248
|
+
* targets a single dialect at compile, so the cast is resolved here, not deferred.
|
|
249
|
+
*/
|
|
250
|
+
function castPlaceholder(dialect, sqlCastMap, column) {
|
|
251
|
+
const sqlCast = sqlCastMap.get(column);
|
|
252
|
+
if (dialect !== 'postgres' || sqlCast === undefined || sqlCast === 'timestamp' || sqlCast === 'date')
|
|
253
|
+
return '?';
|
|
254
|
+
return (0, compile_1.formatterFor)('postgres')('?', sqlCast);
|
|
255
|
+
}
|
|
256
|
+
function opKey(node) {
|
|
257
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node))
|
|
258
|
+
return undefined;
|
|
259
|
+
const keys = Object.keys(node);
|
|
260
|
+
return keys.length === 1 ? keys[0] : undefined;
|
|
261
|
+
}
|
|
262
|
+
function columnOf(node, ctx) {
|
|
263
|
+
const op = opKey(node);
|
|
264
|
+
if (op !== 'ref' && op !== 'refOpt')
|
|
265
|
+
throw new Error(`compileWriteNode: ${ctx}: the column operand must be a {ref:[…]} path`);
|
|
266
|
+
const path = node[op];
|
|
267
|
+
if (!Array.isArray(path) || path.length === 0 || typeof path[path.length - 1] !== 'string') {
|
|
268
|
+
throw new Error(`compileWriteNode: ${ctx}: column ref path must be a non-empty string path`);
|
|
269
|
+
}
|
|
270
|
+
return path[path.length - 1];
|
|
271
|
+
}
|
|
272
|
+
function binOperands(node, op, at) {
|
|
273
|
+
const args = node[op];
|
|
274
|
+
if (!Array.isArray(args) || args.length !== 2)
|
|
275
|
+
throw new Error(`compileWriteNode: ${at}: '${op}' expects exactly 2 operands`);
|
|
276
|
+
return [args[0], args[1]];
|
|
277
|
+
}
|
|
278
|
+
/** The comparison-operator SQL symbols, keyed by the bc operator name (drives the v1 custom-op key). */
|
|
279
|
+
const CMP_OPS = { lt: '<', le: '<=', gt: '>', ge: '>=', ne: '<>' };
|
|
280
|
+
/** A probe placeholder value fed to the v1 builder so it emits one `?` per bound slot. */
|
|
281
|
+
const PROBE = '__probe__';
|
|
282
|
+
/**
|
|
283
|
+
* Produce a bare condition body's TEXT by driving the ORIGINAL v1 `DBConditions.compile()` — the
|
|
284
|
+
* SAME builder the eager write path and `compile-crud` use, so the WHERE text is byte-identical
|
|
285
|
+
* to v1 by construction (a v2 hand-roll would make the corpus tautological). The throwaway
|
|
286
|
+
* `probe` array absorbs the probe values; the real runtime values are the caller's deferred
|
|
287
|
+
* Expression-IR params. The write path targets a single dialect at render but the WHERE `= ?` /
|
|
288
|
+
* `<op> ?` / `IS NULL` / `IS NOT NULL` forms are dialect-invariant in `DBConditions`.
|
|
289
|
+
*/
|
|
290
|
+
function v1ConditionText(conditions) {
|
|
291
|
+
const probe = [];
|
|
292
|
+
return new DBConditions_1.DBConditions(conditions).compile(probe);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The tuned WHERE-body TEXT for a set of PK-equality columns (`k1 = ? AND k2 = ?`), produced by
|
|
296
|
+
* driving the ORIGINAL v1 `DBConditions.compile()` (via {@link v1ConditionText}) — the SAME
|
|
297
|
+
* builder the read path uses — so the write-tx predicate text is byte-identical to v1 by
|
|
298
|
+
* construction (a v1 predicate regression now moves a tx golden). The throwaway probe params are
|
|
299
|
+
* discarded; the real runtime values are the caller's deferred Expression-IR params, bound 1:1
|
|
300
|
+
* with the `?` placeholders. `DBConditions.compile` iterates the condition object in insertion
|
|
301
|
+
* order and joins with ` AND `, so the `?`/param order matches the caller's column order.
|
|
302
|
+
*/
|
|
303
|
+
function v1EqualityWhereText(columns) {
|
|
304
|
+
const conditions = {};
|
|
305
|
+
for (const c of columns)
|
|
306
|
+
conditions[c] = PROBE;
|
|
307
|
+
return v1ConditionText(conditions);
|
|
308
|
+
}
|
|
309
|
+
/** Lower one where-member Expression node → a `<sql, params>` WHERE fragment (deferred params). */
|
|
310
|
+
function lowerWhereMember(node, at) {
|
|
311
|
+
const op = opKey(node);
|
|
312
|
+
if (op === undefined)
|
|
313
|
+
throw new Error(`compileWriteNode: ${at}: a where member must be a single-operator Expression node`);
|
|
314
|
+
if (op === 'eq') {
|
|
315
|
+
const [col, val] = binOperands(node, op, at);
|
|
316
|
+
const column = columnOf(col, at);
|
|
317
|
+
if (val === null)
|
|
318
|
+
return { sql: v1ConditionText({ [column]: null }), params: [] };
|
|
319
|
+
return { sql: v1ConditionText({ [column]: PROBE }), params: [val] };
|
|
320
|
+
}
|
|
321
|
+
if (op in CMP_OPS) {
|
|
322
|
+
const [col, val] = binOperands(node, op, at);
|
|
323
|
+
const column = columnOf(col, at);
|
|
324
|
+
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] };
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`compileWriteNode: ${at}: unsupported where operator '${op}' (write path supports eq/ne/lt/le/gt/ge)`);
|
|
329
|
+
}
|
|
330
|
+
function lowerWherePort(ports, at) {
|
|
331
|
+
const v = ports.where;
|
|
332
|
+
if (v === undefined)
|
|
333
|
+
return { sql: '', params: [] };
|
|
334
|
+
if (typeof v !== 'object' || v === null || !('arr' in v) || !Array.isArray(v.arr)) {
|
|
335
|
+
throw new Error(`compileWriteNode: ${at}: 'where' must be an {arr:[…]} literal`);
|
|
336
|
+
}
|
|
337
|
+
const members = v.arr;
|
|
338
|
+
const parts = [];
|
|
339
|
+
const params = [];
|
|
340
|
+
members.forEach((m, i) => {
|
|
341
|
+
const f = lowerWhereMember(m, `${at}.where[${i}]`);
|
|
342
|
+
parts.push(f.sql);
|
|
343
|
+
params.push(...f.params);
|
|
344
|
+
});
|
|
345
|
+
// Join with the SAME ` AND ` connector `DBConditions.compile` uses (parts.join(' AND ')).
|
|
346
|
+
return { sql: parts.join(' AND '), params };
|
|
347
|
+
}
|
|
348
|
+
function returningTail(ports) {
|
|
349
|
+
const r = stringPort(ports, 'returning');
|
|
350
|
+
return r === undefined ? '' : ` RETURNING ${r}`;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Read the optional PRIMARY KEY descriptor ports of an Insert node (for the MySQL RETURNING
|
|
354
|
+
* emulation). `pk` is a comma-separated column list (`'doc_id'` / `'order_id,line_no'`); `autoInc`
|
|
355
|
+
* names the single AUTO_INCREMENT column, or is absent for a client-supplied PK. Absent `pk`
|
|
356
|
+
* defaults to null (the emulation then keeps its legacy `WHERE id`/`LAST_INSERT_ID` path, so the
|
|
357
|
+
* existing auto-increment-`id` corpus is unchanged).
|
|
358
|
+
*/
|
|
359
|
+
function pkPort(ports) {
|
|
360
|
+
const pk = stringPort(ports, 'pk');
|
|
361
|
+
if (pk === undefined)
|
|
362
|
+
return undefined;
|
|
363
|
+
const columns = pk.split(',').map((c) => c.trim()).filter((c) => c.length > 0);
|
|
364
|
+
if (columns.length === 0)
|
|
365
|
+
return undefined;
|
|
366
|
+
const ai = stringPort(ports, 'autoInc');
|
|
367
|
+
return { columns, autoInc: ai ?? null };
|
|
368
|
+
}
|
|
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
|
+
/**
|
|
391
|
+
* Compile ONE authored catalog write node (`Insert`/`Update`/`Delete`) into a makeSQL {@link TxOp}
|
|
392
|
+
* — complete tuned SQL text + DEFERRED Expression-IR params. This is the makeSQL re-expression of
|
|
393
|
+
* the reduced bridge's `compileNode` for the write path (the tx-DAG base writes). INSERT columns
|
|
394
|
+
* are CANONICAL (alphabetical) sorted — the v2 write-path SSoT (matches `DBModel._insert`).
|
|
395
|
+
*/
|
|
396
|
+
function compileWriteNode(node, dialect = 'sqlite') {
|
|
397
|
+
const { component, ports } = node;
|
|
398
|
+
const table = stringPort(ports, 'table');
|
|
399
|
+
if (table === undefined)
|
|
400
|
+
throw new Error(`compileWriteNode: ${component} node requires a literal 'table' port`);
|
|
401
|
+
// Per-column PG cast types (`sqlCast.<field>` ports) — drive the v1 `?::<sqlCast>` on Postgres.
|
|
402
|
+
const sqlCastMap = collectSqlCast(ports);
|
|
403
|
+
switch (component) {
|
|
404
|
+
case 'Insert': {
|
|
405
|
+
const values = collectFamily(ports, 'values');
|
|
406
|
+
const cols = Object.keys(values);
|
|
407
|
+
if (cols.length === 0)
|
|
408
|
+
throw new Error(`compileWriteNode: Insert requires at least one 'values.<field>' port`);
|
|
409
|
+
const sorted = [...cols].sort();
|
|
410
|
+
// v1 `DBModel._insert` emits `?::<sqlCast>` PER COLUMN on Postgres (skipping timestamp/date);
|
|
411
|
+
// the placeholder list is thus per-column, NOT a uniform `?` join (the latent H1 divergence).
|
|
412
|
+
const placeholders = sorted.map((c) => castPlaceholder(dialect, sqlCastMap, c)).join(', ');
|
|
413
|
+
const sql = `INSERT INTO ${table} (${sorted.join(', ')}) VALUES (${placeholders})${returningTail(ports)}`;
|
|
414
|
+
const pk = pkPort(ports);
|
|
415
|
+
return { sql, params: sorted.map((c) => values[c]), ...(pk !== undefined ? { pk } : {}) };
|
|
416
|
+
}
|
|
417
|
+
case 'Update': {
|
|
418
|
+
const set = collectFamily(ports, 'set');
|
|
419
|
+
const setCols = Object.keys(set);
|
|
420
|
+
if (setCols.length === 0)
|
|
421
|
+
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`);
|
|
425
|
+
// v1 `DBModel._update` emits `<c> = ?::<sqlCast>` PER COLUMN on Postgres (skipping timestamp/date).
|
|
426
|
+
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] };
|
|
429
|
+
}
|
|
430
|
+
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 };
|
|
436
|
+
}
|
|
437
|
+
default:
|
|
438
|
+
throw new Error(`compileWriteNode: catalog component '${component}' has no write compile (SQL writes: Insert/Update/Delete)`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function compileWriteGroup(base, lifecycle, nextId, dialect, seqRef) {
|
|
442
|
+
const e = lifecycle.effects;
|
|
443
|
+
const nodes = [];
|
|
444
|
+
const mk = (stmt, isGate) => {
|
|
445
|
+
const consumes = refHeadsOf(stmt).filter((h) => h !== writes_1.ENTITY_ROOT && !isInputHead(h, stmt));
|
|
446
|
+
return { stmt, seq: seqRef.n++, consumes, produces: stmt.binds ?? null, isGate };
|
|
447
|
+
};
|
|
448
|
+
for (const r of e.requires ?? [])
|
|
449
|
+
nodes.push(mk(compileRequires(r, nextId), true));
|
|
450
|
+
if (e.idempotency !== undefined)
|
|
451
|
+
nodes.push(mk(compileIdempotency(e.idempotency, nextId, dialect), true));
|
|
452
|
+
for (const u of e.unique ?? [])
|
|
453
|
+
nodes.push(mk(compileUnique(u, nextId, dialect), true));
|
|
454
|
+
const bodyId = nextId('body');
|
|
455
|
+
const bodyStmt = {
|
|
456
|
+
id: bodyId,
|
|
457
|
+
role: 'body',
|
|
458
|
+
op: base.op,
|
|
459
|
+
label: base.label,
|
|
460
|
+
...(base.name !== undefined ? { binds: base.name } : {}),
|
|
461
|
+
};
|
|
462
|
+
nodes.push(mk(bodyStmt, false));
|
|
463
|
+
for (const d of e.derive ?? [])
|
|
464
|
+
nodes.push(mk(compileDerive(d, nextId), false));
|
|
465
|
+
for (const ed of e.edges ?? [])
|
|
466
|
+
nodes.push(mk(compileEdge(ed, nextId), false));
|
|
467
|
+
for (const em of e.emits ?? [])
|
|
468
|
+
nodes.push(mk(compileEmit(em, nextId), false));
|
|
469
|
+
return { nodes, bodyId };
|
|
470
|
+
}
|
|
471
|
+
function topoOrder(nodes) {
|
|
472
|
+
const byName = new Map();
|
|
473
|
+
for (const n of nodes) {
|
|
474
|
+
if (n.produces !== null) {
|
|
475
|
+
if (byName.has(n.produces)) {
|
|
476
|
+
throw new Error(`write-plan: two writes both bind the name '${n.produces}' — write names must be unique in a composite Command.`);
|
|
477
|
+
}
|
|
478
|
+
byName.set(n.produces, n);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const succ = new Map();
|
|
482
|
+
const indeg = new Map();
|
|
483
|
+
const nodeById = new Map();
|
|
484
|
+
for (const n of nodes) {
|
|
485
|
+
nodeById.set(n.stmt.id, n);
|
|
486
|
+
succ.set(n.stmt.id, new Set());
|
|
487
|
+
indeg.set(n.stmt.id, 0);
|
|
488
|
+
}
|
|
489
|
+
const addEdge = (fromId, toId) => {
|
|
490
|
+
if (fromId === toId)
|
|
491
|
+
return;
|
|
492
|
+
const s = succ.get(fromId);
|
|
493
|
+
if (!s.has(toId)) {
|
|
494
|
+
s.add(toId);
|
|
495
|
+
indeg.set(toId, indeg.get(toId) + 1);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
for (const n of nodes) {
|
|
499
|
+
for (const dep of n.consumes) {
|
|
500
|
+
const producer = byName.get(dep);
|
|
501
|
+
if (producer === undefined) {
|
|
502
|
+
throw new Error(`write-plan: statement '${n.stmt.label}' references '$.ref.${dep}.*' but no write in this ` +
|
|
503
|
+
`Command binds the name '${dep}' — a dangling write reference (fail-closed; no silent skip).`);
|
|
504
|
+
}
|
|
505
|
+
addEdge(producer.stmt.id, n.stmt.id);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const gates = nodes.filter((n) => n.isGate);
|
|
509
|
+
const nonGates = nodes.filter((n) => !n.isGate);
|
|
510
|
+
for (const g of gates)
|
|
511
|
+
for (const b of nonGates)
|
|
512
|
+
addEdge(g.stmt.id, b.stmt.id);
|
|
513
|
+
const ordered = [];
|
|
514
|
+
const ready = nodes.filter((n) => indeg.get(n.stmt.id) === 0).sort((a, b) => a.seq - b.seq);
|
|
515
|
+
while (ready.length > 0) {
|
|
516
|
+
const n = ready.shift();
|
|
517
|
+
ordered.push(n.stmt);
|
|
518
|
+
for (const depId of succ.get(n.stmt.id)) {
|
|
519
|
+
indeg.set(depId, indeg.get(depId) - 1);
|
|
520
|
+
if (indeg.get(depId) === 0) {
|
|
521
|
+
const dn = nodeById.get(depId);
|
|
522
|
+
let lo = 0;
|
|
523
|
+
while (lo < ready.length && ready[lo].seq < dn.seq)
|
|
524
|
+
lo++;
|
|
525
|
+
ready.splice(lo, 0, dn);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (ordered.length !== nodes.length) {
|
|
530
|
+
const stuck = nodes.filter((n) => !ordered.includes(n.stmt)).map((n) => n.stmt.label);
|
|
531
|
+
throw new Error(`write-plan: the write-time transaction DAG has a CYCLE (unresolvable ordering among ` +
|
|
532
|
+
`[${stuck.join(', ')}]). This is either a mutual '$.ref' data dependency or a gate that ` +
|
|
533
|
+
`depends on a body row (a gate cannot depend on a not-yet-written row). ESCALATE: the ` +
|
|
534
|
+
`Command's write dependencies are contradictory — no order satisfies both the data ` +
|
|
535
|
+
`dependencies and gate-first. (Fail-closed; the derivation never guesses an order.)`);
|
|
536
|
+
}
|
|
537
|
+
return ordered;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Derive the ORDERED, gate-first {@link TransactionPlan} from a Command's base write(s) + its
|
|
541
|
+
* lifecycle save contract (spec §6 / §14). Single base write = fixed §6 group; multiple named
|
|
542
|
+
* base writes = a topologically-ordered gate-first tx DAG (`$.ref.<name>.*` data dependencies).
|
|
543
|
+
*/
|
|
544
|
+
function deriveTransactionPlan(phase, bases, lifecycle, dialect = 'sqlite') {
|
|
545
|
+
if (bases.length === 0) {
|
|
546
|
+
throw new Error('write-plan: a Command must declare at least one base write (Insert/Update/Delete).');
|
|
547
|
+
}
|
|
548
|
+
const nextId = makeIdGen();
|
|
549
|
+
const seqRef = { n: 0 };
|
|
550
|
+
const composite = bases.length > 1;
|
|
551
|
+
const allNodes = [];
|
|
552
|
+
const bodyIds = [];
|
|
553
|
+
bases.forEach((base, i) => {
|
|
554
|
+
const lc = base.effects !== undefined ? { effects: base.effects } : i === 0 ? lifecycle : { effects: {} };
|
|
555
|
+
const { nodes, bodyId } = compileWriteGroup(base, lc, nextId, dialect, seqRef);
|
|
556
|
+
allNodes.push(...nodes);
|
|
557
|
+
bodyIds.push(bodyId);
|
|
558
|
+
});
|
|
559
|
+
const statements = topoOrder(allNodes);
|
|
560
|
+
const soleBody = bases[0];
|
|
561
|
+
const soleBodyId = bodyIds[0];
|
|
562
|
+
const usesEntity = referencesHead(statements, writes_1.ENTITY_ROOT);
|
|
563
|
+
const soleBodyReturns = /\breturning\b/i.test(soleBody.op.sql);
|
|
564
|
+
if (usesEntity && composite) {
|
|
565
|
+
throw new Error(`write-plan: a composite (multi-write) Command uses '$.entity.*' — ambiguous which write it ` +
|
|
566
|
+
`denotes. Address each write's row by name via '$.ref.<writeName>.<field>' (fail-closed).`);
|
|
567
|
+
}
|
|
568
|
+
if (usesEntity && !soleBodyReturns) {
|
|
569
|
+
throw new Error(`write-plan: a derive/edges/emits stage references '$.entity.*' but the body write ` +
|
|
570
|
+
`('${soleBody.label}') has no RETURNING clause — the written row cannot be exposed to later ` +
|
|
571
|
+
`stages. Add a RETURNING to the base write (fail-closed; no absent-entity default).`);
|
|
572
|
+
}
|
|
573
|
+
const entityFrom = usesEntity || (!composite && soleBodyReturns) ? soleBodyId : null;
|
|
574
|
+
for (const base of bases) {
|
|
575
|
+
if (base.name === undefined)
|
|
576
|
+
continue;
|
|
577
|
+
if (referencesHead(statements, base.name) && !/\breturning\b/i.test(base.op.sql)) {
|
|
578
|
+
throw new Error(`write-plan: write '${base.name}' is referenced as '$.ref.${base.name}.*' by a later ` +
|
|
579
|
+
`statement but its op has no RETURNING clause — its row cannot be bound for downstream ` +
|
|
580
|
+
`writes. Add a RETURNING to write '${base.name}' (fail-closed; no absent-row default).`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { phase, entityFrom, statements, onIdempotentHit: 'rollback' };
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Lower a list of CONCRETE batch-write ops (createMany / updateMany / deleteMany — each already
|
|
587
|
+
* compiled by driving the v1 builders in `compile-crud`, so its `sql` is byte-identical to v1 and
|
|
588
|
+
* its `params` are concrete grouped values) into a gate-free {@link TransactionPlan} of `body`
|
|
589
|
+
* statements, run IN THE DECLARED ORDER as ONE transaction. This is the makeSQL re-expression of a
|
|
590
|
+
* BATCH write: createMany with heterogeneous column-set groups is exactly a composition of several
|
|
591
|
+
* INSERT statements (`_insert:928-975` grouping), and each group's SQL is one v1-copied statement.
|
|
592
|
+
*
|
|
593
|
+
* Each op's concrete params are {@link literalize}d into bc literal-carrier IR so they survive the
|
|
594
|
+
* tx runtime's `evaluateExpression` render pass unchanged (a PG batch binds real arrays). The plan
|
|
595
|
+
* carries NO gates and NO `$.entity`/`$.ref` bindings — a batch write is N independent grouped
|
|
596
|
+
* statements, executed in order (the last statement's RETURNING rows, if any, are the `entity`).
|
|
597
|
+
*/
|
|
598
|
+
function deriveBatchPlan(phase, ops) {
|
|
599
|
+
if (ops.length === 0) {
|
|
600
|
+
throw new Error('write-plan: a batch write must declare at least one statement (createMany/updateMany/deleteMany produced none).');
|
|
601
|
+
}
|
|
602
|
+
const nextId = makeIdGen();
|
|
603
|
+
const statements = ops.map((op, i) => ({
|
|
604
|
+
id: nextId('body'),
|
|
605
|
+
role: 'body',
|
|
606
|
+
op: { sql: op.sql, params: op.params.map((p) => literalize(p)) },
|
|
607
|
+
label: op.label ?? `batch[${i}]`,
|
|
608
|
+
}));
|
|
609
|
+
// A batch write has NO single `$.entity` row — it is N grouped statements, each possibly RETURNING
|
|
610
|
+
// its own rows. `entityFrom` stays null; the runtime accumulates EVERY body statement's RETURNING
|
|
611
|
+
// rows into `TransactionResult.returnedRows` (ordered by group), reproducing v1 `createMany`'s
|
|
612
|
+
// "all created rows" result while `expectedDbState` proves the persisted state.
|
|
613
|
+
return { phase, entityFrom: null, statements, onIdempotentHit: 'rollback' };
|
|
614
|
+
}
|
|
615
|
+
/** Collect the ref-heads (`ref[0]`) a statement's params reference. */
|
|
616
|
+
function refHeadsOf(stmt) {
|
|
617
|
+
const heads = new Set();
|
|
618
|
+
collectRefHeads(stmt.op.params, heads);
|
|
619
|
+
return [...heads];
|
|
620
|
+
}
|
|
621
|
+
/** True if a ref-head is an INPUT head (a bare `$.input.<f>` → 1-element `{ref:['<f>']}`). */
|
|
622
|
+
function isInputHead(head, stmt) {
|
|
623
|
+
return refPathLengthFor(stmt.op.params, head) === 1;
|
|
624
|
+
}
|
|
625
|
+
function refPathLengthFor(node, head) {
|
|
626
|
+
let maxLen = 0;
|
|
627
|
+
const walk = (n) => {
|
|
628
|
+
if (n === null || typeof n !== 'object')
|
|
629
|
+
return;
|
|
630
|
+
if (Array.isArray(n)) {
|
|
631
|
+
for (const el of n)
|
|
632
|
+
walk(el);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const keys = Object.keys(n);
|
|
636
|
+
if (keys.length === 1 && keys[0] === 'ref') {
|
|
637
|
+
const path = n.ref;
|
|
638
|
+
if (Array.isArray(path) && path[0] === head)
|
|
639
|
+
maxLen = Math.max(maxLen, path.length);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
for (const k of keys)
|
|
643
|
+
walk(n[k]);
|
|
644
|
+
};
|
|
645
|
+
walk(node);
|
|
646
|
+
return maxLen;
|
|
647
|
+
}
|
|
648
|
+
function referencesHead(statements, head) {
|
|
649
|
+
const heads = new Set();
|
|
650
|
+
for (const s of statements)
|
|
651
|
+
collectRefHeads(s.op.params, heads);
|
|
652
|
+
return heads.has(head);
|
|
653
|
+
}
|
|
654
|
+
function collectRefHeads(node, heads) {
|
|
655
|
+
if (node === null || typeof node !== 'object')
|
|
656
|
+
return;
|
|
657
|
+
if (Array.isArray(node)) {
|
|
658
|
+
for (const el of node)
|
|
659
|
+
collectRefHeads(el, heads);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const keys = Object.keys(node);
|
|
663
|
+
if (keys.length === 1 && keys[0] === 'ref') {
|
|
664
|
+
const path = node.ref;
|
|
665
|
+
if (Array.isArray(path) && typeof path[0] === 'string')
|
|
666
|
+
heads.add(path[0]);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
for (const k of keys)
|
|
670
|
+
collectRefHeads(node[k], heads);
|
|
671
|
+
}
|
|
672
|
+
/** bc evaluates ints to bigint; convert a rendered param to a driver-bindable value. */
|
|
673
|
+
function toDriverParam(v) {
|
|
674
|
+
if (typeof v === 'bigint') {
|
|
675
|
+
if (v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER))
|
|
676
|
+
return Number(v);
|
|
677
|
+
return v;
|
|
678
|
+
}
|
|
679
|
+
// An emit payload evaluates to a plain object (`{obj:{…}}`); serialize it to the outbox JSON text.
|
|
680
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
681
|
+
return JSON.stringify(v);
|
|
682
|
+
return v;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
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.
|
|
689
|
+
*/
|
|
690
|
+
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);
|
|
699
|
+
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 };
|
|
706
|
+
}
|
|
707
|
+
function gateShortCircuit(gate, result) {
|
|
708
|
+
switch (gate) {
|
|
709
|
+
case 'existsElseRollback':
|
|
710
|
+
return result.rows.length === 0 ? 'requires_absent' : null;
|
|
711
|
+
case 'insertedElseRollback':
|
|
712
|
+
return result.changes === 0 ? 'unique_collision' : null;
|
|
713
|
+
case 'insertedElseNoop':
|
|
714
|
+
return result.changes === 0 ? 'idempotent_duplicate' : null;
|
|
715
|
+
default:
|
|
716
|
+
// Fail-CLOSED on an unknown / forward-incompatible gate rule (aligned with Python + Rust): a
|
|
717
|
+
// corrupt or unrecognized gate MUST NOT silently continue (fail-open would let a malformed gate
|
|
718
|
+
// be skipped and the write COMMIT). Throwing here aborts the tx (the caller's catch ROLLBACKs).
|
|
719
|
+
throw new Error(`scp write: unknown gate rule '${String(gate)}'`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/** Execute a derived {@link TransactionPlan} as ONE real SQLite transaction with gate-first. */
|
|
723
|
+
function executeTransaction(db, plan, input, dialect = 'sqlite') {
|
|
724
|
+
db.prepare('BEGIN').run();
|
|
725
|
+
const executed = [];
|
|
726
|
+
const scope = { ...input };
|
|
727
|
+
let entity = null;
|
|
728
|
+
// Batch mode (createMany/updateMany/deleteMany): a gate-free, ref-free plan (no `$.entity`, no
|
|
729
|
+
// gates, no `$.ref` binds) — a pure list of body statements. Only THEN accumulate each body
|
|
730
|
+
// statement's RETURNING rows in order (a composite Command also has `entityFrom:null` but carries
|
|
731
|
+
// `binds`/gates and is NOT batch — its written rows flow via scope refs, not `returnedRows`).
|
|
732
|
+
const isBatch = plan.entityFrom === null &&
|
|
733
|
+
plan.statements.every((s) => s.gate === undefined && s.binds === undefined && s.role === 'body');
|
|
734
|
+
const returnedRows = [];
|
|
735
|
+
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 };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
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 } : {}) };
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
try {
|
|
762
|
+
db.prepare('ROLLBACK').run();
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
/* ROLLBACK best-effort; surface the original failure below */
|
|
766
|
+
}
|
|
767
|
+
throw (0, errors_1.mapSqliteError)(e);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* A counting {@link SqliteDb} wrapper: forwards every call to the wrapped driver but records
|
|
772
|
+
* each PREPARED SQL string. Tests use it to PROVE gate-first short-circuit.
|
|
773
|
+
*/
|
|
774
|
+
function countingDriver(db) {
|
|
775
|
+
const prepared = [];
|
|
776
|
+
const wrapped = {
|
|
777
|
+
prepare(sql) {
|
|
778
|
+
prepared.push(sql);
|
|
779
|
+
return db.prepare(sql);
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
return { db: wrapped, prepared };
|
|
783
|
+
}
|
|
784
|
+
/** Render one statement op to its dialect SQL text + params (exposed for golden tests). */
|
|
785
|
+
function renderTxStatement(op, scope, dialect = 'sqlite') {
|
|
786
|
+
return renderStatement(op, scope, dialect);
|
|
787
|
+
}
|
|
788
|
+
//# sourceMappingURL=tx.js.map
|