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.
Files changed (97) hide show
  1. package/dist/decorators.d.ts.map +1 -1
  2. package/dist/decorators.js +44 -24
  3. package/dist/decorators.js.map +1 -1
  4. package/dist/drivers/mysql.d.ts.map +1 -1
  5. package/dist/drivers/mysql.js +6 -0
  6. package/dist/drivers/mysql.js.map +1 -1
  7. package/dist/drivers/postgres.d.ts.map +1 -1
  8. package/dist/drivers/postgres.js +7 -0
  9. package/dist/drivers/postgres.js.map +1 -1
  10. package/dist/drivers/sqlite.d.ts +2 -0
  11. package/dist/drivers/sqlite.d.ts.map +1 -1
  12. package/dist/drivers/sqlite.js +37 -2
  13. package/dist/drivers/sqlite.js.map +1 -1
  14. package/dist/scp/authoring.d.ts +66 -3
  15. package/dist/scp/authoring.d.ts.map +1 -1
  16. package/dist/scp/authoring.js +194 -5
  17. package/dist/scp/authoring.js.map +1 -1
  18. package/dist/scp/catalog.d.ts +6 -2
  19. package/dist/scp/catalog.d.ts.map +1 -1
  20. package/dist/scp/catalog.js +17 -2
  21. package/dist/scp/catalog.js.map +1 -1
  22. package/dist/scp/codegen.d.ts +132 -26
  23. package/dist/scp/codegen.d.ts.map +1 -1
  24. package/dist/scp/codegen.js +546 -35
  25. package/dist/scp/codegen.js.map +1 -1
  26. package/dist/scp/coltype.d.ts +124 -0
  27. package/dist/scp/coltype.d.ts.map +1 -0
  28. package/dist/scp/coltype.js +408 -0
  29. package/dist/scp/coltype.js.map +1 -0
  30. package/dist/scp/dialect.d.ts +12 -3
  31. package/dist/scp/dialect.d.ts.map +1 -1
  32. package/dist/scp/dialect.js +12 -3
  33. package/dist/scp/dialect.js.map +1 -1
  34. package/dist/scp/fragment/assemble.d.ts.map +1 -1
  35. package/dist/scp/fragment/assemble.js +10 -2
  36. package/dist/scp/fragment/assemble.js.map +1 -1
  37. package/dist/scp/fragment/compile-pg.js +4 -4
  38. package/dist/scp/guard.d.ts +1 -1
  39. package/dist/scp/guard.d.ts.map +1 -1
  40. package/dist/scp/guard.js +6 -0
  41. package/dist/scp/guard.js.map +1 -1
  42. package/dist/scp/index.cjs +10003 -4917
  43. package/dist/scp/index.d.ts +8 -5
  44. package/dist/scp/index.d.ts.map +1 -1
  45. package/dist/scp/index.js +35 -9
  46. package/dist/scp/index.js.map +1 -1
  47. package/dist/scp/index.mjs +1343 -213
  48. package/dist/scp/makesql/authoring-compile.d.ts.map +1 -1
  49. package/dist/scp/makesql/authoring-compile.js +11 -0
  50. package/dist/scp/makesql/authoring-compile.js.map +1 -1
  51. package/dist/scp/makesql/compile-select.d.ts +8 -1
  52. package/dist/scp/makesql/compile-select.d.ts.map +1 -1
  53. package/dist/scp/makesql/compile-select.js +1 -1
  54. package/dist/scp/makesql/compile-select.js.map +1 -1
  55. package/dist/scp/makesql/index.d.ts +2 -3
  56. package/dist/scp/makesql/index.d.ts.map +1 -1
  57. package/dist/scp/makesql/index.js +6 -6
  58. package/dist/scp/makesql/index.js.map +1 -1
  59. package/dist/scp/makesql/json-batch.d.ts.map +1 -1
  60. package/dist/scp/makesql/json-batch.js +6 -1
  61. package/dist/scp/makesql/json-batch.js.map +1 -1
  62. package/dist/scp/makesql/outtype.d.ts +99 -0
  63. package/dist/scp/makesql/outtype.d.ts.map +1 -0
  64. package/dist/scp/makesql/outtype.js +328 -0
  65. package/dist/scp/makesql/outtype.js.map +1 -0
  66. package/dist/scp/makesql/pool-executor.d.ts +70 -0
  67. package/dist/scp/makesql/pool-executor.d.ts.map +1 -1
  68. package/dist/scp/makesql/pool-executor.js +73 -5
  69. package/dist/scp/makesql/pool-executor.js.map +1 -1
  70. package/dist/scp/makesql/static-bundle.d.ts +41 -30
  71. package/dist/scp/makesql/static-bundle.d.ts.map +1 -1
  72. package/dist/scp/makesql/static-bundle.js +351 -131
  73. package/dist/scp/makesql/static-bundle.js.map +1 -1
  74. package/dist/scp/makesql/tx.d.ts +1 -1
  75. package/dist/scp/makesql/tx.d.ts.map +1 -1
  76. package/dist/scp/makesql/tx.js +48 -3
  77. package/dist/scp/makesql/tx.js.map +1 -1
  78. package/dist/scp/makesql/writeouttype.d.ts +65 -0
  79. package/dist/scp/makesql/writeouttype.d.ts.map +1 -0
  80. package/dist/scp/makesql/writeouttype.js +194 -0
  81. package/dist/scp/makesql/writeouttype.js.map +1 -0
  82. package/dist/scp/relation.d.ts +15 -1
  83. package/dist/scp/relation.d.ts.map +1 -1
  84. package/dist/scp/relation.js +61 -2
  85. package/dist/scp/relation.js.map +1 -1
  86. package/dist/scp/runtime.d.ts +17 -5
  87. package/dist/scp/runtime.d.ts.map +1 -1
  88. package/dist/scp/runtime.js +55 -14
  89. package/dist/scp/runtime.js.map +1 -1
  90. package/docs/BENCHMARK.md +58 -56
  91. package/docs/benchmark-chart.svg +166 -166
  92. package/docs/proposal/litedbmodel-v2-scp-architecture.md +51 -0
  93. package/package.json +8 -2
  94. package/dist/scp/makesql/ir.d.ts +0 -42
  95. package/dist/scp/makesql/ir.d.ts.map +0 -1
  96. package/dist/scp/makesql/ir.js +0 -66
  97. 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
- // Step 0 — the bc-runBehavior read executor: bc composes (map / Φ-merge / wiring),
788
- // makeSQL executes. A read behavior method whose output is a Φ-merge of SQL nodes
789
- // (`{obj:{posts:ref[n0], authors:map(n0)}}`) is executed by rewriting each SQL node
790
- // to a `makeSQL` node carrying a synthetic `__scope` port and running bc `runBehavior`
791
- // with a handler that renders that node's static statements against the evaluated scope.
792
- // This gives map iteration + Φ output + wiring FOR FREE (bc owns orchestration).
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}: rewrite each authored SQL
853
- * body node to a `makeSQL` node carrying a synthetic `__scope` port (bc evaluates it in-scope),
854
- * and compile each node's static statements ONCE. SYMBOLIC — no concrete input. bc will own map
855
- * iteration / wire binding / Φ output at execute time; the handler renders each node's statements.
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
- const surrogateBody = component.body.map((n) => {
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 stmts = statementsById[n.id];
871
- if ('map' in n) {
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 surrogate = { ...component, body: surrogateBody };
877
- const ir = { irVersion: 1, exprVersion: 2, components: [surrogate] };
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
- * Execute a compiled {@link ReadGraph} via bc `runBehavior` + a makeSQL handler: bc drives map
888
- * iteration / wire binding / Φ output; the handler renders each node's static statements against
889
- * the evaluated `__scope` and runs REAL SQLite. Returns the component's Φ output. This is the
890
- * design's "bc composes, makeSQL executes" — the SAME path a per-language runtime follows.
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 executeReadGraph(graph, input, db) {
893
- const handle = (ports, ctx) => {
894
- const stmts = graph.statementsById[ctx.nodeId];
895
- if (stmts === undefined)
896
- return { error: `static-bundle: no statements for node '${ctx.nodeId}'` };
897
- const scope = ports[SCOPE_PORT];
898
- if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
899
- return { error: `static-bundle: node '${ctx.nodeId}' surrogate scope did not evaluate to an object` };
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
- const { sql, params } = renderStatements(stmts, graph.dialect, scope);
902
- try {
903
- const rows = db.prepare(sql).all(...params);
904
- return { ok: rows };
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
- catch (e) {
907
- return { error: (0, errors_1.mapSqliteError)(e).message };
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
- const handlers = { [NODE_COMPONENT]: handle };
911
- const normalized = { ...input };
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 normalized))
914
- normalized[head] = null;
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
- return (0, behavior_contracts_1.runBehavior)(graph.ir, handlers, normalized, graph.name);
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
- * Byte-identical composition (SAME IR, SAME per-node SQL text + params, SAME Φ output), but bc's
926
- * `runBehaviorAsync` drives it: the makeSQL handler returns a Promise, and bc dispatches the
927
- * INDEPENDENT sibling nodes of a plan stage on `runPlanAsync`'s bounded-parallel path (bc#23),
928
- * bounded by the plan's `concurrency` (default 16). Against a pooled async driver (`pg` / `mysql2`,
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 handle = async (ports, ctx) => {
939
- const stmts = graph.statementsById[ctx.nodeId];
940
- if (stmts === undefined)
941
- return { error: `static-bundle: no statements for node '${ctx.nodeId}'` };
942
- const scope = ports[SCOPE_PORT];
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
- return await (0, behavior_contracts_1.runBehaviorAsync)(graph.ir, handlers, normalized, graph.name);
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 surrogate IR order (map nodes reference it).
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)