litedbmodel 2.0.1 → 2.1.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.
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +44 -24
- package/dist/decorators.js.map +1 -1
- package/dist/drivers/mysql.d.ts.map +1 -1
- package/dist/drivers/mysql.js +6 -0
- package/dist/drivers/mysql.js.map +1 -1
- package/dist/drivers/postgres.d.ts.map +1 -1
- package/dist/drivers/postgres.js +7 -0
- package/dist/drivers/postgres.js.map +1 -1
- package/dist/drivers/sqlite.d.ts +2 -0
- package/dist/drivers/sqlite.d.ts.map +1 -1
- package/dist/drivers/sqlite.js +37 -2
- package/dist/drivers/sqlite.js.map +1 -1
- package/dist/scp/authoring.d.ts +66 -3
- package/dist/scp/authoring.d.ts.map +1 -1
- package/dist/scp/authoring.js +194 -5
- package/dist/scp/authoring.js.map +1 -1
- package/dist/scp/catalog.d.ts +6 -2
- package/dist/scp/catalog.d.ts.map +1 -1
- package/dist/scp/catalog.js +17 -2
- package/dist/scp/catalog.js.map +1 -1
- package/dist/scp/codegen.d.ts +132 -26
- package/dist/scp/codegen.d.ts.map +1 -1
- package/dist/scp/codegen.js +546 -35
- package/dist/scp/codegen.js.map +1 -1
- package/dist/scp/coltype.d.ts +124 -0
- package/dist/scp/coltype.d.ts.map +1 -0
- package/dist/scp/coltype.js +408 -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/fragment/assemble.d.ts.map +1 -1
- package/dist/scp/fragment/assemble.js +10 -2
- package/dist/scp/fragment/assemble.js.map +1 -1
- package/dist/scp/fragment/compile-pg.js +4 -4
- package/dist/scp/guard.d.ts +1 -1
- package/dist/scp/guard.d.ts.map +1 -1
- package/dist/scp/guard.js +6 -0
- package/dist/scp/guard.js.map +1 -1
- package/dist/scp/index.cjs +10003 -4917
- package/dist/scp/index.d.ts +8 -5
- package/dist/scp/index.d.ts.map +1 -1
- package/dist/scp/index.js +35 -9
- package/dist/scp/index.js.map +1 -1
- package/dist/scp/index.mjs +1343 -213
- package/dist/scp/makesql/authoring-compile.d.ts.map +1 -1
- package/dist/scp/makesql/authoring-compile.js +11 -0
- package/dist/scp/makesql/authoring-compile.js.map +1 -1
- package/dist/scp/makesql/compile-select.d.ts +8 -1
- package/dist/scp/makesql/compile-select.d.ts.map +1 -1
- package/dist/scp/makesql/compile-select.js +1 -1
- package/dist/scp/makesql/compile-select.js.map +1 -1
- package/dist/scp/makesql/index.d.ts +2 -3
- package/dist/scp/makesql/index.d.ts.map +1 -1
- package/dist/scp/makesql/index.js +6 -6
- package/dist/scp/makesql/index.js.map +1 -1
- package/dist/scp/makesql/json-batch.d.ts.map +1 -1
- package/dist/scp/makesql/json-batch.js +6 -1
- package/dist/scp/makesql/json-batch.js.map +1 -1
- package/dist/scp/makesql/outtype.d.ts +99 -0
- package/dist/scp/makesql/outtype.d.ts.map +1 -0
- package/dist/scp/makesql/outtype.js +328 -0
- package/dist/scp/makesql/outtype.js.map +1 -0
- package/dist/scp/makesql/pool-executor.d.ts +70 -0
- package/dist/scp/makesql/pool-executor.d.ts.map +1 -1
- package/dist/scp/makesql/pool-executor.js +73 -5
- package/dist/scp/makesql/pool-executor.js.map +1 -1
- package/dist/scp/makesql/static-bundle.d.ts +41 -30
- package/dist/scp/makesql/static-bundle.d.ts.map +1 -1
- package/dist/scp/makesql/static-bundle.js +351 -131
- package/dist/scp/makesql/static-bundle.js.map +1 -1
- package/dist/scp/makesql/tx.d.ts +1 -1
- package/dist/scp/makesql/tx.d.ts.map +1 -1
- package/dist/scp/makesql/tx.js +48 -3
- package/dist/scp/makesql/tx.js.map +1 -1
- package/dist/scp/makesql/writeouttype.d.ts +65 -0
- package/dist/scp/makesql/writeouttype.d.ts.map +1 -0
- package/dist/scp/makesql/writeouttype.js +194 -0
- package/dist/scp/makesql/writeouttype.js.map +1 -0
- package/dist/scp/relation.d.ts +15 -1
- package/dist/scp/relation.d.ts.map +1 -1
- package/dist/scp/relation.js +61 -2
- package/dist/scp/relation.js.map +1 -1
- package/dist/scp/runtime.d.ts +17 -5
- package/dist/scp/runtime.d.ts.map +1 -1
- package/dist/scp/runtime.js +55 -14
- package/dist/scp/runtime.js.map +1 -1
- package/docs/BENCHMARK.md +58 -56
- package/docs/benchmark-chart.svg +166 -166
- package/docs/proposal/litedbmodel-v2-scp-architecture.md +51 -0
- package/package.json +8 -2
- package/dist/scp/makesql/ir.d.ts +0 -42
- package/dist/scp/makesql/ir.d.ts.map +0 -1
- package/dist/scp/makesql/ir.js +0 -66
- package/dist/scp/makesql/ir.js.map +0 -1
|
@@ -49,6 +49,9 @@ const tx_1 = require("./tx");
|
|
|
49
49
|
const makesql_1 = require("./makesql");
|
|
50
50
|
const handler_1 = require("./handler");
|
|
51
51
|
const tx_2 = require("./tx");
|
|
52
|
+
const find_filter_guard_1 = require("../find-filter-guard");
|
|
53
|
+
const outtype_1 = require("./outtype");
|
|
54
|
+
const coltype_1 = require("../coltype");
|
|
52
55
|
const errors_1 = require("../errors");
|
|
53
56
|
const DBConditions_1 = require("../../DBConditions");
|
|
54
57
|
const DBValues_1 = require("../../DBValues");
|
|
@@ -59,6 +62,10 @@ const authoring_sql_1 = require("../authoring-sql");
|
|
|
59
62
|
function isMap(n) {
|
|
60
63
|
return 'map' in n;
|
|
61
64
|
}
|
|
65
|
+
/** A bc 0.7.3+ `FanoutNode` (connection fan-out). litedbmodel never emits these. */
|
|
66
|
+
function isFanout(n) {
|
|
67
|
+
return 'fanout' in n;
|
|
68
|
+
}
|
|
62
69
|
function nodeRef(n) {
|
|
63
70
|
return isMap(n) ? { component: n.map.component, ports: n.map.ports } : { component: n.component, ports: n.ports };
|
|
64
71
|
}
|
|
@@ -556,7 +563,7 @@ function compilePrimaryNode(node, dialect) {
|
|
|
556
563
|
if (component === 'Select' || component === 'Count')
|
|
557
564
|
return compileSelectNode(node, dialect);
|
|
558
565
|
if (component === 'Insert' || component === 'Update' || component === 'Delete') {
|
|
559
|
-
const op = (0, tx_2.compileWriteNode)(node);
|
|
566
|
+
const op = (0, tx_2.compileWriteNode)(node, dialect);
|
|
560
567
|
return [{ sql: op.sql, params: op.params }];
|
|
561
568
|
}
|
|
562
569
|
throw new Error(`static-bundle: catalog component '${component}' has no makeSQL compile (SQL CRUD only: Select/Count/Insert/Update/Delete)`);
|
|
@@ -576,6 +583,11 @@ function compileStaticBundle(contract, dialect, entry) {
|
|
|
576
583
|
for (const n of component.body) {
|
|
577
584
|
if ('cond' in n)
|
|
578
585
|
continue;
|
|
586
|
+
if (isFanout(n)) {
|
|
587
|
+
// bc 0.7.3+ `FanoutNode`. litedbmodel never emits fanout — reject fail-closed rather
|
|
588
|
+
// than mistake it for the primary catalog `componentRef`.
|
|
589
|
+
throw new Error(`static-bundle: behavior '${component.name}' node '${n.id}' is a fanout node, not supported by litedbmodel (bc FanoutNode)`);
|
|
590
|
+
}
|
|
579
591
|
if (isMap(n))
|
|
580
592
|
continue;
|
|
581
593
|
if (primary === undefined)
|
|
@@ -640,6 +652,8 @@ function collectRefOptHeads(node, into) {
|
|
|
640
652
|
function skipGuardHeads(n, into) {
|
|
641
653
|
if ('cond' in n)
|
|
642
654
|
return;
|
|
655
|
+
if (isFanout(n))
|
|
656
|
+
return; // bc FanoutNode carries no where-ports SKIP guard (litedbmodel never emits it)
|
|
643
657
|
const ports = isMap(n) ? n.map.ports : n.ports;
|
|
644
658
|
const where = ports.where;
|
|
645
659
|
if (typeof where !== 'object' || where === null || !('arr' in where))
|
|
@@ -673,6 +687,65 @@ function collectRefHeads(node, into) {
|
|
|
673
687
|
for (const v of Object.values(node))
|
|
674
688
|
collectRefHeads(v, into);
|
|
675
689
|
}
|
|
690
|
+
// ── FIND_FILTER leak guard: authored WHERE column keys (fail-closed model-scope enforcement) ──
|
|
691
|
+
/**
|
|
692
|
+
* Collect the top-level COLUMN names an authored `where` port constrains — the LHS column of each
|
|
693
|
+
* eq/ne/cmp member, the IN-sentinel column, and (recursively) the columns of `and`/`or` groups and
|
|
694
|
+
* SKIP-`cond` members. This is exactly the key set v1 compares against when it folds a model's
|
|
695
|
+
* `FIND_FILTER` into the WHERE (`condsToRecord(FIND_FILTER)` keys vs the compiled predicate), so the
|
|
696
|
+
* {@link assertFindFilterFolded} guard can fail-closed when a FIND_FILTER model is SCP-compiled
|
|
697
|
+
* without its scope predicates folded in. Additive sentinel primitives (immediate/dynamic/exists/…)
|
|
698
|
+
* carry no plain column key and are skipped — a scope key must be an ordinary eq/cmp/in the author
|
|
699
|
+
* expressed, matching how a FIND_FILTER predicate manifests.
|
|
700
|
+
*/
|
|
701
|
+
function whereMemberColumns(node, into) {
|
|
702
|
+
const op = opKey(node);
|
|
703
|
+
if (op === undefined)
|
|
704
|
+
return;
|
|
705
|
+
if (op === 'and' || op === 'or') {
|
|
706
|
+
const args = node[op];
|
|
707
|
+
if (Array.isArray(args))
|
|
708
|
+
for (const m of args)
|
|
709
|
+
whereMemberColumns(m, into);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (op === 'cond') {
|
|
713
|
+
const cargs = node.cond;
|
|
714
|
+
if (Array.isArray(cargs) && cargs.length === 3)
|
|
715
|
+
whereMemberColumns(cargs[1], into);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (op === 'eq' || op in CMP_OPS) {
|
|
719
|
+
const args = node[op];
|
|
720
|
+
if (!Array.isArray(args) || args.length !== 2)
|
|
721
|
+
return;
|
|
722
|
+
const inCol = inSentinelColumn(args[0]);
|
|
723
|
+
if (inCol !== undefined) {
|
|
724
|
+
into.add(inCol);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// A plain column ref LHS (a bare eq/cmp). An additive sentinel LHS (immediate/dynamic/exists/…)
|
|
728
|
+
// is NOT a plain column — its column key would be encoded in the primitive, so it is skipped
|
|
729
|
+
// (a FIND_FILTER scope key is always an ordinary eq/cmp/IS NULL the author must fold explicitly).
|
|
730
|
+
const lhsOp = opKey(args[0]);
|
|
731
|
+
if (lhsOp === 'ref' || lhsOp === 'refOpt') {
|
|
732
|
+
const path = args[0][lhsOp];
|
|
733
|
+
if (Array.isArray(path) && path.length > 0 && typeof path[path.length - 1] === 'string' && path[0] !== tx_1.IN_SENTINEL) {
|
|
734
|
+
into.add(path[path.length - 1]);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** The authored WHERE column-key set of a read node's `where` port (for the FIND_FILTER guard). */
|
|
740
|
+
function authoredWhereKeysOf(ports) {
|
|
741
|
+
const keys = new Set();
|
|
742
|
+
const arr = arrPort(ports, 'where');
|
|
743
|
+
if (arr === undefined)
|
|
744
|
+
return keys;
|
|
745
|
+
for (const m of arr)
|
|
746
|
+
whereMemberColumns(m, keys);
|
|
747
|
+
return keys;
|
|
748
|
+
}
|
|
676
749
|
// ── Runtime: evaluate value-specs + skip per-input, assemble, render, execute ──
|
|
677
750
|
/** bc evaluates ints to bigint; convert a rendered param to a driver-bindable value. */
|
|
678
751
|
function toDriverParam(v) {
|
|
@@ -784,181 +857,326 @@ function executeStaticWrite(bundle, input, db) {
|
|
|
784
857
|
}
|
|
785
858
|
}
|
|
786
859
|
// ============================================================================
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
// (`
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
// This
|
|
860
|
+
// #12 — the NATIVE read-graph walker (interpreter-free): litedbmodel composes map /
|
|
861
|
+
// Φ-merge / wiring ITSELF over `compileBehaviors`' REAL `Select`/`Count`/map node IR
|
|
862
|
+
// (no `__makeSqlNode`/`__scope` surrogate, no bc `runBehavior`). Each body node's SQL
|
|
863
|
+
// comes from its `statementsById[id]` fragment templates (rendered against the walk
|
|
864
|
+
// scope); topology (`map.over`/`as`, `plan.groups`, `output` Φ) is read off the real
|
|
865
|
+
// nodes. This mirrors the rust/go/py/php native walkers — one shared orchestration model
|
|
866
|
+
// across all 5 runtimes, `runBehavior` invoked on NONE of them.
|
|
793
867
|
// ============================================================================
|
|
794
|
-
/** The synthetic port that carries a SQL node's render scope (bc evaluates it in-scope). */
|
|
795
|
-
const SCOPE_PORT = '__scope';
|
|
796
|
-
/** The makeSQL catalog leaf name every rewritten SQL node references. */
|
|
797
|
-
const NODE_COMPONENT = '__makeSqlNode';
|
|
798
|
-
/** Collect every ref/refOpt path HEAD used inside an Expression IR node. */
|
|
799
|
-
function collectAllHeads(node, heads) {
|
|
800
|
-
if (node === null || typeof node !== 'object')
|
|
801
|
-
return;
|
|
802
|
-
if (Array.isArray(node)) {
|
|
803
|
-
for (const el of node)
|
|
804
|
-
collectAllHeads(el, heads);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
const keys = Object.keys(node);
|
|
808
|
-
if (keys.length === 1 && (keys[0] === 'ref' || keys[0] === 'refOpt')) {
|
|
809
|
-
const path = node[keys[0]];
|
|
810
|
-
if (Array.isArray(path) && typeof path[0] === 'string')
|
|
811
|
-
heads.add(path[0]);
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
for (const k of keys)
|
|
815
|
-
collectAllHeads(node[k], heads);
|
|
816
|
-
}
|
|
817
|
-
/** Every binding head a node's compiled statements reference (params + skip). */
|
|
818
|
-
function statementHeads(statements) {
|
|
819
|
-
const heads = new Set();
|
|
820
|
-
for (const stmt of statements) {
|
|
821
|
-
for (const p of stmt.params)
|
|
822
|
-
collectAllHeads(p, heads);
|
|
823
|
-
if (stmt.skip !== undefined)
|
|
824
|
-
collectAllHeads(stmt.skip, heads);
|
|
825
|
-
}
|
|
826
|
-
return heads;
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* Build the surrogate `__scope` port for a node: a bc `{obj:{…}}` re-exporting each referenced
|
|
830
|
-
* head as `{ref:[head]}` (excluding the IN-sentinel column marker). bc evaluates this in ITS
|
|
831
|
-
* scope (so `input`, sibling wire results and the `map` element `as` all resolve), then the
|
|
832
|
-
* handler renders the node's static statements against the resulting plain scope.
|
|
833
|
-
*/
|
|
834
|
-
function scopePort(statements) {
|
|
835
|
-
const obj = {};
|
|
836
|
-
for (const head of statementHeads(statements)) {
|
|
837
|
-
if (head === tx_1.IN_SENTINEL)
|
|
838
|
-
continue;
|
|
839
|
-
obj[head] = { ref: [head] };
|
|
840
|
-
}
|
|
841
|
-
return { obj };
|
|
842
|
-
}
|
|
843
868
|
/** Compile ONE authored SQL node (Select or CRUD write) into its static statements. */
|
|
844
869
|
function compileNodeStatements(node, dialect) {
|
|
845
870
|
const component = 'map' in node ? node.map.component : node.component;
|
|
846
871
|
if (component === 'Select' || component === 'Count')
|
|
847
872
|
return compileSelectNode(node, dialect);
|
|
848
|
-
const op = (0, tx_2.compileWriteNode)(node);
|
|
873
|
+
const op = (0, tx_2.compileWriteNode)(node, dialect);
|
|
849
874
|
return [{ sql: op.sql, params: op.params }];
|
|
850
875
|
}
|
|
851
876
|
/**
|
|
852
|
-
* Compile a READ behavior method into a portable {@link ReadGraph}
|
|
853
|
-
*
|
|
854
|
-
* and compile each node's static statements ONCE
|
|
855
|
-
*
|
|
877
|
+
* Compile a READ behavior method into a portable {@link ReadGraph} (#12 de-surrogated): keep
|
|
878
|
+
* `compileBehaviors`' REAL `Select`/`Count`/map body nodes (only additively annotate each with its
|
|
879
|
+
* read `outType`), and compile each node's static `makeSQL` statements ONCE into the `statementsById`
|
|
880
|
+
* sidecar (keyed by the real body-node id). SYMBOLIC — no concrete input. The native walker
|
|
881
|
+
* ({@link executeReadGraph}) owns map iteration / wire binding / Φ output at execute time, rendering
|
|
882
|
+
* each node's statements against the walk scope — never bc `runBehavior`, never a `__scope` surrogate.
|
|
856
883
|
*/
|
|
857
|
-
function compileReadGraph(contract, dialect, entry) {
|
|
884
|
+
function compileReadGraph(contract, dialect, entry, findFilterModel, resolveColumnType) {
|
|
858
885
|
const component = entry ? contract.components.find((c) => c.name === entry) : contract.components[0];
|
|
859
886
|
if (component === undefined)
|
|
860
887
|
throw new Error(`static-bundle: entry component '${entry ?? '<first>'}' not found in contract`);
|
|
888
|
+
// FIND_FILTER fail-closed guard (M2): when a `DBModel`-shaped source that DECLARES a `FIND_FILTER`
|
|
889
|
+
// (soft-delete / tenant scope) is routed through the SCP read compile, its scope predicates MUST be
|
|
890
|
+
// folded into the authored `where` of every read node — the SCP compile has no model context to
|
|
891
|
+
// auto-apply them (see find-filter-guard.ts). If any read node's authored WHERE omits a scope key,
|
|
892
|
+
// `assertFindFilterFolded` throws (never a silent cross-tenant / soft-deleted leak). No model
|
|
893
|
+
// supplied ⇒ nothing to enforce (the guard is a no-op for a model with no FIND_FILTER either way).
|
|
894
|
+
if (findFilterModel !== undefined) {
|
|
895
|
+
for (const n of component.body) {
|
|
896
|
+
if ('cond' in n)
|
|
897
|
+
continue;
|
|
898
|
+
if (isFanout(n)) {
|
|
899
|
+
// bc 0.7.3+ `FanoutNode`. litedbmodel never emits fanout — reject fail-closed rather
|
|
900
|
+
// than mis-read it as a Select/Count ref and skip the FIND_FILTER fold check.
|
|
901
|
+
throw new Error(`static-bundle: read component '${component.name}' node '${n.id}' is a fanout node, not supported by litedbmodel (bc FanoutNode)`);
|
|
902
|
+
}
|
|
903
|
+
const ref = 'map' in n ? n.map : n;
|
|
904
|
+
if (ref.component !== 'Select' && ref.component !== 'Count')
|
|
905
|
+
continue;
|
|
906
|
+
(0, find_filter_guard_1.assertFindFilterFolded)(findFilterModel, authoredWhereKeysOf(ref.ports));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
861
909
|
const statementsById = {};
|
|
862
910
|
for (const n of component.body) {
|
|
863
911
|
if ('cond' in n)
|
|
864
912
|
continue;
|
|
913
|
+
if (isFanout(n)) {
|
|
914
|
+
// bc 0.7.3+ `FanoutNode`. litedbmodel never emits fanout — reject fail-closed rather
|
|
915
|
+
// than feed it to `compileNodeStatements` as a catalog ref.
|
|
916
|
+
throw new Error(`static-bundle: read component '${component.name}' node '${n.id}' is a fanout node, not supported by litedbmodel (bc FanoutNode)`);
|
|
917
|
+
}
|
|
865
918
|
statementsById[n.id] = compileNodeStatements(n, dialect);
|
|
866
919
|
}
|
|
867
|
-
|
|
920
|
+
// SINGLE column-type resolution (issue #59 audit — unified read+codegen): `deriveReadOutTypes`
|
|
921
|
+
// resolves every projected column of the read ONCE (via the SHARED projection parser, all shapes:
|
|
922
|
+
// bare / qualified / aliased; `*` / computed / undeclared → HARD ERROR, spec §4.1) and returns BOTH
|
|
923
|
+
// the codegen `outType`/`outputType` annotations AND the TS read-path `materializersByNode` — two
|
|
924
|
+
// projections of the ONE resolution, so they cannot diverge (there is no second, weaker read-path
|
|
925
|
+
// pass). The column types come from the model's inline `static columns` (carried on the contract as
|
|
926
|
+
// `resolveColumnType`, always present for a read model), so this runs for EVERY read: codegen writes
|
|
927
|
+
// the outType IR annotations, the runtime read path consumes the materializers. Any ambiguity /
|
|
928
|
+
// undeclared column / `*` / computed projection throws here.
|
|
929
|
+
const readTypes = resolveColumnType !== undefined ? (0, outtype_1.deriveReadOutTypes)(component, resolveColumnType) : undefined;
|
|
930
|
+
const materializers = readTypes?.materializersByNode;
|
|
931
|
+
// #12 (de-surrogation): the read-graph IR is `compileBehaviors`' REAL component — the authored
|
|
932
|
+
// `Select`/`Count`/map nodes with their own `table`/`select`/`where`/… ports, NOT a hand-built
|
|
933
|
+
// `__makeSqlNode` + `__scope` surrogate. litedbmodel constructs NO `ComponentGraphIR`/`BodyNode`
|
|
934
|
+
// literal here: it only ADDITIVELY annotates each node with its read `outType` (issue #59) and the
|
|
935
|
+
// component with its `outputType` — the node SHAPE/ports are `compileBehaviors`' output untouched.
|
|
936
|
+
// The compiled SQL fragment templates ride the sidecar `statementsById` (opaque, v1-`DBConditions`-
|
|
937
|
+
// sourced TEXT), keyed by the SAME real body-node id; every language runtime walks this real-node
|
|
938
|
+
// IR natively (topology from the real nodes; SQL from `statementsById[id]`), never bc `runBehavior`.
|
|
939
|
+
const annotatedBody = component.body.map((n) => {
|
|
868
940
|
if ('cond' in n)
|
|
869
941
|
return n;
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
return { ...n, map: { ...n.map, component: NODE_COMPONENT, ports: { [SCOPE_PORT]: scopePort(stmts) } } };
|
|
873
|
-
}
|
|
874
|
-
return { ...n, component: NODE_COMPONENT, ports: { [SCOPE_PORT]: scopePort(stmts) } };
|
|
942
|
+
const outType = readTypes?.byNode.get(n.id);
|
|
943
|
+
return outType !== undefined ? { ...n, outType } : n;
|
|
875
944
|
});
|
|
876
|
-
const
|
|
877
|
-
|
|
945
|
+
const irComponent = {
|
|
946
|
+
...component,
|
|
947
|
+
body: annotatedBody,
|
|
948
|
+
...(readTypes !== undefined ? { outputType: readTypes.outputType } : {}),
|
|
949
|
+
};
|
|
950
|
+
// Preserve `compileBehaviors`' OWN IR envelope (irVersion/exprVersion + any other metadata) — the
|
|
951
|
+
// read graph IR is the compiler's output with ONLY the single entry component swapped for its
|
|
952
|
+
// outType-annotated twin (additive). litedbmodel fabricates no `ComponentGraphIR`/`BodyNode`.
|
|
953
|
+
const ir = { ...contract.ir, components: [irComponent] };
|
|
878
954
|
return {
|
|
879
955
|
dialect,
|
|
880
956
|
name: component.name,
|
|
881
957
|
ir,
|
|
882
958
|
statementsById,
|
|
883
959
|
optionalHeads: [...optionalHeadsOfComponent(component, dialect)],
|
|
960
|
+
...(materializers !== undefined && materializers.size > 0 ? { materializersByNode: Object.fromEntries(materializers) } : {}),
|
|
884
961
|
};
|
|
885
962
|
}
|
|
886
963
|
/**
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
964
|
+
* Apply a node's per-column materializers (issue #59) to its raw driver rows: coerce each cell to
|
|
965
|
+
* the JS form its SQL column type declares (BIGINT→bigint, INT→number, DATE→string, BOOLEAN→
|
|
966
|
+
* boolean). A no-op when the node has no materializer map (read compiled without a column-type
|
|
967
|
+
* resolver) or a column has no entry (defensive — only projected columns are typed). Mutates + returns
|
|
968
|
+
* the same row objects (they are freshly produced by the driver, so mutation is safe).
|
|
891
969
|
*/
|
|
892
|
-
function
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
970
|
+
function materializeRows(rows, cols, safeIntegersOn = false) {
|
|
971
|
+
if (cols === undefined && !safeIntegersOn)
|
|
972
|
+
return rows;
|
|
973
|
+
for (const row of rows) {
|
|
974
|
+
for (const key of Object.keys(row)) {
|
|
975
|
+
const klass = cols?.[key];
|
|
976
|
+
if (klass !== undefined) {
|
|
977
|
+
row[key] = (0, coltype_1.materializeCell)(row[key], klass);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
// Defensive (SQLite safeIntegers mode): when the statement was put in safe-integer mode for a
|
|
981
|
+
// BIGINT column, EVERY int column arrives as a bigint — including columns the resolver didn't
|
|
982
|
+
// type. Narrow a stray bigint back to a number when it fits safely, else to an exact string
|
|
983
|
+
// (so a value never leaks out as a raw bigint, which is not JSON-safe).
|
|
984
|
+
if (safeIntegersOn && typeof row[key] === 'bigint') {
|
|
985
|
+
const v = row[key];
|
|
986
|
+
row[key] = v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString();
|
|
987
|
+
}
|
|
900
988
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
989
|
+
}
|
|
990
|
+
return rows;
|
|
991
|
+
}
|
|
992
|
+
/** True iff any projected column of this node is a 64-bit int (needs the driver in exact mode). */
|
|
993
|
+
function hasInt64(cols) {
|
|
994
|
+
for (const k of Object.keys(cols))
|
|
995
|
+
if (cols[k] === 'int64')
|
|
996
|
+
return true;
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
/** The plan stages (`groups`) as body-index lists, or one-node-per-stage in declaration order. */
|
|
1000
|
+
function planStages(component) {
|
|
1001
|
+
const plan = component.plan;
|
|
1002
|
+
const groups = plan?.groups;
|
|
1003
|
+
if (Array.isArray(groups)) {
|
|
1004
|
+
return groups.map((st) => (Array.isArray(st) ? st.filter((i) => typeof i === 'number') : []));
|
|
1005
|
+
}
|
|
1006
|
+
return component.body.map((_n, i) => [i]);
|
|
1007
|
+
}
|
|
1008
|
+
/** The plan's `concurrency` bound (default 1 — serial when absent/≤1), for the async fan-out. */
|
|
1009
|
+
function planConcurrency(component) {
|
|
1010
|
+
const plan = component.plan;
|
|
1011
|
+
const c = plan?.concurrency;
|
|
1012
|
+
return typeof c === 'number' && c >= 1 ? c : 1;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Bounded-parallel map preserving INPUT ORDER in the result. At most `limit` tasks run at once
|
|
1016
|
+
* (`limit <= 1` ⇒ strictly serial). Deterministic: the output array is indexed by input position,
|
|
1017
|
+
* regardless of completion order — mirrors bc `runPlanAsync`'s in-order commit, so the async walker's
|
|
1018
|
+
* assembled result equals the serial walker's byte-for-byte.
|
|
1019
|
+
*/
|
|
1020
|
+
async function boundedMap(items, limit, fn) {
|
|
1021
|
+
const out = new Array(items.length);
|
|
1022
|
+
const cap = Math.max(1, Math.floor(limit));
|
|
1023
|
+
let next = 0;
|
|
1024
|
+
async function worker() {
|
|
1025
|
+
for (;;) {
|
|
1026
|
+
const i = next++;
|
|
1027
|
+
if (i >= items.length)
|
|
1028
|
+
return;
|
|
1029
|
+
out[i] = await fn(items[i], i);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
await Promise.all(Array.from({ length: Math.min(cap, items.length) }, () => worker()));
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
/** Render + execute a node's `statementsById` fragments against `scope`, sync driver, with #59 de-box. */
|
|
1036
|
+
function execReadNodeSync(graph, nodeId, scope, db) {
|
|
1037
|
+
const stmts = graph.statementsById[nodeId];
|
|
1038
|
+
if (stmts === undefined)
|
|
1039
|
+
throw new Error(`static-bundle: no statements for node '${nodeId}'`);
|
|
1040
|
+
const { sql, params } = renderStatements(stmts, graph.dialect, scope);
|
|
1041
|
+
const cols = graph.materializersByNode?.[nodeId];
|
|
1042
|
+
try {
|
|
1043
|
+
const stmt = db.prepare(sql);
|
|
1044
|
+
// A BIGINT-projecting node runs in SQLite safe-integer mode so int8 comes back as an EXACT
|
|
1045
|
+
// bigint (a JS number would round it — the #59 hole); `materializeRows` then narrows/stringifies.
|
|
1046
|
+
let safeIntegersOn = false;
|
|
1047
|
+
if (cols !== undefined && hasInt64(cols) && typeof stmt.safeIntegers === 'function') {
|
|
1048
|
+
stmt.safeIntegers(true);
|
|
1049
|
+
safeIntegersOn = true;
|
|
905
1050
|
}
|
|
906
|
-
|
|
907
|
-
|
|
1051
|
+
const rows = stmt.all(...params);
|
|
1052
|
+
return materializeRows(rows, cols, safeIntegersOn);
|
|
1053
|
+
}
|
|
1054
|
+
catch (e) {
|
|
1055
|
+
throw (0, errors_1.mapSqliteError)(e);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Compute ONE read-graph body node's value against `base`: a `cond` join, a `map` (per-element
|
|
1059
|
+
* render+execute under the `as` binding), or a componentRef (render+execute). Sync driver. */
|
|
1060
|
+
function computeReadNodeSync(graph, node, base, db) {
|
|
1061
|
+
if (node.cond !== undefined)
|
|
1062
|
+
return (0, behavior_contracts_1.evaluateExpression)(node.cond, base);
|
|
1063
|
+
if (node.map !== undefined) {
|
|
1064
|
+
const over = (0, behavior_contracts_1.evaluateExpression)(node.map.over, base);
|
|
1065
|
+
if (!Array.isArray(over))
|
|
1066
|
+
throw new Error(`static-bundle: map '${node.id}': 'over' is not an array`);
|
|
1067
|
+
const asName = node.map.as ?? '$';
|
|
1068
|
+
const out = [];
|
|
1069
|
+
for (const el of over) {
|
|
1070
|
+
out.push(execReadNodeSync(graph, node.id, { ...base, [asName]: el }, db));
|
|
908
1071
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
return execReadNodeSync(graph, node.id, base, db);
|
|
1075
|
+
}
|
|
1076
|
+
/** Normalize omitted OPTIONAL heads to present-as-null (absent-key SKIP; SSoT-driven). */
|
|
1077
|
+
function normalizeReadInput(graph, input) {
|
|
1078
|
+
const out = { ...input };
|
|
912
1079
|
for (const head of graph.optionalHeads)
|
|
913
|
-
if (!(head in
|
|
914
|
-
|
|
1080
|
+
if (!(head in out))
|
|
1081
|
+
out[head] = null;
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Execute a compiled {@link ReadGraph} via the NATIVE walker (#12): walk `compileBehaviors`' REAL
|
|
1086
|
+
* `Select`/`Count`/map node IR in `plan.groups` stage order — computing each node's rows from its
|
|
1087
|
+
* `statementsById` fragments (rendered against the walk scope) and committing `(nodeId → value)` —
|
|
1088
|
+
* then evaluate the component's `output` Φ expression. NO bc `runBehavior`, NO `__makeSqlNode`
|
|
1089
|
+
* surrogate; litedbmodel owns map iteration / wire binding / Φ output. This is the SAME native
|
|
1090
|
+
* orchestration model the rust/go/py/php runtimes follow — one shape, all 5 languages, interpreter-free.
|
|
1091
|
+
*/
|
|
1092
|
+
function executeReadGraph(graph, input, db) {
|
|
1093
|
+
const component = graph.ir.components[0];
|
|
1094
|
+
const body = component.body;
|
|
1095
|
+
const output = component.output ?? null;
|
|
1096
|
+
const normalized = normalizeReadInput(graph, input);
|
|
915
1097
|
try {
|
|
916
|
-
|
|
1098
|
+
const results = [];
|
|
1099
|
+
for (const stage of planStages(component)) {
|
|
1100
|
+
// Commit stage members in ascending body index (deterministic failure precedence).
|
|
1101
|
+
const ordered = [...stage].sort((a, b) => a - b);
|
|
1102
|
+
const base = { ...normalized };
|
|
1103
|
+
for (const [id, v] of results)
|
|
1104
|
+
base[id] = v;
|
|
1105
|
+
for (const idx of ordered) {
|
|
1106
|
+
const node = body[idx];
|
|
1107
|
+
base[node.id] = results[results.push([node.id, computeReadNodeSync(graph, node, base, db)]) - 1][1];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const scope = { ...normalized };
|
|
1111
|
+
for (const [id, v] of results)
|
|
1112
|
+
scope[id] = v;
|
|
1113
|
+
return (0, behavior_contracts_1.evaluateExpression)(output, scope);
|
|
917
1114
|
}
|
|
918
1115
|
catch (e) {
|
|
919
1116
|
throw reErrorToSqlFailure(e);
|
|
920
1117
|
}
|
|
921
1118
|
}
|
|
1119
|
+
/** Render + execute a node's `statementsById` fragments against `scope`, async driver, with #59 de-box. */
|
|
1120
|
+
async function execReadNodeAsync(graph, nodeId, scope, exec) {
|
|
1121
|
+
const stmts = graph.statementsById[nodeId];
|
|
1122
|
+
if (stmts === undefined)
|
|
1123
|
+
throw new Error(`static-bundle: no statements for node '${nodeId}'`);
|
|
1124
|
+
const { sql, params } = renderStatements(stmts, graph.dialect, scope);
|
|
1125
|
+
try {
|
|
1126
|
+
const rows = await exec(sql, params);
|
|
1127
|
+
// #59 materializers: the pooled PG/MySQL drivers are configured so int8/BIGINT arrive as a string
|
|
1128
|
+
// (→ bigint) and BOOLEAN as a JS boolean; `materializeCell` coerces each to the SQL-type JS form.
|
|
1129
|
+
return materializeRows(rows, graph.materializersByNode?.[nodeId]);
|
|
1130
|
+
}
|
|
1131
|
+
catch (e) {
|
|
1132
|
+
throw (0, errors_1.mapSqliteError)(e);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/** Async twin of {@link computeReadNodeSync}: dispatches a map stage's per-element queries in bounded
|
|
1136
|
+
* parallel (the live PG/MySQL fan-out, capped at `concurrency`); a componentRef runs one query; a
|
|
1137
|
+
* `cond` is pure. */
|
|
1138
|
+
async function computeReadNodeAsync(graph, node, base, exec, concurrency) {
|
|
1139
|
+
if (node.cond !== undefined)
|
|
1140
|
+
return (0, behavior_contracts_1.evaluateExpression)(node.cond, base);
|
|
1141
|
+
if (node.map !== undefined) {
|
|
1142
|
+
const over = (0, behavior_contracts_1.evaluateExpression)(node.map.over, base);
|
|
1143
|
+
if (!Array.isArray(over))
|
|
1144
|
+
throw new Error(`static-bundle: map '${node.id}': 'over' is not an array`);
|
|
1145
|
+
const asName = node.map.as ?? '$';
|
|
1146
|
+
return (await boundedMap(over, concurrency, (el) => execReadNodeAsync(graph, node.id, { ...base, [asName]: el }, exec)));
|
|
1147
|
+
}
|
|
1148
|
+
return execReadNodeAsync(graph, node.id, base, exec);
|
|
1149
|
+
}
|
|
922
1150
|
/**
|
|
923
|
-
* Async twin of {@link executeReadGraph} — the PG / MySQL execution model (#40).
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
* `
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
* each `exec` call resolving on its own pooled connection), that becomes REAL parallel read-relation
|
|
930
|
-
* DB I/O — the plan's declared concurrency finally cashes out as concurrent dispatch. The result is
|
|
931
|
-
* deterministic (bc commits stage outcomes in declaration order), so it equals the serial
|
|
932
|
-
* `executeReadGraph` output exactly; only the wall-clock changes.
|
|
933
|
-
*
|
|
934
|
-
* The conformance bar stays on the SYNC in-proc better-sqlite3 path (`executeReadGraph`); this async
|
|
935
|
-
* path is the live PG/MySQL execution model and is proven by the latency-injecting concurrency test.
|
|
1151
|
+
* Async twin of {@link executeReadGraph} — the live PG / MySQL execution model (#40). Byte-identical
|
|
1152
|
+
* composition (SAME real-node IR, SAME per-node SQL text + params, SAME Φ output); within a plan stage
|
|
1153
|
+
* the INDEPENDENT sibling nodes are dispatched concurrently against the pooled async executor (each
|
|
1154
|
+
* `exec` resolving on its own pooled connection), and a map node's per-element queries fan out in
|
|
1155
|
+
* bounded parallel. The result is deterministic (stage outcomes commit in declaration order), so it
|
|
1156
|
+
* equals the serial `executeReadGraph` output exactly; only the wall-clock changes.
|
|
936
1157
|
*/
|
|
937
1158
|
async function executeReadGraphAsync(graph, input, exec) {
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
|
|
944
|
-
return { error: `static-bundle: node '${ctx.nodeId}' surrogate scope did not evaluate to an object` };
|
|
945
|
-
}
|
|
946
|
-
const { sql, params } = renderStatements(stmts, graph.dialect, scope);
|
|
947
|
-
try {
|
|
948
|
-
const rows = await exec(sql, params);
|
|
949
|
-
return { ok: rows };
|
|
950
|
-
}
|
|
951
|
-
catch (e) {
|
|
952
|
-
return { error: (0, errors_1.mapSqliteError)(e).message };
|
|
953
|
-
}
|
|
954
|
-
};
|
|
955
|
-
const handlers = { [NODE_COMPONENT]: handle };
|
|
956
|
-
const normalized = { ...input };
|
|
957
|
-
for (const head of graph.optionalHeads)
|
|
958
|
-
if (!(head in normalized))
|
|
959
|
-
normalized[head] = null;
|
|
1159
|
+
const component = graph.ir.components[0];
|
|
1160
|
+
const body = component.body;
|
|
1161
|
+
const output = component.output ?? null;
|
|
1162
|
+
const concurrency = planConcurrency(component);
|
|
1163
|
+
const normalized = normalizeReadInput(graph, input);
|
|
960
1164
|
try {
|
|
961
|
-
|
|
1165
|
+
const results = [];
|
|
1166
|
+
for (const stage of planStages(component)) {
|
|
1167
|
+
const ordered = [...stage].sort((a, b) => a - b);
|
|
1168
|
+
const base = { ...normalized };
|
|
1169
|
+
for (const [id, v] of results)
|
|
1170
|
+
base[id] = v;
|
|
1171
|
+
// Independent siblings of a stage run in bounded parallel (capped at the plan concurrency;
|
|
1172
|
+
// concurrency ≤ 1 ⇒ serial); committed in ascending body index order for determinism.
|
|
1173
|
+
const vals = await boundedMap(ordered, concurrency, (idx) => computeReadNodeAsync(graph, body[idx], base, exec, concurrency));
|
|
1174
|
+
ordered.forEach((idx, i) => results.push([body[idx].id, vals[i]]));
|
|
1175
|
+
}
|
|
1176
|
+
const scope = { ...normalized };
|
|
1177
|
+
for (const [id, v] of results)
|
|
1178
|
+
scope[id] = v;
|
|
1179
|
+
return (0, behavior_contracts_1.evaluateExpression)(output, scope);
|
|
962
1180
|
}
|
|
963
1181
|
catch (e) {
|
|
964
1182
|
throw reErrorToSqlFailure(e);
|
|
@@ -980,7 +1198,7 @@ function executeReadBehavior(contract, input, dialect, db, entry) {
|
|
|
980
1198
|
*/
|
|
981
1199
|
function renderReadPrimary(graph, input) {
|
|
982
1200
|
const ids = Object.keys(graph.statementsById);
|
|
983
|
-
// The primary node is the first body node in the
|
|
1201
|
+
// The primary node is the first body node in the real-node IR order (map nodes reference it).
|
|
984
1202
|
const bodyIds = graph.ir.components[0].body.map((n) => n.id).filter((id) => ids.includes(id));
|
|
985
1203
|
const primaryId = bodyIds[0];
|
|
986
1204
|
if (primaryId === undefined)
|
|
@@ -1003,6 +1221,8 @@ function optionalHeadsOfComponent(component, dialect) {
|
|
|
1003
1221
|
for (const n of component.body) {
|
|
1004
1222
|
if ('cond' in n)
|
|
1005
1223
|
continue;
|
|
1224
|
+
if (isFanout(n))
|
|
1225
|
+
continue; // bc FanoutNode: litedbmodel never emits it; carries no ref-opt heads.
|
|
1006
1226
|
const stmts = compileNodeStatements(n, dialect);
|
|
1007
1227
|
for (const stmt of stmts) {
|
|
1008
1228
|
for (const p of stmt.params)
|