@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/CHANGELOG.md +125 -0
- package/README.md +149 -1
- package/bin/engine.mjs +124 -0
- package/bin/factoidal.mjs +658 -0
- package/bin/store.mjs +206 -0
- package/factoidal-npm-entry.js +13657 -30789
- package/factoidal-npm-entry.wasm.assets/{code-7ac046580f1bbdda8dc6.wasm → code-5a7fc68f2ab1323718b8.wasm} +0 -0
- package/factoidal-npm-entry.wasm.js +2 -2
- package/factoidal.js +11448 -25890
- package/fn.js +39 -0
- package/l4-assets/l4factoidal.js +197 -0
- package/l4-assets/l4factoidal.mjs +2 -0
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/package.json +4 -0
- package/l4-assets/version.json +16 -0
- package/l4-core.d.ts +159 -0
- package/l4-core.js +188 -0
- package/l4.d.ts +50 -0
- package/l4.js +104 -0
- package/lib/api.js +162 -2
- package/package.json +30 -3
- package/select.d.ts +116 -0
- package/select.js +492 -0
- package/store-host/deno.mjs +199 -0
- package/store-host/errors.mjs +55 -0
- package/store-host/index.mjs +208 -0
- package/store-host/node.mjs +212 -0
- package/store-host/paths.mjs +77 -0
- package/version.json +24 -23
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
|
+
}
|