@factoidal/core 0.10.0 → 0.12.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/dataset.mjs CHANGED
@@ -13,49 +13,131 @@
13
13
  // Lifetime: dispose() (or `using ds = ...`, Symbol.dispose) releases the
14
14
  // engine copy. A Dataset that is garbage collected without dispose() is
15
15
  // released by a FinalizationRegistry callback. Every method of a disposed
16
- // Dataset throws a TypeError that names the method.
16
+ // Dataset throws the engine's FactoidalError, code handle.disposed.
17
17
 
18
- import { nquadsLineToQuad, quadToNQuads, termToNQuads, termFromSrj } from '../rdfjs.mjs';
18
+ import { dataFactory, quadFromJson, quadToJson, termToJson, termFromSrj, Literal, NamedNode } from '../rdfjs.mjs';
19
+ import { FactoidalError, envelopeResult } from './errors.mjs';
19
20
 
20
21
  const INTERNAL = Symbol('factoidal.Dataset.internal');
21
22
 
22
- // Writer names (lower case) -> the engine's writer tag. JSON-LD is served
23
+ // Writer names (lower case) -> the engine's writer tag: the engine's table
24
+ // (op engineTables, `writerFormats`), read once per engine. JSON-LD is served
23
25
  // by the op datasetToJsonLd; the SPARQL results formats are query outputs.
24
- const WRITER_TAGS = {
25
- nquads: 'nquads', nq: 'nquads', 'n-quads': 'nquads',
26
- ntriples: 'ntriples', nt: 'ntriples', 'n-triples': 'ntriples',
27
- turtle: 'turtle', ttl: 'turtle',
28
- trig: 'trig',
29
- rdfxml: 'rdfxml', 'rdf-xml': 'rdfxml', rdf: 'rdfxml',
30
- jsonld: 'jsonld', 'json-ld': 'jsonld',
31
- rdfc: 'rdfc', canonical: 'rdfc', 'rdfc-1.0': 'rdfc',
32
- };
26
+ // `WRITER_TAGS` stays exported for callers that read it; it is filled from
27
+ // the engine on the first Dataset.
28
+ const WRITER_TAGS = {};
33
29
 
34
30
  const VERSIONS = { '1.1': '11', '1.2': '12' };
35
31
 
36
- /** Throw a TypeError when `options` has a key outside `allowed`. */
37
- function checkOptions(who, options, allowed) {
32
+ /**
33
+ * The options object of public function `who`, checked by the engine (op
34
+ * apiCheck, formal/lean4/Wasm/Ops/Handles.lean): every key one the function
35
+ * takes, every format, regime, output and version in the engine's tables, and
36
+ * no two options that cannot be combined. A value that is not an object is a
37
+ * TypeError (a wrong JavaScript type); every other refusal is the engine's
38
+ * FactoidalError.
39
+ */
40
+ function checkOptions(e, who, options) {
38
41
  if (options == null) return {};
39
42
  if (typeof options !== 'object' || Array.isArray(options)) {
40
43
  throw new TypeError(`${who}: options must be an object, got ${Array.isArray(options) ? 'array' : typeof options}`);
41
44
  }
45
+ // Each value as the engine reads it: a primitive as its String form, an
46
+ // object, array or function as {}, undefined as null.
47
+ const wire = {};
42
48
  for (const key of Object.keys(options)) {
43
- if (!allowed.includes(key)) {
44
- throw new TypeError(
45
- `${who}: unknown option '${key}'. Known options: ${allowed.join(', ') || '(none)'}`);
46
- }
49
+ const v = options[key];
50
+ wire[key] = v === undefined || v === null ? null
51
+ : (typeof v === 'object' || typeof v === 'function') ? {} : String(v);
47
52
  }
53
+ // An engine with no apiCheck op (the F* entry of tests/engine-compare, a
54
+ // private comparison build) has no option tables: its ops refuse instead.
55
+ if (typeof e.apiCheck !== 'function') return options;
56
+ envelopeResult(e.apiCheck(who, JSON.stringify(wire)));
48
57
  return options;
49
58
  }
50
59
 
51
- function envelope(jsonText, what) {
52
- const r = JSON.parse(jsonText);
53
- if (!r.ok) throw new Error(`${what}: ${r.error}`);
54
- return r;
60
+ function envelope(jsonText, _what, text) {
61
+ return envelopeResult(jsonText, text);
62
+ }
63
+
64
+ // The quads of the compact answer of datasetQuadsJson: each distinct term is
65
+ // built once and shared (TermJson.compactCertified).
66
+ function quadsFromCompact(terms, ixs) {
67
+ const ts = terms.map(termFromSrj);
68
+ const dg = dataFactory.defaultGraph();
69
+ return ixs.map((ix) => dataFactory.quad(ts[ix[0]], ts[ix[1]], ts[ix[2]],
70
+ ix.length > 3 ? ts[ix[3]] : dg));
55
71
  }
56
72
 
57
- const SERIALIZE_OPTIONS = ['format', 'version', 'prefixes', 'literalShorthand',
58
- 'useNativeTypes', 'useRdfType', 'rdfDirection'];
73
+ // The region of op datasetQuadsBin, read as the engine wrote it
74
+ // (formal/lean4/L4Factoidal/RDF/QuadsBin.lean: `binSpec` is the layout,
75
+ // `decodeBin` the reader this function follows, `decodeBin_quadsBin` the
76
+ // theorem that the region reads back as exactly the quads). Every number is a
77
+ // u32 little-endian; each term is in the term codec of DLOG version 2, which
78
+ // names the term kind, the datatype IRI, the language tag and the base
79
+ // direction explicitly, so this function copies fields and decides nothing.
80
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
81
+ const DIRECTION = ['', 'ltr', 'rtl'];
82
+
83
+ function quadsFromRegion(bytes, out) {
84
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
85
+ if (bytes.length < 12 || v.getUint32(0, true) !== 0x31425146) {
86
+ throw new FactoidalError('iterate: the engine answered no datasetQuadsBin region', { code: 'op.failed' });
87
+ }
88
+ const nTerms = v.getUint32(4, true);
89
+ const nQuads = v.getUint32(8, true);
90
+ let p = 12;
91
+ // UTF-8 bytes to a string; a short run of bytes below 0x80 (one UTF-16
92
+ // unit each) is copied without a TextDecoder call.
93
+ const str = () => {
94
+ const n = v.getUint32(p, true);
95
+ const a = p + 4;
96
+ p = a + n;
97
+ if (n <= 64) {
98
+ let i = a;
99
+ while (i < p && bytes[i] < 0x80) i++;
100
+ if (i === p) return String.fromCharCode.apply(null, bytes.subarray(a, p));
101
+ }
102
+ return UTF8.decode(bytes.subarray(a, p));
103
+ };
104
+ const datatypes = new Map();
105
+ const term = () => {
106
+ switch (bytes[p++]) {
107
+ case 0: return new NamedNode(str());
108
+ case 1: return dataFactory.blankNode(str());
109
+ case 2: {
110
+ const lexical = str();
111
+ const dt = str();
112
+ let datatype = datatypes.get(dt);
113
+ if (datatype === undefined) { datatype = new NamedNode(dt); datatypes.set(dt, datatype); }
114
+ const flag = bytes[p++];
115
+ const language = flag === 0 ? '' : str();
116
+ const direction = flag === 2 ? DIRECTION[bytes[p++]] : '';
117
+ return new Literal(lexical, language, datatype, direction);
118
+ }
119
+ case 3: {
120
+ const s = term();
121
+ const pr = term();
122
+ return dataFactory.quad(s, pr, term());
123
+ }
124
+ default: throw new FactoidalError('iterate: a term tag outside the DLOG version 2 codec', { code: 'op.failed' });
125
+ }
126
+ };
127
+ const ts = new Array(nTerms);
128
+ for (let i = 0; i < nTerms; i++) ts[i] = term();
129
+ const dg = dataFactory.defaultGraph();
130
+ for (let i = 0; i < nQuads; i++, p += 16) {
131
+ const g = v.getUint32(p + 12, true);
132
+ out.push(dataFactory.quad(ts[v.getUint32(p, true)], ts[v.getUint32(p + 4, true)],
133
+ ts[v.getUint32(p + 8, true)], g === 0 ? dg : ts[g - 1]));
134
+ }
135
+ return nQuads;
136
+ }
137
+
138
+ // Quads of the first datasetQuadsBin call while iterating: an iteration that
139
+ // stops early reads this many; the rest come in one more call.
140
+ const FIRST_PAGE = 4096;
59
141
 
60
142
  class Dataset {
61
143
  /** @private Datasets come from parse(), csvwToRdf() and the other producers. */
@@ -99,9 +181,11 @@ class Dataset {
99
181
  */
100
182
  get info() { return this._info; }
101
183
 
184
+ // A disposed Dataset: the engine answers handle.disposed for its handle.
102
185
  _open(who) {
103
186
  if (this._disposed) {
104
- throw new TypeError(`Dataset.${who}: the Dataset was disposed (handle '${this._handle}')`);
187
+ envelopeResult(this._ctx.e.datasetClose(this._handle));
188
+ throw new FactoidalError(`Dataset.${who}: the Dataset was disposed`, { code: 'handle.disposed' });
105
189
  }
106
190
  }
107
191
 
@@ -123,7 +207,7 @@ class Dataset {
123
207
  async query(sparql, options) {
124
208
  this._open('query');
125
209
  return this._ctx.queryHandle(this, sparql,
126
- checkOptions('Dataset.query', options, ['sparql12', 'version', 'entail', 'output']));
210
+ checkOptions(this._ctx.e, 'Dataset.query', options));
127
211
  }
128
212
 
129
213
  /**
@@ -134,11 +218,11 @@ class Dataset {
134
218
  */
135
219
  async update(updateText, options) {
136
220
  this._open('update');
137
- checkOptions('Dataset.update', options, []);
221
+ checkOptions(this._ctx.e, 'Dataset.update', options);
138
222
  if (typeof updateText !== 'string') {
139
223
  throw new TypeError('Dataset.update: updateText must be a string');
140
224
  }
141
- const r = envelope(this._ctx.e.datasetUpdate(this._handle, updateText), 'update');
225
+ const r = envelope(this._ctx.e.datasetUpdate(this._handle, updateText), 'update', updateText);
142
226
  this._changed(r.count);
143
227
  return this;
144
228
  }
@@ -152,7 +236,7 @@ class Dataset {
152
236
  async validateShacl(shapes, options) {
153
237
  this._open('validateShacl');
154
238
  return this._ctx.shacl(this, shapes,
155
- checkOptions('Dataset.validateShacl', options, ['format', 'baseIRI']));
239
+ checkOptions(this._ctx.e, 'Dataset.validateShacl', options));
156
240
  }
157
241
 
158
242
  /**
@@ -164,7 +248,7 @@ class Dataset {
164
248
  async validateShex(schema, options) {
165
249
  this._open('validateShex');
166
250
  return this._ctx.shex(this, schema,
167
- checkOptions('Dataset.validateShex', options, ['focus', 'shape']));
251
+ checkOptions(this._ctx.e, 'Dataset.validateShex', options));
168
252
  }
169
253
 
170
254
  /**
@@ -194,8 +278,8 @@ class Dataset {
194
278
  */
195
279
  async serialize(options) {
196
280
  this._open('serialize');
197
- if (typeof options === 'string') return this._write({ format: options });
198
- return this._write(checkOptions('Dataset.serialize', options, SERIALIZE_OPTIONS));
281
+ const opts = typeof options === 'string' ? { format: options } : options;
282
+ return this._write(checkOptions(this._ctx.e, 'Dataset.serialize', opts));
199
283
  }
200
284
 
201
285
  /**
@@ -205,33 +289,18 @@ class Dataset {
205
289
  */
206
290
  toString(formatOrOptions) {
207
291
  this._open('toString');
208
- const opts = typeof formatOrOptions === 'string'
209
- ? { format: formatOrOptions }
210
- : checkOptions('Dataset.toString', formatOrOptions, SERIALIZE_OPTIONS);
211
- return this._write(opts);
292
+ const opts = typeof formatOrOptions === 'string' ? { format: formatOrOptions } : formatOrOptions;
293
+ return this._write(checkOptions(this._ctx.e, 'Dataset.toString', opts));
212
294
  }
213
295
 
214
296
  _write(opts) {
215
297
  const e = this._ctx.e;
216
298
  const name = String(opts.format || 'nquads').toLowerCase();
217
- const tag = WRITER_TAGS[name];
218
- if (!tag) {
219
- throw new TypeError(
220
- `Dataset.serialize: unknown format '${opts.format}'. Known formats: ` +
221
- [...new Set(Object.values(WRITER_TAGS))].join(', '));
222
- }
223
- let suffix = '';
224
- if (opts.version !== undefined) {
225
- suffix = VERSIONS[String(opts.version)];
226
- if (!suffix) throw new TypeError("Dataset.serialize: version must be '1.1' or '1.2'");
227
- }
228
- if (tag !== 'turtle' && (opts.prefixes !== undefined || opts.literalShorthand !== undefined)) {
229
- throw new TypeError("Dataset.serialize: 'prefixes' and 'literalShorthand' apply to format 'turtle' only");
230
- }
231
- const jsonldKeys = ['useNativeTypes', 'useRdfType', 'rdfDirection'];
232
- if (tag !== 'jsonld' && jsonldKeys.some((k) => opts[k] !== undefined)) {
233
- throw new TypeError(`Dataset.serialize: ${jsonldKeys.join(', ')} apply to format 'jsonld' only`);
234
- }
299
+ const writerTags = this._ctx.tables().writerFormats;
300
+ // The engine checked the format, the version and the option
301
+ // combination (op apiCheck) before this.
302
+ const tag = writerTags[name];
303
+ const suffix = opts.version !== undefined ? VERSIONS[String(opts.version)] : '';
235
304
  if (tag === 'jsonld') {
236
305
  if (typeof e.datasetToJsonLd !== 'function') throw this._ctx.missing('serialize {format:jsonld}', 'datasetToJsonLd');
237
306
  const r = envelope(e.datasetToJsonLd(this._handle, JSON.stringify({
@@ -273,40 +342,58 @@ class Dataset {
273
342
  }
274
343
 
275
344
  /**
276
- * RDF/JS quads, read from the engine's N-Quads text one line at a time.
345
+ * RDF/JS quads, built from the engine's term objects (op datasetQuadsJson),
346
+ * in the line order of toNQuads(). No N-Quads text is read here.
277
347
  * @returns {Iterator<object>}
278
348
  */
279
349
  *[Symbol.iterator]() {
280
350
  this._open('iterate');
281
351
  if (this._quads) { yield* this._quads; return; }
282
- const text = this.toNQuads();
283
- let start = 0;
284
- let lineNo = 0;
285
- while (start < text.length) {
286
- let end = text.indexOf('\n', start);
287
- if (end < 0) end = text.length;
288
- lineNo += 1;
289
- const line = text.slice(start, end);
290
- start = end + 1;
291
- const q = nquadsLineToQuad(line, lineNo);
292
- if (q) yield q;
352
+ const e = this._ctx.e;
353
+ if (typeof e.datasetQuadsBin !== 'function') { yield* this._allQuads(); return; }
354
+ // The engine's region (op datasetQuadsBin) in two calls, a first page and
355
+ // the rest; the whole list is cached when the rest is read.
356
+ const acc = [];
357
+ const first = e.datasetQuadsBin(this._handle, `{"offset":0,"limit":${FIRST_PAGE}}`);
358
+ quadsFromRegion(first.bytes, acc);
359
+ for (let i = 0; i < acc.length; i++) yield acc[i];
360
+ if (acc.length < first.envelope.total) {
361
+ const rest = e.datasetQuadsBin(this._handle, `{"offset":${acc.length}}`);
362
+ const from = acc.length;
363
+ quadsFromRegion(rest.bytes, acc);
364
+ for (let i = from; i < acc.length; i++) yield acc[i];
365
+ }
366
+ if (!this._quads) this._quads = Object.freeze(acc);
367
+ }
368
+
369
+ // Every quad, read once and cached.
370
+ _allQuads() {
371
+ if (!this._quads) {
372
+ const e = this._ctx.e;
373
+ if (typeof e.datasetQuadsBin === 'function') {
374
+ const acc = [];
375
+ quadsFromRegion(e.datasetQuadsBin(this._handle, '').bytes, acc);
376
+ this._quads = Object.freeze(acc);
377
+ } else {
378
+ const r = envelope(e.datasetQuadsJson(this._handle, '{"compact":true}'), 'iterate');
379
+ this._quads = Object.freeze(r.ixs ? quadsFromCompact(r.terms, r.ixs) : r.quads.map((q) => quadFromJson(q)));
380
+ }
293
381
  }
382
+ return this._quads;
294
383
  }
295
384
 
296
385
  /** Every quad as an array. */
297
386
  toArray() {
298
387
  this._open('toArray');
299
- if (!this._quads) this._quads = Object.freeze([...this]);
300
- return this._quads.slice();
388
+ return this._allQuads().slice();
301
389
  }
302
390
 
303
391
  // A quad pattern for the ops datasetMatch and datasetMatchCount: four
304
- // N-Triples term tokens, '' for an unbound position, 'DEFAULT' for the
305
- // default graph.
392
+ // engine term objects, null for an unbound position, {type:'default'} for
393
+ // the default graph.
306
394
  _pattern(subject, predicate, object, graph) {
307
- const tok = (t) => (t == null ? '' : termToNQuads(t));
308
- const g = graph == null ? '' : (graph.termType === 'DefaultGraph' ? 'DEFAULT' : termToNQuads(graph));
309
- return JSON.stringify([tok(subject), tok(predicate), tok(object), g]);
395
+ return JSON.stringify([termToJson(subject), termToJson(predicate), termToJson(object),
396
+ termToJson(graph)]);
310
397
  }
311
398
 
312
399
  /** RDF/JS DatasetCore.has: one indexed lookup in the engine. */
@@ -336,7 +423,8 @@ class Dataset {
336
423
  */
337
424
  add(quad) {
338
425
  this._open('add');
339
- const r = envelope(this._ctx.e.datasetAddQuads(this._handle, quadToNQuads(quad)), 'add');
426
+ const r = envelope(this._ctx.e.datasetAddQuadsJson(this._handle,
427
+ JSON.stringify([quadToJson(quad)])), 'add');
340
428
  this._changed(r.count);
341
429
  return this;
342
430
  }
@@ -344,7 +432,8 @@ class Dataset {
344
432
  /** RDF/JS DatasetCore.delete: remove one quad. */
345
433
  delete(quad) {
346
434
  this._open('delete');
347
- const r = envelope(this._ctx.e.datasetDeleteQuads(this._handle, quadToNQuads(quad)), 'delete');
435
+ const r = envelope(this._ctx.e.datasetDeleteQuadsJson(this._handle,
436
+ JSON.stringify([quadToJson(quad)])), 'delete');
348
437
  this._changed(r.count);
349
438
  return this;
350
439
  }
@@ -20,6 +20,7 @@
20
20
  // would; false when the log is empty
21
21
  //
22
22
  // No Node built-ins: the browser imports this file as it is.
23
+ import { hostError } from './errors.mjs';
23
24
 
24
25
  const DELTA_STORE = 'deltaBatches';
25
26
 
@@ -65,7 +66,7 @@ export function memoryDeltaLogStore() {
65
66
  function indexedDbFactory() {
66
67
  const factory = globalThis.indexedDB;
67
68
  if (!factory) {
68
- throw new Error('deltaLog: this runtime has no indexedDB; the browser entry keeps the log in IndexedDB');
69
+ throw hostError('deltaLog: this runtime has no indexedDB; the browser entry keeps the log in IndexedDB');
69
70
  }
70
71
  return factory;
71
72
  }
@@ -80,23 +81,23 @@ function idbOpen(dbName) {
80
81
  }
81
82
  };
82
83
  req.onsuccess = () => resolve(req.result);
83
- req.onerror = () => reject(req.error || new Error('indexedDB.open failed'));
84
- req.onblocked = () => reject(new Error('indexedDB.open blocked (another tab holds an open connection at an older version)'));
84
+ req.onerror = () => reject(hostError('indexedDB.open failed', req.error));
85
+ req.onblocked = () => reject(hostError('indexedDB.open blocked (another tab holds an open connection at an older version)'));
85
86
  });
86
87
  }
87
88
 
88
89
  function requestDone(req) {
89
90
  return new Promise((resolve, reject) => {
90
91
  req.onsuccess = () => resolve(req.result);
91
- req.onerror = () => reject(req.error || new Error('IndexedDB request failed'));
92
+ req.onerror = () => reject(hostError('IndexedDB request failed', req.error));
92
93
  });
93
94
  }
94
95
 
95
96
  function transactionDone(tx) {
96
97
  return new Promise((resolve, reject) => {
97
98
  tx.oncomplete = () => resolve();
98
- tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
99
- tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
99
+ tx.onerror = () => reject(hostError('IndexedDB transaction failed', tx.error));
100
+ tx.onabort = () => reject(hostError('IndexedDB transaction aborted', tx.error));
100
101
  });
101
102
  }
102
103
 
@@ -164,8 +165,8 @@ export function indexedDbDeltaLogStore() {
164
165
  await new Promise((resolve, reject) => {
165
166
  const req = indexedDbFactory().deleteDatabase(name);
166
167
  req.onsuccess = () => resolve();
167
- req.onerror = () => reject(req.error || new Error('indexedDB.deleteDatabase failed'));
168
- req.onblocked = () => reject(new Error('indexedDB.deleteDatabase blocked (another open connection)'));
168
+ req.onerror = () => reject(hostError('indexedDB.deleteDatabase failed', req.error));
169
+ req.onblocked = () => reject(hostError('indexedDB.deleteDatabase blocked (another open connection)'));
169
170
  });
170
171
  },
171
172
  async corruptLast(name) {
package/lib/errors.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // factoidal — the one error class of the package
2
+ // (https://github.com/danbri/factoidal/issues/882 item 4).
3
+ //
4
+ // Every function of the package rejects (or throws) with a FactoidalError.
5
+ // The engine decides its `code`, its `spec` section and its `position`
6
+ // (formal/lean4/Wasm/Ops/ErrorModel.lean); this file only copies them out
7
+ // of the error envelope. A TypeError is thrown only when an argument has the
8
+ // wrong JavaScript type (a number where a string is needed, and the like).
9
+ //
10
+ // ES module, no Node built-ins: the browser loads this file as it is.
11
+
12
+ /**
13
+ * An engine refusal, or a host failure (`host.io`).
14
+ *
15
+ * - `code`: a stable machine code, one of the engine's `errorCodes` table
16
+ * (op engineTables; README "Errors").
17
+ * - `position`: `{line, column, offset, byteOffset}` where the input defines
18
+ * one, else `null`. `line` and `column` are 1-based (`column` counts
19
+ * Unicode scalar values); `offset` is a UTF-16 index into the JavaScript
20
+ * string, so `text.slice(err.position.offset)` starts at the error;
21
+ * `byteOffset` counts UTF-8 bytes.
22
+ * - `spec`: the URL of the specification section that defines the
23
+ * condition, or `null`.
24
+ * - `cause`: the underlying error, when there is one.
25
+ * - `format`, `formatInferred`: for a parse failure of text, the syntax it
26
+ * was read as and whether that syntax was inferred.
27
+ */
28
+ export class FactoidalError extends Error {
29
+ constructor(message, info) {
30
+ const i = info || {};
31
+ super(message, i.cause !== undefined ? { cause: i.cause } : undefined);
32
+ this.name = 'FactoidalError';
33
+ this.code = i.code || 'op.failed';
34
+ this.position = i.position || null;
35
+ this.spec = i.spec || null;
36
+ if (i.format !== undefined) this.format = i.format;
37
+ if (i.formatInferred !== undefined) this.formatInferred = i.formatInferred;
38
+ if (typeof Error.captureStackTrace === 'function') {
39
+ Error.captureStackTrace(this, FactoidalError);
40
+ }
41
+ }
42
+ }
43
+
44
+ // The engine counts `offset` in Unicode scalar values from the start of the
45
+ // input; a JavaScript string is indexed in UTF-16 code units, where a
46
+ // character at U+10000 or above takes two. This converts the count to the
47
+ // UTF-16 index of the same position (a unit conversion of the host string;
48
+ // a count past the end gives text.length).
49
+ export function utf16OffsetOf(text, scalarOffset) {
50
+ if (typeof text !== 'string' || !Number.isInteger(scalarOffset) || scalarOffset < 0) {
51
+ return scalarOffset;
52
+ }
53
+ let units = 0;
54
+ let scalars = 0;
55
+ for (const ch of text) {
56
+ if (scalars >= scalarOffset) break;
57
+ units += ch.length;
58
+ scalars += 1;
59
+ }
60
+ return units;
61
+ }
62
+
63
+ /**
64
+ * The FactoidalError of an error envelope ({"ok":false,"error",...}).
65
+ * `text` is the input string the envelope's position counts into, when the
66
+ * caller has it.
67
+ */
68
+ export function errorFromEnvelope(env, text) {
69
+ const p = env && env.position;
70
+ let position = null;
71
+ if (p && typeof p === 'object') {
72
+ position = {
73
+ line: p.line,
74
+ column: p.column,
75
+ offset: typeof text === 'string' ? utf16OffsetOf(text, p.offset) : p.offset,
76
+ byteOffset: p.byteOffset,
77
+ };
78
+ }
79
+ return new FactoidalError(String((env && env.error) || 'engine refusal'), {
80
+ code: env && env.code,
81
+ spec: env && env.spec,
82
+ position,
83
+ format: env && env.format,
84
+ });
85
+ }
86
+
87
+ /** Unwrap one op envelope: the envelope object, or throw its FactoidalError. */
88
+ export function envelopeResult(jsonText, text) {
89
+ const r = typeof jsonText === 'string' ? JSON.parse(jsonText) : jsonText;
90
+ if (!r.ok) throw errorFromEnvelope(r, text);
91
+ return r;
92
+ }
93
+
94
+ /** A host failure (file system, IndexedDB): code `host.io`, with its cause. */
95
+ export function hostError(message, cause) {
96
+ return new FactoidalError(message, { code: 'host.io', cause });
97
+ }
@@ -22,12 +22,13 @@
22
22
  // None of them makes a semantic decision: which IRIs reach the host, what
23
23
  // an absent answer means, which artifact a query reads, are decided in Lean.
24
24
 
25
- const XSD = 'http://www.w3.org/2001/XMLSchema#';
26
25
 
27
26
  // The two npm entries and the engine each loads (issue 713 stage 4). The
28
27
  // core engine's `ops` reflection lists what only the full engine serves
29
28
  // (`fullOnlyOps`, `fullOnlyFormats`); a call to one of those names the
30
29
  // entry that has it.
30
+ import { appTermToJson, termToJson } from '../rdfjs.mjs';
31
+ import { errorFromEnvelope } from './errors.mjs';
31
32
  export const FULL_ENTRY = '@factoidal/core';
32
33
  // The core profile is built for the hub pages only; the package ships the
33
34
  // full engine (owner, 2026-10-20).
@@ -53,33 +54,31 @@ function okEnvelope(extra) {
53
54
  }
54
55
 
55
56
  // An engine refusal arrives as an Error whose `.envelope` is the whole
56
- // {"ok":false,...} document (the loader keeps it, so a parse failure's
57
- // line, column, offset and format reach lib/api.mjs's ParseError). The
58
- // message stays the loader's own, "l4factoidal: <error>".
57
+ // {"ok":false,...} document: the engine's message, code, spec section and
58
+ // position pass to lib/errors.mjs unchanged. Any other throw (the wasm
59
+ // trapped, the loader failed) is code op.failed.
59
60
  function failEnvelope(err) {
60
61
  const env = err && err.envelope;
61
- if (env && typeof env === 'object' && env.ok === false) {
62
- return JSON.stringify({ ...env, error: String(err.message) });
63
- }
64
- return JSON.stringify({ ok: false, error: String((err && err.message) || err) });
62
+ if (env && typeof env === 'object' && env.ok === false) return JSON.stringify(env);
63
+ return JSON.stringify({ ok: false, code: 'op.failed', error: String((err && err.message) || err) });
65
64
  }
66
65
 
67
- // One SPARQL Results JSON term object per answer, the wire form
68
- // Wasm/ExtHost.lean reads; '' means "no value" (the section 17.6 error).
66
+ // One answer for Wasm/Ops/ExtFns.lean: the JavaScript value tagged with its
67
+ // JavaScript type ({js, v}; a number as its Number::toString text), or a term
68
+ // object. The engine chooses the datatype and the lexical form
69
+ // (L4Factoidal.XSD.JsValue.jsLiteral, issue 894 item 3). '' means "no
70
+ // value" (the section 17.6 error).
69
71
  function encodeExtAnswer(out) {
70
72
  if (out === null || out === undefined || out === EXT_PENDING_MARKER) return '';
71
- if (typeof out === 'boolean') {
72
- return JSON.stringify({ type: 'literal', value: out ? 'true' : 'false', datatype: XSD + 'boolean' });
73
- }
74
- if (typeof out === 'number') {
75
- if (!Number.isFinite(out)) return '';
76
- return Number.isInteger(out)
77
- ? JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'integer' })
78
- : JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'double' });
73
+ switch (typeof out) {
74
+ case 'boolean': return JSON.stringify({ js: 'boolean', v: out });
75
+ case 'number': return JSON.stringify({ js: 'number', v: Object.is(out, -0) ? '-0' : String(out) });
76
+ case 'bigint': return JSON.stringify({ js: 'bigint', v: out.toString() });
77
+ case 'string': return JSON.stringify({ js: 'string', v: out });
78
+ case 'object':
79
+ try { return JSON.stringify(appTermToJson(out)); } catch (_error) { return ''; }
80
+ default: return '';
79
81
  }
80
- if (typeof out === 'string') return JSON.stringify({ type: 'literal', value: out });
81
- if (typeof out === 'object' && typeof out.type === 'string') return JSON.stringify(out);
82
- return '';
83
82
  }
84
83
 
85
84
  // The Lean module asks its host through ONE global,
@@ -158,7 +157,7 @@ export function entryFromEngine(eng) {
158
157
  entry[op] = (...args) => {
159
158
  if (formatChecked && entry.fullOnlyFormats.includes(String(args[1] ?? '').toLowerCase())) {
160
159
  return JSON.stringify({
161
- ok: false, servedBy: 'full',
160
+ ok: false, servedBy: 'full', code: 'op.unknown',
162
161
  error: fullEngineMessage(`the format '${args[1]}'`),
163
162
  });
164
163
  }
@@ -200,5 +199,20 @@ export function entryFromEngine(eng) {
200
199
  }
201
200
  };
202
201
  }
202
+ // datasetQuadsBin answers a byte region (formal/lean4/L4Factoidal/RDF/QuadsBin.lean):
203
+ // this method answers {envelope, bytes} and throws on a refusal.
204
+ const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
205
+ if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
206
+ entry.datasetQuadsBin = (handle, optsJson) => {
207
+ try {
208
+ return eng.callBlobIO('datasetQuadsBin', [String(handle), String(optsJson ?? '')], new Uint8Array(0));
209
+ } catch (err) {
210
+ if (err && err.envelope) throw errorFromEnvelope(err.envelope);
211
+ throw err;
212
+ }
213
+ };
214
+ } else {
215
+ delete entry.datasetQuadsBin;
216
+ }
203
217
  return entry;
204
218
  }
package/lib/store.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  // `factoidal` command.
18
18
 
19
19
  import { requireChildName } from '../store-host/paths.mjs';
20
+ import { hostError } from './errors.mjs';
20
21
 
21
22
  /** The manifest file names a generation directory can carry, in the order
22
23
  * the native tools try them. */
@@ -44,10 +45,8 @@ export async function openGeneration(reader, generation) {
44
45
  try {
45
46
  pointer = await readBytes(reader, 'CURRENT');
46
47
  } catch (cause) {
47
- const err = new Error(
48
- `openStore: the collection root has no readable CURRENT pointer (${cause && cause.message})`);
49
- err.cause = cause;
50
- throw err;
48
+ throw hostError(
49
+ `openStore: the collection root has no readable CURRENT pointer (${cause && cause.message})`, cause);
51
50
  }
52
51
  // CURRENT holds a UTF-8 generation name (docs/shardborough-storage-spec.md
53
52
  // section 6.4); trailing ASCII whitespace is tolerated, as in
@@ -67,7 +66,7 @@ export async function openGeneration(reader, generation) {
67
66
  }
68
67
  return { generation: name, manifestName, manifest, activated: generation == null };
69
68
  }
70
- throw new Error(`openStore: generation '${name}' has none of ${MANIFEST_NAMES.join(', ')}`);
69
+ throw hostError(`openStore: generation '${name}' has none of ${MANIFEST_NAMES.join(', ')}`);
71
70
  }
72
71
 
73
72
  /** Read the named artifacts of one generation into one region, with a
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.10.0';
5
+ export const version = '0.12.0';