@factoidal/core 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,137 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-09-05
4
+
5
+ **A full-text index, a geometry index, extension functions from
6
+ JavaScript, and a planner that no longer reads the whole store for a
7
+ `REGEX`.** Measured end to end on the largest corpus we have: 7,315,251
8
+ quads over 204 named graphs, 3,286 blocks, a 1.0 GB generation, queried
9
+ through a store handle on plain `node` with no flags.
10
+
11
+ | search | rows | time |
12
+ |---|---|---|
13
+ | `water` | 5 | 645 ms |
14
+ | `glacier` | **0** | 670 ms |
15
+ | `bicycle` | 2 | 617 ms |
16
+
17
+ A miss costs what a hit costs, because both are index lookups.
18
+
19
+ ### The planner stopped reading the whole store
20
+
21
+ `REGEX`, `REPLACE`, `IRI()`, `NOW()`, the digest functions, aggregates in
22
+ a filter position, the triple-term accessors and every SPARQL 1.1
23
+ section 17.6 extension call each made the planner abandon predicate
24
+ selection and take every block in the manifest. On a 119-block store a
25
+ `REGEX` filter selected 119 artifacts where the same query with
26
+ `CONTAINS` selected 1.
27
+
28
+ The test was `Expr.backendLocal`, which answers a different question: may
29
+ the backend evaluate this expression, or must it materialise and
30
+ delegate. The right test is `Expr.existsFree` — `Expr.existsPat` and
31
+ `Expr.notExistsPat` are the only constructors carrying a pattern, so an
32
+ exists-free expression reads no triple whatever functions it calls. A
33
+ FILTER may narrow a plan or leave it alone; it must never widen it.
34
+ `EXISTS` and `NOT EXISTS` still widen, correctly.
35
+
36
+ ### LGI1, a character-gram literal index
37
+
38
+ The packer writes a `.lgi1` sidecar beside each block and the planner
39
+ uses it where the query shape allows. It holds character 3-grams of the
40
+ case-folded lexical form and is a CANDIDATE FILTER, never a decider: it
41
+ answers a superset and the engine re-evaluates the original `FILTER`, so
42
+ the rows are the scan's rows. Tokens would not do, because `CONTAINS` is
43
+ a substring test and "underwater" contains "water" without being the
44
+ token "water".
45
+
46
+ Falls back to a scan, silently and correctly: a needle under 3
47
+ characters, a variable needle, `REGEX`, `UCASE`, `!CONTAINS`, `CONTAINS`
48
+ under `||`, a filter on a variable not bound in object position, and any
49
+ generation without the sidecar. Costs about 55% of the block bytes.
50
+
51
+ ### GBI1, a geometry bounding-box index
52
+
53
+ The same construction for `geof:`. Five of the six topological functions
54
+ are filtered by a box — `sfIntersects`, `sfWithin`, `sfContains`,
55
+ `sfTouches`, `sfEquals` — at 74 to 94 times a scan, with a miss at 82
56
+ times. **`sfDisjoint` is refused** and falls back: it accepts exactly the
57
+ rows a box can exclude, so a box test inverts and would drop answers.
58
+
59
+ ### Extension functions, from JavaScript, on the Lean engine
60
+
61
+ `registerExtensionFunction` reaches the Lean engine and the persisted
62
+ store, not only the in-memory F* path. Section 17.6 semantics are gated:
63
+ an unregistered IRI is unbound in SELECT and drops the row in FILTER, a
64
+ registration never overrides a built-in family, and `geof:` still
65
+ answers from the built-in table. Synchronous only; async is designed and
66
+ deferred.
67
+
68
+ ### A handle is bounded by retained bytes
69
+
70
+ `storeOpen` was capped at 64 artifacts, inherited from the stateless path
71
+ where every query re-read and re-decoded its blocks. A handle pays that
72
+ once, so a count was the wrong shape — 257 small blocks may cost less
73
+ than 4 large ones. The cap is now 134,217,728 retained bytes, derived
74
+ from a measured 16.2 bytes resident per retained byte against half the
75
+ wasm32 address space, and the check runs BEFORE the read rather than
76
+ after hashing and decoding the whole set. A corpus-wide handle over 257
77
+ blocks and 741,179 rows now opens.
78
+
79
+ ### Named graphs pack at scale
80
+
81
+ Blocks cut at graph boundaries, so `GRAPH <iri>` with a constant
82
+ predicate reads one block and the graph filter is exact rather than
83
+ conservative. Packer peak memory fell 32.6% and dictionary duplication
84
+ is 2.57%. No new wire version was needed: SBM7 already admitted several
85
+ entries per predicate.
86
+
87
+ **A large store no longer needs `node --stack-size`.** Several engine
88
+ paths recurse once per manifest entry and once per row, and on a
89
+ 7,315,251-quad collection of 3,286 blocks `storeQueryPlan` alone
90
+ overflowed the default call stack of Node, before an artifact byte was
91
+ read ([issue 653](https://github.com/danbri/factoidal/issues/653)).
92
+
93
+ - New `@factoidal/core/store-worker`: `openStoreHandleOnWorker` holds the
94
+ engine and the handle on a `worker_threads` thread with a raised stack.
95
+ A handle is state inside the wasm instance and an instance does not
96
+ cross a thread boundary, so the handle lives there and `query()` and
97
+ `close()` are messages to it. One thread per session, many handles per
98
+ thread. Every call is asynchronous; the synchronous `openStoreHandle`
99
+ is unchanged.
100
+ - `factoidal query` still runs IN PROCESS, so a one-shot query pays
101
+ nothing for a worker it would drop. It runs again on a worker only when
102
+ the runtime runs out of frames — under Deno by re-executing itself with
103
+ a raised V8 stack, which needs `--allow-run` and `--allow-env`.
104
+ `--no-worker` turns the retry off.
105
+ - Measured: the worker costs about 110 ms once (thread start plus a
106
+ second engine load) and under a millisecond per query after that.
107
+ Deno's `worker_threads` shim does not raise the stack (10,835 frames
108
+ by default, 13,837 at `stackSizeMb` 64, against Node's 41,195 and
109
+ 696,555), so a Deno library caller gets an in-process handle behind the
110
+ same interface and starts its process with
111
+ `--v8-flags=--stack-size=65536`.
112
+
113
+ ### Fixed
114
+
115
+ - `openStoreHandle` with no options failed on any IBK3 generation with
116
+ `artifact 'predicate-0.ibk3.sri2' is not declared by this manifest`.
117
+ Two engine operations disagreed: `storeManifestInspect` reports
118
+ `subjectIndex`, `termIndex` and `objectIndex`, while the handle's
119
+ admission accepted only the block, `literalIndex` and `geoIndex`. One
120
+ list now serves both.
121
+ - The README named GeoSPARQL functions that do not exist and said a text
122
+ index was "separate work". Both corrected, with the shapes that fall
123
+ back to a scan listed.
124
+
125
+ ### Known limits, measured
126
+
127
+ - Ingest is about 2,180 quads per second and peak memory is 2.07 times
128
+ the source, so a 142 GB corpus is not importable at that ratio. Making
129
+ memory flat is a wire change and is not done.
130
+ - The quad packer streams N-Quads only; Turtle and TriG are buffered.
131
+ - `ORDER BY ... LIMIT n` still overflows above about 14,576 materialised
132
+ rows. https://github.com/danbri/factoidal/issues/653
133
+ - The manifest is at version 9. Older generations still read.
134
+
3
135
  ## 0.5.1 — 2026-09-04
4
136
 
5
137
  **A store can be opened once and queried many times.** `storeQuery` is
package/README.md CHANGED
@@ -245,6 +245,45 @@ error. Async functions run over the synchronous verified engine
245
245
  through a bounded, memoised re-evaluation loop — within one query
246
246
  every call with the same arguments sees one stable answer.
247
247
 
248
+ ### The same functions against the Lean engine and a persisted store
249
+
250
+ The registration above serves the F\* engine's in-memory `query()`. The
251
+ Lean engine has its own registry, and it reaches every Lean query path,
252
+ including a store handle — so a registered function can filter rows read
253
+ off disk:
254
+
255
+ ```js
256
+ import { loadEngine } from '@factoidal/core/bin/engine.mjs'
257
+ import { openStore, openStoreHandle } from '@factoidal/core/bin/store.mjs'
258
+ import { registerExtensionFunction, withExtensionFunctions }
259
+ from '@factoidal/core/bin/ext.mjs'
260
+
261
+ const engine = await loadEngine()
262
+ const handle = openStoreHandle(engine, openStore('/path/to/store'))
263
+
264
+ registerExtensionFunction(engine, 'http://example.org/fn/endsWithZed',
265
+ ([label]) => label.value.endsWith('z'))
266
+
267
+ const answer = handle.query(`
268
+ PREFIX ex: <http://example.org/fn/>
269
+ PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
270
+ SELECT ?c ?l WHERE { ?c skos:prefLabel ?l FILTER(ex:endsWithZed(?l)) }`)
271
+ ```
272
+
273
+ The argument and result encoding, the §17.6 error rules and the
274
+ async re-evaluation loop are the same as above, so one function serves
275
+ both engines. Two differences to plan around:
276
+
277
+ - **Registration is per engine instance**, not per handle or per query.
278
+ A server that answers for more than one caller uses
279
+ `withExtensionFunctions(engine, map, body)`, which registers, runs and
280
+ clears in a `finally`, or loads one engine per caller.
281
+ - **Every call crosses into JavaScript**, so a function in a FILTER runs
282
+ once per row. Measure before putting one on a large scan.
283
+
284
+ Design, with the determinism and scope rules in full:
285
+ [`docs/designissues/2026-09-04-lean-extension-functions.md`](../../docs/designissues/2026-09-04-lean-extension-functions.md).
286
+
248
287
  ## Functional API (fn)
249
288
 
250
289
  `@factoidal/core/fn` is a strictly functional variant of the API above:
@@ -513,7 +552,7 @@ const docs = openStoreHandle(engine, openStore('./docs-store'))
513
552
 
514
553
  console.log(listStoreHandles(engine))
515
554
  // { ok: true, handles: [ {handle:'s1', bytes:…, rows:…}, {handle:'s2', …} ],
516
- // bytes: …, rows: …, handleCap: 8, bytesCap: 67108864 }
555
+ // bytes: …, rows: …, handleCap: 8, bytesCap: 134217728 }
517
556
 
518
557
  const PREFIX = 'PREFIX skos: <http://www.w3.org/2004/02/skos/core#>'
519
558
 
@@ -559,23 +598,112 @@ caller that holds only the handle id.
559
598
  | tenth query, all different | 1382 ms | 103 ms |
560
599
  | ten queries, total | 13962 ms | 2277 ms |
561
600
 
562
- **What a handle buys and what it does not.** It removes the per-query
563
- digest check, block decode and index build. It does NOT make search
564
- sub-linear: `CONTAINS` still scans every retained row, so a query still
565
- costs time proportional to the row count. A text index is separate work.
601
+ **What a handle buys.** It removes the per-query digest check, block
602
+ decode and index build. With the LGI1 literal index (below) a search is
603
+ also sub-linear rather than a scan, so a handle plus the index is what
604
+ makes repeated search on a large store interactive.
566
605
 
567
606
  **Residency.** Retaining a decoded block costs memory. Measured on the
568
607
  same store: 76 MiB resident with the engine loaded and no handle, 170 MiB
569
608
  with the handle open — about 94 MiB for a 5.5 MB packed block, and
570
- evaluation peaks higher again (346 MiB during the queries above). The
571
- caps are on ARTIFACT bytes, which is what the manifest declares: 8 open
572
- handles, and 67108864 retained artifact bytes across all of them. A cap
573
- is a refusal naming the cap; no handle is ever evicted to make room for
609
+ evaluation peaks higher again (346 MiB during the queries above). At
610
+ corpus scale the marginal figure is smaller, because that one is carrying
611
+ the fixed cost of the process: measured 2026-09-05 on a 7,315,251-quad
612
+ store, one handle over 257 blocks and 103,341,302 retained artifact bytes
613
+ peaked at 1,675,345,920 bytes resident — 16.2 bytes resident per retained
614
+ byte.
615
+
616
+ The caps are on ARTIFACT bytes, which is what the manifest declares: 8
617
+ open handles, and 134217728 (128 MiB) retained artifact bytes across all
618
+ of them. There is NO cap on the number of artifacts a handle retains; a
619
+ count bounds nothing that the bytes do not
620
+ (https://github.com/danbri/factoidal/issues/657). 128 MiB is half the
621
+ wasm32 address space divided by that measured multiplier, and it admits
622
+ the 257-block corpus-wide set above. A cap is a refusal naming the cap
623
+ and the value that tripped it; no handle is ever evicted to make room for
574
624
  another. `listStoreHandles` is how a server sees its own residency.
575
625
 
576
626
  **One call at a time.** The WebAssembly module is single-threaded. Two
577
627
  `query()` calls cannot overlap; a server queues them.
578
628
 
629
+ ### A handle on a large store: the worker route
630
+
631
+ Several engine paths recurse once per manifest entry and once per row.
632
+ Against a large collection that exceeds the default call stack of Node
633
+ and of Deno, and the failure is `Maximum call stack size exceeded`.
634
+ **Measured 2026-09-05**, macOS arm64, Node 22.22.2, on a 7,315,251-quad
635
+ collection of 3,286 blocks in 204 graphs: `storeQueryPlan` alone
636
+ overflows on plain `node`, before one artifact byte is read.
637
+ `node --stack-size=60000` clears the plan, the open and every query.
638
+
639
+ `openStoreHandleOnWorker` removes the flag. It holds the engine and the
640
+ handle on a `worker_threads` thread with `resourceLimits.stackSizeMb`,
641
+ the route `factoidal pack` already takes
642
+ ([issue 649](https://github.com/danbri/factoidal/issues/649)). A handle
643
+ is state inside the wasm instance and an instance does not cross a
644
+ thread boundary, so the handle lives where the raised stack is, and
645
+ `query()` and `close()` are messages to it.
646
+
647
+ ```js
648
+ import { openStoreHandleOnWorker, closeSharedStoreWorkerSession }
649
+ from '@factoidal/core/store-worker'
650
+
651
+ // No runtime flag. One worker thread, shared by every handle opened
652
+ // this way, so a caller that opens several stores pays for one thread
653
+ // and one copy of the module.
654
+ const handle = await openStoreHandleOnWorker('/path/to/store', {
655
+ sparql: 'SELECT ?c ?l WHERE { GRAPH <urn:g> { ?c ?p ?l } }'
656
+ })
657
+ const answer = await handle.query(`${PREFIX}
658
+ SELECT ?c ?l WHERE { GRAPH <urn:g> { ?c skos:prefLabel ?l }
659
+ FILTER(CONTAINS(LCASE(STR(?l)), "volcan")) } LIMIT 8`)
660
+ await handle.close()
661
+ await closeSharedStoreWorkerSession()
662
+ ```
663
+
664
+ | option | effect |
665
+ |---|---|
666
+ | `{sparql}`, `{keys}` | the same artifact choice `openStoreHandle` takes |
667
+ | `{generation}` | open a generation that has not been activated |
668
+ | `{session}` | open into a session you started with `openStoreWorkerSession()` |
669
+ | `{ownWorker: true}` | give this handle its own thread and its own copy of the module |
670
+ | `{worker: false}` | open in this process, with no thread |
671
+
672
+ What it costs, all **measured 2026-09-05** on the same machine, against
673
+ the bundled sample store so the overhead is not lost in the query:
674
+
675
+ | | in process | on a worker |
676
+ |---|---|---|
677
+ | one-shot query (thread start + engine load + query) | 163 ms | 220 ms |
678
+ | handle open (thread start + engine load + open) | 124 ms | 236 ms |
679
+ | every query after the open | 1 ms | 1 ms |
680
+
681
+ So the thread and its second copy of the engine cost about 110 ms once,
682
+ and the message round trip is under a millisecond. Three further costs:
683
+ every call is asynchronous where the in-process handle is synchronous;
684
+ an extension function registered on the main thread's engine
685
+ (`bin/ext.mjs`) is not visible to the worker's engine; and the worker
686
+ keeps the process alive until `close()`.
687
+
688
+ **Deno takes a different route.** Deno's `node:worker_threads` shim
689
+ accepts `resourceLimits.stackSizeMb` and raises almost nothing with it —
690
+ measured by counting frames to the overflow inside the worker, Node
691
+ reaches 41,195 frames by default and 696,555 at `stackSizeMb` 64, where
692
+ Deno reaches 10,835 and 13,837. `openStoreHandleOnWorker` therefore
693
+ gives a Deno caller an in-process handle behind the same asynchronous
694
+ interface, and the process supplies the stack:
695
+
696
+ ```
697
+ deno run --allow-read --v8-flags=--stack-size=65536 your-program.mjs
698
+ ```
699
+
700
+ **A one-shot `factoidal query` pays none of this.** It builds no handle,
701
+ so it runs in process and only retries on a worker if the runtime runs
702
+ out of frames; under Deno it re-executes itself once with a raised V8
703
+ stack, which needs `--allow-run` and `--allow-env`. `--no-worker` turns
704
+ the retry off. See
705
+ [issue 653](https://github.com/danbri/factoidal/issues/653).
706
+
579
707
  ## API (draft)
580
708
 
581
709
  The `factoidal` CLI (`bin/factoidal-cli/factoidal_cli.ml`, built to
@@ -768,15 +896,51 @@ handling beyond what the WKT literal carries; no GML literals. Geometry
768
896
  comes from a WKT parser, so a shapefile, GeoJSON or GML source must be
769
897
  converted to `geo:wktLiteral` before it is loaded.
770
898
 
771
- ### Full text: SPARQL's own functions, no index
899
+ ### Full text: SPARQL's own functions, over a character-gram index
772
900
 
773
901
  `CONTAINS`, `STRSTARTS`, `STRENDS` and `REGEX` (SPARQL 1.1 §17.4.3) are
774
- implemented and are the way to search text. They are evaluated per row
775
- after a block is decoded **there is no inverted index and no
776
- `text:query`-style extension**. Measured 2026-09-04: a `CONTAINS` over
777
- 45,806 `skos:prefLabel` values in one block answers in about 6 seconds.
778
- That is fine for a vocabulary and will not scale to a large literal
779
- corpus.
902
+ the way to search text. There is no `text:query`-style extension and you
903
+ do not ask for the index: where the query shape allows it, the planner
904
+ uses the **LGI1** literal index the packer writes beside each block.
905
+
906
+ LGI1 holds character 3-grams of the case-folded lexical form, and it is a
907
+ CANDIDATE FILTER rather than a decider — it answers a superset and the
908
+ engine re-evaluates your original `FILTER` on those rows, so the answer
909
+ is exactly the answer a scan gives. Tokens would not do: `CONTAINS` is a
910
+ substring test, and "underwater" contains "water" without being the
911
+ token "water".
912
+
913
+ Measured 2026-09-05, full skosdex corpus (7,315,251 quads, 3,286 blocks,
914
+ 204 named graphs, 1.0 GB), through a store handle on plain `node`:
915
+
916
+ | search | rows | time |
917
+ |---|---|---|
918
+ | `water` | 5 | 645 ms |
919
+ | `glacier` | 0 | 670 ms |
920
+ | `bicycle` | 2 | 617 ms |
921
+
922
+ A miss costs what a hit costs, because both are index lookups rather than
923
+ scans. On a 45,806-row block the same search was about 180 ms of scan
924
+ before the index and 1.8 ms after it.
925
+
926
+ **It falls back to a scan**, silently and correctly, for: a needle under
927
+ 3 characters, a variable needle, `REGEX`, `UCASE`, `!CONTAINS`,
928
+ `CONTAINS` under `||`, a filter on a variable not bound in object
929
+ position, and any block whose generation has no `.lgi1` sidecar. The
930
+ index costs about 55% of the block bytes.
931
+
932
+ ### Geometry: the GBI1 bounding-box index
933
+
934
+ The same construction for `geof:`. Each `geo:wktLiteral` object's
935
+ bounding box is indexed, and five of the six topological functions are
936
+ filtered by it — `sfIntersects`, `sfWithin`, `sfContains`, `sfTouches`,
937
+ `sfEquals`. Measured 74x to 94x against a scan, with a miss at 82x.
938
+
939
+ **`sfDisjoint` is refused and falls back to a scan**, deliberately: it
940
+ accepts exactly the rows a box can exclude, so a box test inverts and
941
+ would drop answers. A non-overlapping pair of boxes proves geometries
942
+ disjoint, but that saves work inside a scan rather than reducing the
943
+ candidate set.
780
944
 
781
945
  ## Limits (deliberate, documented)
782
946
 
package/bin/ext.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // Caller-registered SPARQL 1.1 §17.6 extension functions for the Lean
2
+ // engine. https://github.com/danbri/factoidal/issues/463 (the F* side),
3
+ // docs/designissues/2026-09-04-lean-extension-functions.md (this side).
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Hold the caller's functions, carry values across the boundary, and
7
+ // memoise. It makes NO semantic decision: which IRIs may reach a
8
+ // registered function, what an absent answer means, and how a §17.6
9
+ // error behaves are all decided in Lean (Wasm/Ops/ExtFns.lean and the
10
+ // evaluator). A JavaScript answer this file cannot encode becomes the
11
+ // empty string, which Lean reads as the §17.6 error — the same outcome
12
+ // as a function that is not registered at all.
13
+ //
14
+ // THE BRIDGE
15
+ // One function, globalThis.__factoidalExtCall(iri, argsJson), which the
16
+ // EM_JS body in formal/lean4/ffi/l4_ext.c calls SYNCHRONOUSLY from
17
+ // inside the evaluator. `argsJson` is a JSON array of SPARQL Query
18
+ // Results JSON binding-value objects; the answer is one such object as
19
+ // a JSON string, or the empty string for "no value".
20
+ //
21
+ // ASYNC
22
+ // The evaluator is synchronous and inside WebAssembly, so it cannot wait
23
+ // for a promise. The F* engine's answer is reused verbatim
24
+ // (npm/factoidal/browser.js, withExtensionRounds): the bridge starts the
25
+ // promise, records it, and answers "no value" for this round; the host
26
+ // awaits every pending promise, writes the answers into the memo table,
27
+ // and runs the WHOLE query again, until a round adds no new pending
28
+ // call.
29
+ //
30
+ // DETERMINISM
31
+ // The memo table is keyed on iri + ' ' + argsJson and lives for one
32
+ // top-level query, so the same call answers the same way however many
33
+ // times the physical plan evaluates the expression, and across
34
+ // re-evaluation rounds.
35
+
36
+ const XSD = 'http://www.w3.org/2001/XMLSchema#'
37
+
38
+ /** How many evaluation rounds an async resolution may take. */
39
+ export const EXT_MAX_ROUNDS = 25
40
+
41
+ const extFunctions = new Map()
42
+ const extRegistered = new Set()
43
+ let extCache = new Map()
44
+ let extPending = []
45
+ let bridgeInstalled = false
46
+
47
+ /**
48
+ * Encode one JavaScript answer as an SRJ binding-value object, as a JSON
49
+ * string. `''` means "no value" — the §17.6 error.
50
+ */
51
+ function encodeAnswer (out) {
52
+ if (out === null || out === undefined) return ''
53
+ if (typeof out === 'boolean') {
54
+ return JSON.stringify({ type: 'literal', value: out ? 'true' : 'false', datatype: XSD + 'boolean' })
55
+ }
56
+ if (typeof out === 'number') {
57
+ if (!Number.isFinite(out)) return ''
58
+ return Number.isInteger(out)
59
+ ? JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'integer' })
60
+ : JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'double' })
61
+ }
62
+ if (typeof out === 'string') {
63
+ return JSON.stringify({ type: 'literal', value: out })
64
+ }
65
+ if (typeof out === 'object' && typeof out.type === 'string') {
66
+ // Already an SRJ binding-value object (or an RDF/JS-shaped term the
67
+ // caller built): pass it through and let the Lean decoder judge it.
68
+ return JSON.stringify(out)
69
+ }
70
+ return ''
71
+ }
72
+
73
+ /** The synchronous bridge the wasm module calls. */
74
+ function extBridge (iri, argsJson) {
75
+ const key = iri + ' ' + argsJson
76
+ if (extCache.has(key)) return extCache.get(key)
77
+ const fn = extFunctions.get(iri)
78
+ if (typeof fn !== 'function') return ''
79
+ let out
80
+ try {
81
+ out = fn(JSON.parse(argsJson))
82
+ } catch (_error) {
83
+ extCache.set(key, '')
84
+ return ''
85
+ }
86
+ if (out !== null && typeof out === 'object' && typeof out.then === 'function') {
87
+ extPending.push({ key, promise: out })
88
+ return ''
89
+ }
90
+ const encoded = encodeAnswer(out)
91
+ extCache.set(key, encoded)
92
+ return encoded
93
+ }
94
+
95
+ function installBridge () {
96
+ if (bridgeInstalled) return
97
+ globalThis.__factoidalExtCall = extBridge
98
+ bridgeInstalled = true
99
+ }
100
+
101
+ /**
102
+ * Register one function under an absolute IRI.
103
+ *
104
+ * `fn` receives the evaluated arguments as an array of SRJ
105
+ * binding-value objects and returns a term object, a JavaScript
106
+ * string/number/boolean, `null`/`undefined` (the §17.6 error), or a
107
+ * promise of any of those.
108
+ *
109
+ * Scope: per loaded engine (one wasm module instance). A server that
110
+ * serves more than one caller uses `withExtensionFunctions` instead, or
111
+ * loads one engine per caller.
112
+ */
113
+ export function registerExtensionFunction (engine, iri, fn) {
114
+ if (typeof iri !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(iri)) {
115
+ throw new TypeError('registerExtensionFunction: iri must be an absolute IRI string')
116
+ }
117
+ if (typeof fn !== 'function') {
118
+ throw new TypeError('registerExtensionFunction: fn must be a function')
119
+ }
120
+ installBridge()
121
+ extFunctions.set(iri, fn)
122
+ engine.call('extRegister', [iri])
123
+ extRegistered.add(iri)
124
+ }
125
+
126
+ /** Remove one registered function. */
127
+ export function unregisterExtensionFunction (engine, iri) {
128
+ extFunctions.delete(iri)
129
+ extRegistered.delete(iri)
130
+ engine.call('extUnregister', [iri])
131
+ }
132
+
133
+ /** Return the engine to the built-in table (`geof:` and the built-ins). */
134
+ export function clearExtensionFunctions (engine) {
135
+ extFunctions.clear()
136
+ extRegistered.clear()
137
+ engine.call('extClear', [])
138
+ }
139
+
140
+ /** The IRIs the engine will ask the host about. */
141
+ export function listExtensionFunctions (engine) {
142
+ return engine.call('extList', []).iris
143
+ }
144
+
145
+ /**
146
+ * Run `body` with a fresh per-query memo table, resolving async answers
147
+ * by re-evaluation. `body` is called once per round and must be the
148
+ * WHOLE query, because a later round re-evaluates it with more answers
149
+ * known.
150
+ */
151
+ export async function withExtensionRounds (body) {
152
+ extCache = new Map()
153
+ for (let round = 0; ; round += 1) {
154
+ extPending = []
155
+ const result = body()
156
+ if (extPending.length === 0) return result
157
+ if (round >= EXT_MAX_ROUNDS) {
158
+ throw new Error(
159
+ 'extension functions: async resolution did not converge within ' +
160
+ `${EXT_MAX_ROUNDS} evaluation rounds`)
161
+ }
162
+ const pending = extPending
163
+ extPending = []
164
+ await Promise.all(pending.map(async ({ key, promise }) => {
165
+ try {
166
+ extCache.set(key, encodeAnswer(await promise))
167
+ } catch (_error) {
168
+ extCache.set(key, '')
169
+ }
170
+ }))
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Run one query with a fresh memo table. Use this for a synchronous
176
+ * function set; use `withExtensionRounds` when any function is async.
177
+ */
178
+ export function withFreshMemo (body) {
179
+ extCache = new Map()
180
+ extPending = []
181
+ return body()
182
+ }
183
+
184
+ /**
185
+ * Register `map` (IRI -> function), run `body`, and clear in a
186
+ * `finally`. This is the scope a long-lived server wants: one caller's
187
+ * registrations are never visible to the next.
188
+ */
189
+ export async function withExtensionFunctions (engine, map, body) {
190
+ for (const [iri, fn] of Object.entries(map)) {
191
+ registerExtensionFunction(engine, iri, fn)
192
+ }
193
+ try {
194
+ return await withExtensionRounds(body)
195
+ } finally {
196
+ clearExtensionFunctions(engine)
197
+ }
198
+ }
package/bin/factoidal.mjs CHANGED
@@ -28,6 +28,9 @@ import { loadEngine } from './engine.mjs'
28
28
  import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
29
29
  import { PackError, packSupported, verifyGeneration } from './pack.mjs'
30
30
  import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
31
+ import {
32
+ planQueryOnWorker, queryStoreOnWorker, workerRouteAvailable
33
+ } from './store-worker-host.mjs'
31
34
  import {
32
35
  STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
33
36
  queryStore, stackLimitAdvice, turtleOfNQuads
@@ -179,6 +182,21 @@ options:
179
182
  --generation NAME query this generation instead of the activated one
180
183
  --json shorthand for --format json
181
184
  --quiet print the result only, no plan line on stderr
185
+ --no-worker never retry on a worker thread (see below)
186
+
187
+ Several engine paths recurse once per manifest entry or once per row, and
188
+ against a large collection that exceeds the runtime's default call stack.
189
+ A one-shot query builds no store handle, so it runs IN PROCESS and pays
190
+ nothing for a worker it would drop; only when the runtime runs out of
191
+ frames does it run again on a worker thread with a raised stack, or under
192
+ Deno re-execute itself once with a raised V8 stack, which needs
193
+ --allow-run and --allow-env (https://github.com/danbri/factoidal/issues/653).
194
+ --no-worker turns the retry off, and the frame budget is then reported.
195
+
196
+ A process that asks MANY questions about one store should hold a store
197
+ handle instead: openStoreHandleOnWorker in bin/store-worker-host.mjs
198
+ verifies and decodes once, on a thread with the raised stack, and answers
199
+ every later query from what it retained.
182
200
 
183
201
  formats:
184
202
  table a human display of the engine's SPARQL Query Results JSON;
@@ -532,6 +550,36 @@ function reportStoreFailure (error) {
532
550
  return EXIT_FAILURE
533
551
  }
534
552
 
553
+ /**
554
+ * Run one store call again on a bigger call stack, after the in-process
555
+ * attempt ran out of frames.
556
+ *
557
+ * A one-shot query builds no handle and drops what it loads, so it runs
558
+ * IN PROCESS first and pays nothing for a worker it would throw away.
559
+ * This is the retry, and it is reached only by the overflow.
560
+ *
561
+ * Node takes the worker route; Deno has no `worker_threads` route with a
562
+ * stack size, so it re-executes this command once with a raised V8 stack
563
+ * (`denoReexec`), which needs --allow-run and --allow-env.
564
+ *
565
+ * @returns the retried answer, or a number to exit with (the Deno child's
566
+ * code), or null when no bigger stack is available here
567
+ */
568
+ async function onBiggerStack (options, quiet, retry) {
569
+ const host = { worker: options['no-worker'] !== true }
570
+ if (host.worker === false) return null
571
+ if (isDeno) {
572
+ const code = await denoReexec(host)
573
+ return typeof code === 'number' ? code : null
574
+ }
575
+ if (!workerRouteAvailable()) return null
576
+ if (!quiet) {
577
+ err('The runtime ran out of call stack; running again on a worker thread ' +
578
+ 'with a bigger one.')
579
+ }
580
+ return await retry()
581
+ }
582
+
535
583
  async function commandQuery (positional, options) {
536
584
  if (positional.length < 1) throw new UsageError('query needs a STORE')
537
585
  if (typeof options.base === 'string') {
@@ -554,8 +602,13 @@ async function commandQuery (positional, options) {
554
602
  try {
555
603
  plan = planQuery(engine, store, sparql)
556
604
  } catch (error) {
557
- if (error instanceof StoreOperationError) return reportStoreFailure(error)
558
- throw error
605
+ if (!(error instanceof StoreOperationError)) throw error
606
+ if (!error.stackLimit) return reportStoreFailure(error)
607
+ const retried = await onBiggerStack(options, quiet,
608
+ () => planQueryOnWorker(root, named, sparql))
609
+ if (retried === null) return reportStoreFailure(error)
610
+ if (typeof retried === 'number') return retried
611
+ plan = retried
559
612
  }
560
613
  if (format === 'json' || options.json === true) {
561
614
  out(JSON.stringify(plan, null, 2))
@@ -572,8 +625,13 @@ async function commandQuery (positional, options) {
572
625
  try {
573
626
  answer = queryStore(engine, store, sparql)
574
627
  } catch (error) {
575
- if (error instanceof StoreOperationError) return reportStoreFailure(error)
576
- throw error
628
+ if (!(error instanceof StoreOperationError)) throw error
629
+ if (!error.stackLimit) return reportStoreFailure(error)
630
+ const retried = await onBiggerStack(options, quiet,
631
+ () => queryStoreOnWorker(root, named, sparql))
632
+ if (retried === null) return reportStoreFailure(error)
633
+ if (typeof retried === 'number') return retried
634
+ answer = retried
577
635
  }
578
636
  const { plan, result, blobBytes } = answer
579
637
  if (!quiet) {