@factoidal/core 0.14.0 → 0.16.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/lib/nodes.mjs CHANGED
@@ -14,15 +14,34 @@
14
14
  // on a wire is `{ graph, evidence }`, and each node appends one evidence
15
15
  // record `{ stage, status, contract, assumptions, checked, artefact }` (plus
16
16
  // `unchecked` and `reason` for a step run under run()'s `unchecked` opt-in;
17
- // the record logic is in evidence.mjs). The result of
18
- // a run also reports the union of the named assumptions of the nodes it
19
- // passed: the premise list of the pipeline's soundness statement
20
- // (Dataflow/EndToEnd.lean) as the user sees it.
17
+ // the record logic is in evidence.mjs).
18
+ //
19
+ // The type check and the assumption union are the engine's (op
20
+ // pipelineCheck, formal/lean4/Wasm/Ops/StageList.lean, computed by
21
+ // pipelineReport of formal/lean4/L4Factoidal/Dataflow/Pipeline.lean): a
22
+ // pipeline is well typed when each stage's output wire is the next stage's
23
+ // input wire or converts to it, and its `assumptions` are the premises of
24
+ // Pipeline.guarantee_sound, the guarantee of any pipeline of components with
25
+ // these signatures. This file does not compute the union. With an engine
26
+ // built before the op, pipe() and run() fall back to the union of the
27
+ // nodes' fields and report `wellTyped: null`.
28
+ //
29
+ // Node settings (https://github.com/danbri/factoidal/issues/975,
30
+ // https://github.com/danbri/factoidal/issues/1026): the params of node()
31
+ // are the node's settings. When the engine has the op pipelineCheckNodes
32
+ // (formal/lean4/Wasm/Ops/PipelineNodes.lean), the engine decides, per node:
33
+ // the settings in canonical form with their SHA-256 digest, the role of each
34
+ // setting in the stage's contract, the settings outside the proved domain
35
+ // (a proved stage with one is `unproved`), and the output wire the settings
36
+ // select (SELECT gives a Table, ASK an Answer, CONSTRUCT and DESCRIBE a
37
+ // Dataset, so a CONSTRUCT node can feed the next node). This file only marshals: a Dataset setting is sent as
38
+ // {"$nquads": its N-Quads text}. With an older engine, records carry no
39
+ // settings and the evaluator's output wire is the registry's.
21
40
  //
22
41
  // ES module, no Node built-ins: the browser loads this file as it is.
23
42
 
24
43
  import { FactoidalError } from './errors.mjs';
25
- import { refusalFor, makeRecord, summarize, unionAssumptions } from './evidence.mjs';
44
+ import { refusalFor, makeRecord, summarize, unionAssumptions, unionLists } from './evidence.mjs';
26
45
 
27
46
  /**
28
47
  * @param {object} deps the package functions and the engine entry
@@ -30,7 +49,7 @@ import { refusalFor, makeRecord, summarize, unionAssumptions } from './evidence.
30
49
  */
31
50
  export function buildNodes(deps) {
32
51
  const { entry, entryResult, requireEntryFn, Dataset,
33
- parse, closure, shaclValidate, query, serialize, canonicalize, explain } = deps;
52
+ parse, closure, shaclValidate, query, serialize, canonicalize, explain, csvwToRdf } = deps;
34
53
 
35
54
  const reader = (format) => async (wire, params) => {
36
55
  if (typeof wire.graph !== 'string') {
@@ -81,6 +100,14 @@ export function buildNodes(deps) {
81
100
  rdfcStage: async (wire, params) => ({
82
101
  artefact: { canonical: await canonicalize(wire.graph, params) },
83
102
  }),
103
+ 'CSVW (tabular data to RDF)': async (wire, params) => {
104
+ if (typeof wire.graph !== 'string') {
105
+ throw new TypeError(`node CSVW: the stage takes CSV text on its wire (got ${typeof wire.graph})`);
106
+ }
107
+ const { metadata, ...opts } = params;
108
+ const meta = metadata === undefined || typeof metadata === 'string' ? metadata : JSON.stringify(metadata);
109
+ return { graph: await csvwToRdf(wire.graph, meta, opts), artefact: { tabular: 'csv' } };
110
+ },
84
111
  rdfsExplainGraphStage: async (wire, params) => {
85
112
  const { description, ...opts } = params;
86
113
  if (description === undefined) {
@@ -91,6 +118,7 @@ export function buildNodes(deps) {
91
118
  };
92
119
 
93
120
  let stageCache = null;
121
+ let engine = null; // the entry stages() loaded; node() calls stages(), so pipe() has it
94
122
 
95
123
  /**
96
124
  * The engine's stage list (op stageList), one element per registry entry.
@@ -101,13 +129,17 @@ export function buildNodes(deps) {
101
129
  stageCache = (async () => {
102
130
  const e = await entry();
103
131
  requireEntryFn(e, 'stageList', 'stages');
132
+ engine = e;
104
133
  const r = entryResult(e.stageList(), 'stages');
105
134
  return Object.freeze(r.stages.map((s) => Object.freeze({
106
135
  ...s,
107
136
  theorems: Object.freeze([...s.theorems]),
108
137
  assumptions: Object.freeze([...s.assumptions]),
109
138
  tests: Object.freeze([...s.tests]),
110
- bound: Object.prototype.hasOwnProperty.call(RUNNERS, s.name),
139
+ // bound: the package has a runner AND the engine lets the stage
140
+ // run. A runner for a stage that an older engine reports as
141
+ // `missing` is not bound (the engine's registry decides).
142
+ bound: s.runnable && Object.prototype.hasOwnProperty.call(RUNNERS, s.name),
111
143
  })));
112
144
  })();
113
145
  stageCache.catch(() => { stageCache = null; });
@@ -117,6 +149,32 @@ export function buildNodes(deps) {
117
149
 
118
150
  const NODE = Symbol('factoidal.node');
119
151
 
152
+ // The settings as the engine reads them: JSON values, a Dataset as
153
+ // {"$nquads": text}. Anything else is refused.
154
+ function marshal(v, path) {
155
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return v;
156
+ if (typeof v === 'number') {
157
+ if (!Number.isFinite(v)) throw new TypeError(`node: setting ${path} is not a finite number`);
158
+ return v;
159
+ }
160
+ if (v instanceof Dataset) return { $nquads: v.toNQuads() };
161
+ if (Array.isArray(v)) return v.map((x, i) => marshal(x, `${path}[${i}]`));
162
+ if (typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype) {
163
+ const out = {};
164
+ for (const [k, x] of Object.entries(v)) if (x !== undefined) out[k] = marshal(x, `${path}.${k}`);
165
+ return out;
166
+ }
167
+ throw new TypeError(`node: setting ${path} must be a JSON value or a Dataset (got ${typeof v})`);
168
+ }
169
+
170
+ const hasNodeCheck = () => !!engine && typeof engine.pipelineCheckNodes === 'function';
171
+
172
+ // The engine's check of nodes with settings (op pipelineCheckNodes).
173
+ function nodeCheck(items) {
174
+ return entryResult(engine.pipelineCheckNodes(JSON.stringify(
175
+ items.map((n) => ({ stage: n.stage, settings: n.settingsWire })))), 'pipelineCheckNodes');
176
+ }
177
+
120
178
  /**
121
179
  * A node for the registered stage `name`, with its parameters (the
122
180
  * second argument of the package function it calls, e.g. `{shapes}`,
@@ -135,18 +193,28 @@ export function buildNodes(deps) {
135
193
  throw new FactoidalError(`node: no registered stage named '${name}' (see stages())`,
136
194
  { code: 'option.invalid-value' });
137
195
  }
196
+ const settingsWire = marshal({ ...(params || {}) }, 'params');
197
+ // The engine's reading of the settings, when it has the op.
198
+ let s = null;
199
+ if (hasNodeCheck()) s = nodeCheck([{ stage: info.name, settingsWire }]).stages[0];
138
200
  return Object.freeze({
139
201
  [NODE]: true,
140
202
  stage: info.name,
141
203
  kind: info.kind,
142
- status: info.status,
204
+ status: s ? s.status : info.status,
205
+ registryStatus: info.status,
143
206
  contract: info.contract,
144
207
  assumptions: info.assumptions,
145
208
  wireIn: info.wireIn,
146
- wireOut: info.wireOut,
209
+ wireOut: s ? s.wireOut : info.wireOut,
147
210
  runnable: info.runnable,
148
211
  bound: info.bound,
149
212
  params: Object.freeze({ ...(params || {}) }),
213
+ settingsWire,
214
+ settings: s ? s.settings : null,
215
+ settingsDigest: s ? s.settingsDigest : null,
216
+ outside: Object.freeze(s ? s.outside.map((o) => Object.freeze({ ...o })) : []),
217
+ derived: s ? Object.freeze({ ...s.derived }) : null,
150
218
  });
151
219
  }
152
220
 
@@ -161,14 +229,49 @@ export function buildNodes(deps) {
161
229
  }
162
230
 
163
231
  /**
164
- * A pipeline: the nodes in order (pipelines are flattened).
165
- * `assumptions` is the union of the nodes' named assumptions.
166
- * @returns {{nodes: object[], assumptions: string[]}}
232
+ * The engine's check of the stages of `nodes` in order (op pipelineCheck),
233
+ * or null when the loaded engine has no such op.
234
+ */
235
+ function check(nodes) {
236
+ let r;
237
+ if (hasNodeCheck()) r = nodeCheck(nodes);
238
+ else if (engine && typeof engine.pipelineCheck === 'function') {
239
+ r = entryResult(engine.pipelineCheck(JSON.stringify(nodes.map((n) => n.stage))), 'pipelineCheck');
240
+ } else return null;
241
+ return Object.freeze({
242
+ wellTyped: r.wellTyped,
243
+ wireIn: r.wireIn,
244
+ wireOut: r.wireOut,
245
+ mismatch: r.mismatch === null ? null : Object.freeze({ ...r.mismatch }),
246
+ conversions: Object.freeze(r.conversions.map((c) => Object.freeze({ ...c }))),
247
+ assumptions: Object.freeze([...r.assumptions]),
248
+ contract: Object.freeze({ theorem: r.contract.theorem, stages: Object.freeze([...r.contract.stages]) }),
249
+ stages: Object.freeze(r.stages.map((s) => Object.freeze({
250
+ ...s,
251
+ assumptions: Object.freeze([...s.assumptions]),
252
+ ...(s.outside ? { outside: Object.freeze(s.outside.map((o) => Object.freeze({ ...o }))) } : {}),
253
+ }))),
254
+ });
255
+ }
256
+
257
+ /**
258
+ * A pipeline: the nodes in order (pipelines are flattened). `check` is
259
+ * the engine's op pipelineCheck on the nodes' stages; `wellTyped` and
260
+ * `assumptions` (the union of the nodes' named assumptions) are its
261
+ * fields. Without the op: `check` and `wellTyped` are null and
262
+ * `assumptions` is the union of the nodes' fields.
263
+ * @returns {{nodes: object[], assumptions: string[], wellTyped: boolean|null, check: object|null}}
167
264
  */
168
265
  function pipe(...items) {
169
266
  const nodes = nodesOf(items, 'pipe');
170
267
  if (nodes.length === 0) throw new TypeError('pipe: no nodes');
171
- return Object.freeze({ nodes: Object.freeze(nodes), assumptions: Object.freeze(unionAssumptions(nodes)) });
268
+ const c = check(nodes);
269
+ return Object.freeze({
270
+ nodes: Object.freeze(nodes),
271
+ assumptions: c ? c.assumptions : Object.freeze(unionAssumptions(nodes)),
272
+ wellTyped: c ? c.wellTyped : null,
273
+ check: c,
274
+ });
172
275
  }
173
276
 
174
277
  const isWire = (v) => v !== null && typeof v === 'object' && !(v instanceof Dataset) &&
@@ -180,9 +283,13 @@ export function buildNodes(deps) {
180
283
  * `options.unchecked: true` lets stages that are not `proved` run,
181
284
  * `missing` ones too when the package has a runner for them; each such
182
285
  * step's record says so (evidence.mjs). Without it, a `missing` stage
183
- * refuses before any node runs.
286
+ * refuses before any node runs, and so does a pipeline that the engine's
287
+ * check finds not well typed (op pipelineCheck); with it such a pipeline
288
+ * runs and `proved` is false.
289
+ * `assumptions`: the assumptions of the records on the input wire, then
290
+ * the engine's union for this pipeline (op pipelineCheck), no duplicate.
184
291
  * @returns {Promise<{graph: any, evidence: object[], assumptions: string[],
185
- * proved: boolean, uncheckedSteps: string[]}>}
292
+ * proved: boolean, uncheckedSteps: string[], wellTyped: boolean|null}>}
186
293
  */
187
294
  async function run(pipeline, input, options) {
188
295
  if (options !== undefined && (options === null || typeof options !== 'object')) {
@@ -194,6 +301,14 @@ export function buildNodes(deps) {
194
301
  const why = refusalFor(n, unchecked, Object.prototype.hasOwnProperty.call(RUNNERS, n.stage));
195
302
  if (why) throw why;
196
303
  }
304
+ const c = check(nodes);
305
+ if (c && !c.wellTyped && !unchecked) {
306
+ const m = c.mismatch;
307
+ throw new FactoidalError(`run: the pipeline is not well typed: stage ${m.at} ` +
308
+ `(${nodes[m.at].stage}) writes ${m.out}, stage ${m.at + 1} (${nodes[m.at + 1].stage}) ` +
309
+ `reads ${m.in} (pass { unchecked: true } to run it without a guarantee)`, { code: 'op.failed' });
310
+ }
311
+ const prior = isWire(input) ? input.evidence : [];
197
312
  let wire = isWire(input) ? { graph: input.graph, evidence: [...input.evidence] }
198
313
  : { graph: input, evidence: [] };
199
314
  let owned = null; // a Dataset an earlier node of this run made
@@ -208,7 +323,17 @@ export function buildNodes(deps) {
208
323
  }
209
324
  wire = { graph, evidence: [...wire.evidence, record] };
210
325
  }
211
- return { graph: wire.graph, evidence: wire.evidence, ...summarize(wire.evidence) };
326
+ const s = summarize(wire.evidence);
327
+ if (!c) return { graph: wire.graph, evidence: wire.evidence, ...s, wellTyped: null };
328
+ return {
329
+ graph: wire.graph,
330
+ evidence: wire.evidence,
331
+ assumptions: prior.length === 0 ? [...c.assumptions]
332
+ : unionLists([unionAssumptions(prior), c.assumptions]),
333
+ proved: s.proved && c.wellTyped,
334
+ uncheckedSteps: s.uncheckedSteps,
335
+ wellTyped: c.wellTyped,
336
+ };
212
337
  }
213
338
 
214
339
  return { stages, node, pipe, run };
@@ -0,0 +1,200 @@
1
+ // The LWS reader of an SBM11 generation for lib/sbm11.mjs: the files of a
2
+ // generation (or of a collection root with its CURRENT pointer) stored as
3
+ // Linked Web Storage resources, one resource per file, under one container
4
+ // URL (https://github.com/danbri/factoidal/issues/1010 KR3, read part;
5
+ // LWS 1.0: https://w3c.github.io/lws-protocol/lws10-core/).
6
+ //
7
+ // WHAT THIS FILE IS ALLOWED TO DO
8
+ // Move bytes. The engine names every file and checks every length against
9
+ // the manifest (formal/lean4/L4Factoidal/Storage/Sbm11/HostSession.lean,
10
+ // through the storage medium of formal/lean4/L4Factoidal/Storage/Medium.lean).
11
+ // This file maps a file name to a resource URL, makes HTTP GETs, and hands
12
+ // the bytes over. It reads no manifest and no byte of a file.
13
+ //
14
+ // HTTP (RFC 9110 section 14): each read asks for `Range: bytes=a-b`. A
15
+ // partial answer is used only when it is status 206 and its Content-Range is
16
+ // exactly `bytes a-b/N`. Any other successful answer is the whole
17
+ // representation: it is kept in memory and every later read of that file is
18
+ // cut from it, with no further request. A changed entity tag between two
19
+ // reads of one file refuses the read (the bytes of two versions are never
20
+ // mixed). Limits: no Merkle check here (the engine's Verify.none path), no
21
+ // eviction of the in-memory copies, no retry.
22
+ //
23
+ // EVIDENCE
24
+ // `evidence()` lists each resource read: { resource, etag, lastModified,
25
+ // requests, partial, bytes } -- `partial` the number of 206 answers. The
26
+ // store from openSbm11 answers it as `store.evidence`.
27
+ //
28
+ // SYNCHRONOUS TRANSPORT
29
+ // The engine is inside WebAssembly and asks for bytes synchronously, so the
30
+ // reader needs a synchronous GET: `transport(url, headers) -> { status,
31
+ // header(name) -> string | null, bytes: Uint8Array }`. In Node the default
32
+ // is lib/sync-http-node.mjs (fetch in a worker thread, Atomics.wait); in a
33
+ // browser worker it is a synchronous XMLHttpRequest. A browser window
34
+ // thread has neither: pass `transport` or run the engine in a worker.
35
+
36
+ function containerUrl (url) {
37
+ const s = String(url)
38
+ return s.endsWith('/') ? s : s + '/'
39
+ }
40
+
41
+ function resourceUrl (base, file) {
42
+ return new URL(String(file).split('/').map(encodeURIComponent).join('/'), base).href
43
+ }
44
+
45
+ /** A synchronous GET with XMLHttpRequest (a browser worker). */
46
+ export function xhrSyncTransport (url, headers) {
47
+ const x = new XMLHttpRequest()
48
+ x.open('GET', url, false)
49
+ x.responseType = 'arraybuffer'
50
+ for (const [k, v] of Object.entries(headers)) x.setRequestHeader(k, v)
51
+ x.send()
52
+ return {
53
+ status: x.status,
54
+ header: (name) => x.getResponseHeader(name),
55
+ bytes: new Uint8Array(x.response || new ArrayBuffer(0))
56
+ }
57
+ }
58
+
59
+ function parseContentRange (value) {
60
+ const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/.exec(String(value || '').trim())
61
+ if (!m) return null
62
+ return { first: Number(m[1]), last: Number(m[2]), total: m[3] === '*' ? null : Number(m[3]) }
63
+ }
64
+
65
+ function unsatisfiedTotal (value) {
66
+ const m = /^bytes\s+\*\/(\d+)$/.exec(String(value || '').trim())
67
+ return m ? Number(m[1]) : null
68
+ }
69
+
70
+ /**
71
+ * A reader ({ readAt, size, evidence, close }) of the SBM11 files held as
72
+ * LWS resources under the container `url`.
73
+ *
74
+ * @param {string} url the container that holds CURRENT and the generation
75
+ * (or the generation container itself)
76
+ * @param {object} [opts]
77
+ * @param {Function} [opts.transport] the synchronous GET (see above)
78
+ * @param {string} [opts.auth] a value for the Authorization header, such as
79
+ * `Bearer <access token>` (an LWS access token)
80
+ * @param {object} [opts.headers] further request headers
81
+ */
82
+ export function lwsSbm11Reader (url, opts = {}) {
83
+ const base = containerUrl(url)
84
+ let transport = opts.transport
85
+ if (typeof transport !== 'function') {
86
+ if (typeof XMLHttpRequest === 'function' && typeof document === 'undefined') {
87
+ transport = xhrSyncTransport
88
+ } else {
89
+ throw new TypeError('lwsSbm11Reader: no synchronous transport here; pass opts.transport (Node: @factoidal/core gives one)')
90
+ }
91
+ }
92
+ const extra = { ...(opts.headers || {}) }
93
+ if (opts.auth) extra.authorization = String(opts.auth)
94
+ // file -> { url, etag, lastModified, total, whole, requests, partial, bytes }
95
+ const files = new Map()
96
+ const errors = []
97
+
98
+ function entry (file) {
99
+ let e = files.get(file)
100
+ if (!e) {
101
+ e = { url: resourceUrl(base, file), etag: null, lastModified: null, total: undefined, whole: null, requests: 0, partial: 0, bytes: 0 }
102
+ files.set(file, e)
103
+ }
104
+ return e
105
+ }
106
+
107
+ function fail (file, message) {
108
+ errors.push(`${file}: ${message}`)
109
+ return null
110
+ }
111
+
112
+ function get (e, file, first, last) {
113
+ const headers = { ...extra, range: `bytes=${first}-${last}` }
114
+ let r
115
+ try {
116
+ r = transport(e.url, headers)
117
+ } catch (error) {
118
+ fail(file, `GET ${e.url}: ${error && error.message ? error.message : error}`)
119
+ return null
120
+ }
121
+ e.requests += 1
122
+ const tag = r.header('etag')
123
+ if (r.status >= 200 && r.status < 300) {
124
+ if (e.etag !== null && tag !== e.etag) {
125
+ fail(file, `the entity tag changed from ${e.etag} to ${tag}`)
126
+ return null
127
+ }
128
+ e.etag = tag
129
+ e.lastModified = r.header('last-modified')
130
+ e.bytes += r.bytes.length
131
+ }
132
+ return r
133
+ }
134
+
135
+ function sizeOf (file) {
136
+ const e = entry(file)
137
+ if (e.total !== undefined) return e.total
138
+ const r = get(e, file, 0, 0)
139
+ if (r === null) return null
140
+ const cr = parseContentRange(r.header('content-range'))
141
+ if (r.status === 206 && cr && cr.first === 0 && cr.last === 0 && cr.total !== null && r.bytes.length === 1) {
142
+ e.partial += 1
143
+ e.total = cr.total
144
+ } else if (r.status === 416 && unsatisfiedTotal(r.header('content-range')) !== null) {
145
+ e.total = unsatisfiedTotal(r.header('content-range'))
146
+ e.whole = new Uint8Array(0)
147
+ } else if (r.status === 200) {
148
+ e.whole = r.bytes
149
+ e.total = r.bytes.length
150
+ } else if (r.status === 404 || r.status === 410) {
151
+ e.total = null
152
+ } else {
153
+ fail(file, `GET ${e.url}: status ${r.status}`)
154
+ return null
155
+ }
156
+ return e.total
157
+ }
158
+
159
+ return {
160
+ readAt (file, offset, length) {
161
+ const e = entry(file)
162
+ if (length === 0) return new Uint8Array(0)
163
+ if (e.whole !== null) {
164
+ return offset + length <= e.whole.length ? e.whole.subarray(offset, offset + length) : null
165
+ }
166
+ const last = offset + length - 1
167
+ const r = get(e, file, offset, last)
168
+ if (r === null) return null
169
+ const cr = parseContentRange(r.header('content-range'))
170
+ if (r.status === 206 && cr && cr.first === offset && cr.last === last && r.bytes.length === length) {
171
+ e.partial += 1
172
+ return r.bytes
173
+ }
174
+ if (r.status === 200) {
175
+ e.whole = r.bytes
176
+ e.total = r.bytes.length
177
+ return offset + length <= r.bytes.length ? r.bytes.subarray(offset, offset + length) : null
178
+ }
179
+ return fail(file, `GET ${e.url} bytes ${offset}-${last}: status ${r.status}`)
180
+ },
181
+ size (file) {
182
+ return sizeOf(file)
183
+ },
184
+ /** Each resource read: { resource, etag, lastModified, requests, partial, bytes }. */
185
+ evidence () {
186
+ const out = []
187
+ for (const e of files.values()) {
188
+ if (e.requests === 0 || e.etag === null) continue
189
+ out.push({ resource: e.url, etag: e.etag, lastModified: e.lastModified, requests: e.requests, partial: e.partial, bytes: e.bytes })
190
+ }
191
+ return out
192
+ },
193
+ /** The transport failures, as text (a failed read reaches the engine as no bytes). */
194
+ errors () { return errors.slice() },
195
+ close () {
196
+ files.clear()
197
+ if (typeof opts.onClose === 'function') opts.onClose()
198
+ }
199
+ }
200
+ }
package/lib/sbm11.mjs CHANGED
@@ -68,6 +68,32 @@ export function setDefaultSbm11Reader (factory) {
68
68
  defaultReader = factory
69
69
  }
70
70
 
71
+ /**
72
+ * A reader over files held in memory: `files` is a Map (or a plain object)
73
+ * from file name to Uint8Array, for example the result of packSbm11. It
74
+ * moves bytes only; a read past the end of a file, or of a file it does not
75
+ * hold, answers null, and the engine refuses it with the file name.
76
+ */
77
+ export function mapReader (files) {
78
+ const m = files instanceof Map ? files : new Map(Object.entries(files || {}))
79
+ return {
80
+ readAt (file, offset, length) {
81
+ const b = m.get(file)
82
+ if (!b || offset < 0 || length < 0 || offset + length > b.length) return null
83
+ return b.subarray(offset, offset + length)
84
+ },
85
+ size (file) {
86
+ const b = m.get(file)
87
+ return b ? b.length : null
88
+ }
89
+ }
90
+ }
91
+
92
+ function opsReflection (eng) {
93
+ const r = eng.call('ops', [])
94
+ return typeof r === 'string' ? JSON.parse(r) : r
95
+ }
96
+
71
97
  function envelope (raw) {
72
98
  const j = typeof raw === 'string' ? JSON.parse(raw) : raw
73
99
  if (!j || j.ok !== true) {
@@ -98,8 +124,15 @@ export function sbm11Api (loadEngine) {
98
124
  const opened = envelope(eng.call('sbm11Open', [String(source)]))
99
125
  const handle = opened.handle
100
126
  let open = true
127
+ const reader = options.reader || readers.get(String(source))
101
128
  return {
102
129
  handle,
130
+ /** The resources the reader read (`lwsSbm11Reader`): `[{ resource,
131
+ * etag, lastModified, requests, partial, bytes }]`; undefined for a
132
+ * reader that records none. */
133
+ get evidence () {
134
+ return reader && typeof reader.evidence === 'function' ? reader.evidence() : undefined
135
+ },
103
136
  generation: opened.generation,
104
137
  terms: Number(opened.terms),
105
138
  query (sparql, qopts = {}) {
@@ -144,5 +177,44 @@ export function sbm11Api (loadEngine) {
144
177
  }
145
178
  }
146
179
  }
147
- return { openSbm11 }
180
+ /**
181
+ * Pack an RDF document into the files of one SBM11 generation, in memory
182
+ * (the wasm op sbm11PackFiles, formal/lean4/Wasm/Ops/Sbm11Pack.lean;
183
+ * https://github.com/danbri/factoidal/issues/1009 stage 2). `text` is a
184
+ * string or UTF-8 bytes; `options.syntax` is turtle, trig, nquads,
185
+ * ntriples, rdfxml or jsonld; `options.base` the base IRI. Resolves to a
186
+ * Map from file name to Uint8Array: the files `l4sbm11-pack` writes for the
187
+ * same quads (data files, `.merkle` sidecars, `manifest.sbm11`), without
188
+ * `CURRENT`. Open them with `openSbm11(name, { reader: mapReader(files) })`,
189
+ * or write each under its name into one directory. The engine names every
190
+ * file and computes every byte (iron rule 7).
191
+ */
192
+ async function packSbm11 (text, options = {}) {
193
+ const syntax = options.syntax ?? options.format
194
+ if (typeof syntax !== 'string' || syntax === '') {
195
+ throw new TypeError('packSbm11: options.syntax is required (turtle, trig, nquads, ntriples, rdfxml or jsonld)')
196
+ }
197
+ const eng = await loadEngine()
198
+ const refl = opsReflection(eng)
199
+ const blobIoOps = Array.isArray(refl && refl.blobIoOps) ? refl.blobIoOps : []
200
+ if (typeof eng.callBlobIO !== 'function' || !blobIoOps.includes('sbm11PackFiles')) {
201
+ const e = new Error('packSbm11: this wasm module has no sbm11PackFiles op (it was built before ' +
202
+ 'https://github.com/danbri/factoidal/issues/1009 stage 2)')
203
+ e.code = 'SBM11_NO_PACK_OP'
204
+ throw e
205
+ }
206
+ const bytes = typeof text === 'string' ? new TextEncoder().encode(text) : new Uint8Array(text)
207
+ const base = options.base == null ? '' : String(options.base)
208
+ const files = new Map()
209
+ let count = 1
210
+ for (let i = 0; i < count; i++) {
211
+ const answer = eng.callBlobIO('sbm11PackFiles', [syntax, base, String(i)], bytes)
212
+ const j = envelope(answer.envelope)
213
+ count = Number(j.count)
214
+ files.set(j.name, answer.bytes)
215
+ }
216
+ return files
217
+ }
218
+
219
+ return { openSbm11, packSbm11 }
148
220
  }
@@ -0,0 +1,71 @@
1
+ // A synchronous HTTP GET for Node, for the readers of lib/sbm11.mjs that read
2
+ // over HTTP (lib/sbm11-lws.mjs; https://github.com/danbri/factoidal/issues/1010).
3
+ // The engine is inside WebAssembly and asks for bytes synchronously; Node's
4
+ // fetch is asynchronous. A worker thread makes the fetch; this thread waits
5
+ // on a shared Int32Array (Atomics.wait) and takes the answer with
6
+ // receiveMessageOnPort. Pure I/O: no header is read here, no status judged.
7
+ //
8
+ // The thread that calls the transport is blocked until the answer comes, so
9
+ // the server must not run on that thread (start it in another process or
10
+ // worker).
11
+
12
+ import { Worker, MessageChannel, receiveMessageOnPort } from 'node:worker_threads'
13
+
14
+ const WORKER = `
15
+ const { workerData } = require('node:worker_threads')
16
+ const { port, signal } = workerData
17
+ const flag = new Int32Array(signal)
18
+ port.on('message', async (req) => {
19
+ let out
20
+ try {
21
+ const r = await fetch(req.url, { headers: req.headers, redirect: 'follow' })
22
+ const headers = {}
23
+ r.headers.forEach((v, k) => { headers[k] = v })
24
+ out = { status: r.status, headers, bytes: new Uint8Array(await r.arrayBuffer()) }
25
+ } catch (e) {
26
+ out = { error: String(e && e.message ? e.message : e) }
27
+ }
28
+ port.postMessage(out)
29
+ Atomics.store(flag, 0, 1)
30
+ Atomics.notify(flag, 0)
31
+ })
32
+ `
33
+
34
+ /**
35
+ * A synchronous transport `(url, headers) -> { status, header(name), bytes }`
36
+ * with a `close()` that stops its worker.
37
+ *
38
+ * @param {object} [opts]
39
+ * @param {number} [opts.timeoutMs] the most one request may take (default 60000)
40
+ */
41
+ export function nodeSyncTransport (opts = {}) {
42
+ const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 60000
43
+ const signal = new SharedArrayBuffer(4)
44
+ const flag = new Int32Array(signal)
45
+ const { port1, port2 } = new MessageChannel()
46
+ const worker = new Worker(WORKER, { eval: true, workerData: { port: port2, signal }, transferList: [port2] })
47
+ worker.unref()
48
+ port1.unref()
49
+ let closed = false
50
+ function transport (url, headers) {
51
+ if (closed) throw new Error('the transport is closed')
52
+ Atomics.store(flag, 0, 0)
53
+ port1.postMessage({ url: String(url), headers: { ...headers } })
54
+ if (Atomics.wait(flag, 0, 0, timeoutMs) === 'timed-out') {
55
+ throw new Error(`GET ${url}: no answer in ${timeoutMs} ms`)
56
+ }
57
+ const got = receiveMessageOnPort(port1)
58
+ if (!got) throw new Error(`GET ${url}: no answer from the transport worker`)
59
+ const m = got.message
60
+ if (m.error) throw new Error(m.error)
61
+ const h = m.headers
62
+ return { status: m.status, header: (name) => (Object.hasOwn(h, name.toLowerCase()) ? h[name.toLowerCase()] : null), bytes: m.bytes }
63
+ }
64
+ transport.close = () => {
65
+ if (closed) return
66
+ closed = true
67
+ port1.close()
68
+ worker.terminate()
69
+ }
70
+ return transport
71
+ }
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.14.0';
5
+ export const version = '0.16.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",