@factoidal/core 0.1.0 → 0.3.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/bin/store.mjs ADDED
@@ -0,0 +1,206 @@
1
+ // Driving the three WebAssembly store operations from a JavaScript host.
2
+ // https://github.com/danbri/factoidal/issues/641
3
+ //
4
+ // WHAT THIS FILE IS ALLOWED TO DO
5
+ // Read CURRENT, read a manifest file by name, read the artifact files the
6
+ // engine's plan named, concatenate their bytes, and hand them over. It
7
+ // never parses a manifest, never verifies a digest, never decodes a block
8
+ // and never decides which artifact answers a query. Every one of those is
9
+ // a format decision and it lives in `formal/lean4/Wasm/Ops/Store.lean`
10
+ // (iron rule 7 of CLAUDE.md). A reviewer who finds a magic number, a
11
+ // field offset or a hash in this file has found a rule violation.
12
+ //
13
+ // The operations and their envelopes are described in
14
+ // `docs/designissues/2026-09-03-wasm-shardborough-store-ops.md`.
15
+
16
+ import { openCollection, readWhole } from '../store-host/index.mjs'
17
+ import { joinPath } from '../store-host/paths.mjs'
18
+ import { hexOfBytes } from './engine.mjs'
19
+
20
+ /** The manifest file names a generation directory can carry, in the order
21
+ * `Harness.ShardMerklePread.readManifest` tries them. */
22
+ const MANIFEST_NAMES = ['manifest.sbm2', 'manifest.sbm1']
23
+
24
+ /**
25
+ * An error the store operations reported. `capName` is set when the
26
+ * refusal was one of the operation's caps; `message` is always the
27
+ * engine's own words.
28
+ */
29
+ export class StoreOperationError extends Error {
30
+ constructor (message, detail = {}) {
31
+ super(message)
32
+ this.name = 'StoreOperationError'
33
+ this.capValue = detail.capValue === undefined ? null : detail.capValue
34
+ this.capLimit = detail.capLimit === undefined ? null : detail.capLimit
35
+ this.digestKey = detail.digestKey === undefined ? null : detail.digestKey
36
+ this.stackLimit = detail.stackLimit === true
37
+ }
38
+ }
39
+
40
+ // The engine's refusals arrive as an Error whose message is
41
+ // "l4factoidal: <the operation's own text>". These two patterns say only
42
+ // WHICH refusal it was, so the command can add a next step; the text the
43
+ // user sees is always the engine's, never a rewrite of it.
44
+ const CAP_PATTERN = /the plan selects (\d+) (?:artifacts|artifact bytes|rows), the cap is (\d+)/
45
+ const DIGEST_PATTERN = /artifact '([^']*)' does not match the SHA-256/
46
+
47
+ function asStoreError (error) {
48
+ const raw = error && error.message ? String(error.message) : String(error)
49
+ const message = raw.replace(/^l4factoidal:\s*/, '')
50
+ // Not a refusal by the engine: the host runtime ran out of call stack
51
+ // inside the wasm module. Some evaluator paths recurse once per row.
52
+ // Measured 2026-09-03 against the committed wasm, on a 6455-row
53
+ // generation: `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` and the same query
54
+ // with `ORDER BY` overflow under Node's default WebAssembly frame
55
+ // budget, while `SELECT *`, and either query with a LIMIT, do not.
56
+ // `node --stack-size=4000` clears all of them, and Deno clears them at
57
+ // its own default.
58
+ if (error instanceof RangeError || message.indexOf('call stack size exceeded') >= 0) {
59
+ return new StoreOperationError(message, { stackLimit: true })
60
+ }
61
+ const cap = CAP_PATTERN.exec(message)
62
+ if (cap !== null) {
63
+ return new StoreOperationError(message,
64
+ { capValue: Number(cap[1]), capLimit: Number(cap[2]) })
65
+ }
66
+ const digest = DIGEST_PATTERN.exec(message)
67
+ if (digest !== null) {
68
+ return new StoreOperationError(message, { digestKey: digest[1] })
69
+ }
70
+ return new StoreOperationError(message)
71
+ }
72
+
73
+ /**
74
+ * Open a store and return its manifest bytes.
75
+ *
76
+ * With no `generationName` the activated generation is opened through
77
+ * CURRENT. With one, that generation is opened directly, which is how a
78
+ * generation that has not been activated is inspected.
79
+ *
80
+ * @param {string} root the collection root that holds CURRENT
81
+ * @param {string|null} generationName
82
+ * @returns {{root: string, generation: string, generationDir: string,
83
+ * manifestName: string, manifest: Uint8Array,
84
+ * manifestHex: string, activated: boolean}}
85
+ */
86
+ export function openStore (root, generationName = null) {
87
+ if (typeof generationName !== 'string') {
88
+ const opened = openCollection(root)
89
+ return { ...opened, manifestHex: hexOfBytes(opened.manifest), activated: true }
90
+ }
91
+ const generationDir = joinPath(root, generationName)
92
+ for (const manifestName of MANIFEST_NAMES) {
93
+ let manifest
94
+ try {
95
+ manifest = readWhole(joinPath(generationDir, manifestName))
96
+ } catch (_error) {
97
+ continue
98
+ }
99
+ return {
100
+ root,
101
+ generation: generationName,
102
+ generationDir,
103
+ manifestName,
104
+ manifest,
105
+ manifestHex: hexOfBytes(manifest),
106
+ activated: false
107
+ }
108
+ }
109
+ throw new StoreOperationError(
110
+ `${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`)
111
+ }
112
+
113
+ /** `storeManifestInspect` — decode one manifest. */
114
+ export function inspectManifest (engine, store) {
115
+ try {
116
+ return engine.call('storeManifestInspect', [store.manifestHex])
117
+ } catch (error) {
118
+ throw asStoreError(error)
119
+ }
120
+ }
121
+
122
+ /** `storeQueryPlan` — the artifact keys this query needs, and the mode. */
123
+ export function planQuery (engine, store, sparql) {
124
+ try {
125
+ return engine.call('storeQueryPlan', [store.manifestHex, sparql])
126
+ } catch (error) {
127
+ throw asStoreError(error)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Ask the engine to decide the caps before any file is read.
133
+ *
134
+ * `storeQuery` checks its three caps against the manifest's own
135
+ * declarations BEFORE it looks at the artifact descriptors, so a call
136
+ * carrying an empty descriptor list gets the cap decision without moving
137
+ * a byte. The caps themselves stay where they are defined
138
+ * (`Wasm/Ops/Store.lean`); this host holds none of their values.
139
+ *
140
+ * @returns the envelope when the plan needs no artifact at all, else null
141
+ * @throws {StoreOperationError} when a cap refused the plan
142
+ */
143
+ function capDecision (engine, store, sparql) {
144
+ try {
145
+ return engine.callBlob('storeQuery',
146
+ [store.manifestHex, sparql, '[]'], new Uint8Array(0))
147
+ } catch (error) {
148
+ const refusal = asStoreError(error)
149
+ if (refusal.message.indexOf('no bytes were supplied for artifact') >= 0) {
150
+ return null
151
+ }
152
+ throw refusal
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Evaluate one SPARQL query against one generation.
158
+ *
159
+ * The sequence is: plan, cap decision, read exactly the artifacts the
160
+ * plan named, concatenate them into one buffer, and call `storeQuery`
161
+ * with a `{"key","offset","len"}` window per artifact. The buffer is
162
+ * written straight into the wasm heap by `engine.callBlob` with no
163
+ * encoding, and the engine bounds-checks every window.
164
+ *
165
+ * @returns {{plan: object, result: object, blobBytes: number,
166
+ * artifacts: {key: string, offset: number, len: number}[]}}
167
+ */
168
+ export function queryStore (engine, store, sparql) {
169
+ const plan = planQuery(engine, store, sparql)
170
+ const empty = capDecision(engine, store, sparql)
171
+ if (empty !== null) {
172
+ return { plan, result: empty, blobBytes: 0, artifacts: [] }
173
+ }
174
+ const chunks = plan.keys.map((key) => readWhole(joinPath(store.generationDir, key)))
175
+ let total = 0
176
+ for (const chunk of chunks) total += chunk.length
177
+ const blob = new Uint8Array(total)
178
+ const artifacts = []
179
+ let offset = 0
180
+ for (let index = 0; index < chunks.length; index += 1) {
181
+ blob.set(chunks[index], offset)
182
+ artifacts.push({ key: plan.keys[index], offset, len: chunks[index].length })
183
+ offset += chunks[index].length
184
+ }
185
+ let result
186
+ try {
187
+ result = engine.callBlob('storeQuery',
188
+ [store.manifestHex, sparql, JSON.stringify(artifacts)], blob)
189
+ } catch (error) {
190
+ throw asStoreError(error)
191
+ }
192
+ return { plan, result, blobBytes: total, artifacts }
193
+ }
194
+
195
+ /**
196
+ * `serializeTurtle` — the engine's own Turtle writer, over the N-Quads a
197
+ * CONSTRUCT answered. Named graphs are flattened into the default graph
198
+ * on this path; `--format nquads` is the fidelity-preserving one.
199
+ */
200
+ export function turtleOfNQuads (engine, nquads) {
201
+ try {
202
+ return engine.call('serializeTurtle', [nquads]).turtle
203
+ } catch (error) {
204
+ throw asStoreError(error)
205
+ }
206
+ }