@factoidal/core 0.11.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/CHANGELOG.md +382 -0
- package/README.md +63 -3
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/bin/factoidal.mjs +139 -3
- package/browser.d.ts +3 -2
- package/browser.js +1 -2
- package/fn.js +8 -12
- package/index.d.ts +83 -28
- package/index.mjs +1 -2
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +93 -203
- package/lib/dataset.mjs +40 -45
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +15 -10
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +70 -3
- package/lws/persist.mjs +237 -6
- package/lws/server.mjs +271 -9
- package/package.json +2 -1
- package/rdfjs.mjs +4 -2
- package/solid/server/index.mjs +7 -1
package/lib/dataset.mjs
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
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
|
|
16
|
+
// Dataset throws the engine's FactoidalError, code handle.disposed.
|
|
17
17
|
|
|
18
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
|
|
|
@@ -28,30 +29,38 @@ const WRITER_TAGS = {};
|
|
|
28
29
|
|
|
29
30
|
const VERSIONS = { '1.1': '11', '1.2': '12' };
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
function
|
|
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) {
|
|
33
41
|
if (options == null) return {};
|
|
34
42
|
if (typeof options !== 'object' || Array.isArray(options)) {
|
|
35
43
|
throw new TypeError(`${who}: options must be an object, got ${Array.isArray(options) ? 'array' : typeof options}`);
|
|
36
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 = {};
|
|
37
48
|
for (const key of Object.keys(options)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
49
|
+
const v = options[key];
|
|
50
|
+
wire[key] = v === undefined || v === null ? null
|
|
51
|
+
: (typeof v === 'object' || typeof v === 'function') ? {} : String(v);
|
|
42
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)));
|
|
43
57
|
return options;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
function envelope(jsonText,
|
|
47
|
-
|
|
48
|
-
if (!r.ok) throw new Error(`${what}: ${r.error}`);
|
|
49
|
-
return r;
|
|
60
|
+
function envelope(jsonText, _what, text) {
|
|
61
|
+
return envelopeResult(jsonText, text);
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
const SERIALIZE_OPTIONS = ['format', 'version', 'prefixes', 'literalShorthand',
|
|
53
|
-
'useNativeTypes', 'useRdfType', 'rdfDirection'];
|
|
54
|
-
|
|
55
64
|
// The quads of the compact answer of datasetQuadsJson: each distinct term is
|
|
56
65
|
// built once and shared (TermJson.compactCertified).
|
|
57
66
|
function quadsFromCompact(terms, ixs) {
|
|
@@ -74,7 +83,7 @@ const DIRECTION = ['', 'ltr', 'rtl'];
|
|
|
74
83
|
function quadsFromRegion(bytes, out) {
|
|
75
84
|
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
76
85
|
if (bytes.length < 12 || v.getUint32(0, true) !== 0x31425146) {
|
|
77
|
-
throw new
|
|
86
|
+
throw new FactoidalError('iterate: the engine answered no datasetQuadsBin region', { code: 'op.failed' });
|
|
78
87
|
}
|
|
79
88
|
const nTerms = v.getUint32(4, true);
|
|
80
89
|
const nQuads = v.getUint32(8, true);
|
|
@@ -112,7 +121,7 @@ function quadsFromRegion(bytes, out) {
|
|
|
112
121
|
const pr = term();
|
|
113
122
|
return dataFactory.quad(s, pr, term());
|
|
114
123
|
}
|
|
115
|
-
default: throw new
|
|
124
|
+
default: throw new FactoidalError('iterate: a term tag outside the DLOG version 2 codec', { code: 'op.failed' });
|
|
116
125
|
}
|
|
117
126
|
};
|
|
118
127
|
const ts = new Array(nTerms);
|
|
@@ -172,9 +181,11 @@ class Dataset {
|
|
|
172
181
|
*/
|
|
173
182
|
get info() { return this._info; }
|
|
174
183
|
|
|
184
|
+
// A disposed Dataset: the engine answers handle.disposed for its handle.
|
|
175
185
|
_open(who) {
|
|
176
186
|
if (this._disposed) {
|
|
177
|
-
|
|
187
|
+
envelopeResult(this._ctx.e.datasetClose(this._handle));
|
|
188
|
+
throw new FactoidalError(`Dataset.${who}: the Dataset was disposed`, { code: 'handle.disposed' });
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
191
|
|
|
@@ -196,7 +207,7 @@ class Dataset {
|
|
|
196
207
|
async query(sparql, options) {
|
|
197
208
|
this._open('query');
|
|
198
209
|
return this._ctx.queryHandle(this, sparql,
|
|
199
|
-
checkOptions('Dataset.query', options
|
|
210
|
+
checkOptions(this._ctx.e, 'Dataset.query', options));
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
/**
|
|
@@ -207,11 +218,11 @@ class Dataset {
|
|
|
207
218
|
*/
|
|
208
219
|
async update(updateText, options) {
|
|
209
220
|
this._open('update');
|
|
210
|
-
checkOptions('Dataset.update', options
|
|
221
|
+
checkOptions(this._ctx.e, 'Dataset.update', options);
|
|
211
222
|
if (typeof updateText !== 'string') {
|
|
212
223
|
throw new TypeError('Dataset.update: updateText must be a string');
|
|
213
224
|
}
|
|
214
|
-
const r = envelope(this._ctx.e.datasetUpdate(this._handle, updateText), 'update');
|
|
225
|
+
const r = envelope(this._ctx.e.datasetUpdate(this._handle, updateText), 'update', updateText);
|
|
215
226
|
this._changed(r.count);
|
|
216
227
|
return this;
|
|
217
228
|
}
|
|
@@ -225,7 +236,7 @@ class Dataset {
|
|
|
225
236
|
async validateShacl(shapes, options) {
|
|
226
237
|
this._open('validateShacl');
|
|
227
238
|
return this._ctx.shacl(this, shapes,
|
|
228
|
-
checkOptions('Dataset.validateShacl', options
|
|
239
|
+
checkOptions(this._ctx.e, 'Dataset.validateShacl', options));
|
|
229
240
|
}
|
|
230
241
|
|
|
231
242
|
/**
|
|
@@ -237,7 +248,7 @@ class Dataset {
|
|
|
237
248
|
async validateShex(schema, options) {
|
|
238
249
|
this._open('validateShex');
|
|
239
250
|
return this._ctx.shex(this, schema,
|
|
240
|
-
checkOptions('Dataset.validateShex', options
|
|
251
|
+
checkOptions(this._ctx.e, 'Dataset.validateShex', options));
|
|
241
252
|
}
|
|
242
253
|
|
|
243
254
|
/**
|
|
@@ -267,8 +278,8 @@ class Dataset {
|
|
|
267
278
|
*/
|
|
268
279
|
async serialize(options) {
|
|
269
280
|
this._open('serialize');
|
|
270
|
-
|
|
271
|
-
return this._write(checkOptions('Dataset.serialize',
|
|
281
|
+
const opts = typeof options === 'string' ? { format: options } : options;
|
|
282
|
+
return this._write(checkOptions(this._ctx.e, 'Dataset.serialize', opts));
|
|
272
283
|
}
|
|
273
284
|
|
|
274
285
|
/**
|
|
@@ -278,34 +289,18 @@ class Dataset {
|
|
|
278
289
|
*/
|
|
279
290
|
toString(formatOrOptions) {
|
|
280
291
|
this._open('toString');
|
|
281
|
-
const opts = typeof formatOrOptions === 'string'
|
|
282
|
-
|
|
283
|
-
: checkOptions('Dataset.toString', formatOrOptions, SERIALIZE_OPTIONS);
|
|
284
|
-
return this._write(opts);
|
|
292
|
+
const opts = typeof formatOrOptions === 'string' ? { format: formatOrOptions } : formatOrOptions;
|
|
293
|
+
return this._write(checkOptions(this._ctx.e, 'Dataset.toString', opts));
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
_write(opts) {
|
|
288
297
|
const e = this._ctx.e;
|
|
289
298
|
const name = String(opts.format || 'nquads').toLowerCase();
|
|
290
299
|
const writerTags = this._ctx.tables().writerFormats;
|
|
300
|
+
// The engine checked the format, the version and the option
|
|
301
|
+
// combination (op apiCheck) before this.
|
|
291
302
|
const tag = writerTags[name];
|
|
292
|
-
|
|
293
|
-
throw new TypeError(
|
|
294
|
-
`Dataset.serialize: unknown format '${opts.format}'. Known formats: ` +
|
|
295
|
-
[...new Set(Object.values(writerTags))].join(', '));
|
|
296
|
-
}
|
|
297
|
-
let suffix = '';
|
|
298
|
-
if (opts.version !== undefined) {
|
|
299
|
-
suffix = VERSIONS[String(opts.version)];
|
|
300
|
-
if (!suffix) throw new TypeError("Dataset.serialize: version must be '1.1' or '1.2'");
|
|
301
|
-
}
|
|
302
|
-
if (tag !== 'turtle' && (opts.prefixes !== undefined || opts.literalShorthand !== undefined)) {
|
|
303
|
-
throw new TypeError("Dataset.serialize: 'prefixes' and 'literalShorthand' apply to format 'turtle' only");
|
|
304
|
-
}
|
|
305
|
-
const jsonldKeys = ['useNativeTypes', 'useRdfType', 'rdfDirection'];
|
|
306
|
-
if (tag !== 'jsonld' && jsonldKeys.some((k) => opts[k] !== undefined)) {
|
|
307
|
-
throw new TypeError(`Dataset.serialize: ${jsonldKeys.join(', ')} apply to format 'jsonld' only`);
|
|
308
|
-
}
|
|
303
|
+
const suffix = opts.version !== undefined ? VERSIONS[String(opts.version)] : '';
|
|
309
304
|
if (tag === 'jsonld') {
|
|
310
305
|
if (typeof e.datasetToJsonLd !== 'function') throw this._ctx.missing('serialize {format:jsonld}', 'datasetToJsonLd');
|
|
311
306
|
const r = envelope(e.datasetToJsonLd(this._handle, JSON.stringify({
|
package/lib/delta-log-hosts.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
84
|
-
req.onblocked = () => reject(
|
|
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(
|
|
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(
|
|
99
|
-
tx.onabort = () => reject(
|
|
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(
|
|
168
|
-
req.onblocked = () => reject(
|
|
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
|
+
}
|
package/lib/lean-entry.mjs
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// (`fullOnlyOps`, `fullOnlyFormats`); a call to one of those names the
|
|
29
29
|
// entry that has it.
|
|
30
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,15 +54,13 @@ 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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
-
|
|
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
66
|
// One answer for Wasm/Ops/ExtFns.lean: the JavaScript value tagged with its
|
|
@@ -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
|
}
|
|
@@ -204,8 +203,14 @@ export function entryFromEngine(eng) {
|
|
|
204
203
|
// this method answers {envelope, bytes} and throws on a refusal.
|
|
205
204
|
const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
|
|
206
205
|
if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
|
|
207
|
-
entry.datasetQuadsBin = (handle, optsJson) =>
|
|
208
|
-
|
|
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
|
+
};
|
|
209
214
|
} else {
|
|
210
215
|
delete entry.datasetQuadsBin;
|
|
211
216
|
}
|
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
|
-
|
|
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
|
|
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
package/lws/README.md
CHANGED
|
@@ -27,10 +27,12 @@ host or the engine failed rather than answered.
|
|
|
27
27
|
| op | arguments | answer |
|
|
28
28
|
| --- | --- | --- |
|
|
29
29
|
| `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
|
|
30
|
-
| `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
|
|
30
|
+
| `lwsStep` | `[handle, requestJson]` | `{ ok, response, changed, deliveries?, wake? }`, or `{ ok, resolve: { host } }` |
|
|
31
31
|
| `lwsClose` | `[handle]` | `{ ok }` |
|
|
32
32
|
| `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
|
|
33
33
|
| `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
|
|
34
|
+
| `lwsDelivered` | `[handle, reportJson]` | `{ ok, changed, retry? }` |
|
|
35
|
+
| `lwsFlush` | `[handle, { now? }]` | `{ ok, changed, deliveries?, wake? }` |
|
|
34
36
|
|
|
35
37
|
```
|
|
36
38
|
request { method, target, headers: [[name, value]] } + the content as bytes
|
|
@@ -46,8 +48,11 @@ unchanged and this host composes no IRI.
|
|
|
46
48
|
`configJson` members, all optional: `baseIri` (the absolute IRI the
|
|
47
49
|
storage root maps to), `now` (the engine clock in seconds, which advances
|
|
48
50
|
by one second per mutation so a write sequence has increasing
|
|
49
|
-
`Last-Modified` values with no real clock), `owner`, `agent`, `
|
|
50
|
-
|
|
51
|
+
`Last-Modified` values with no real clock), `owner`, `agent`, `pageSize`
|
|
52
|
+
(the container pagination threshold and page size, default 100, 0 for no
|
|
53
|
+
pagination), `rootAcl`, `auth` and `insecureNoAuth` (below),
|
|
54
|
+
`notifications` (default true) and `entropy` (random bytes, base64, which
|
|
55
|
+
this host makes for every open). A request
|
|
51
56
|
may carry `now` and `agent` of its own to override the handle's for one
|
|
52
57
|
step; `listen({realClock: true})` is how a server with a real clock feeds
|
|
53
58
|
one in, and a test leaves it off to keep the engine's deterministic
|
|
@@ -87,6 +92,68 @@ const running = await listen({
|
|
|
87
92
|
* `insecureNoAuth: true` serves every request with no authorization. It is
|
|
88
93
|
for development only, and must be asked for.
|
|
89
94
|
|
|
95
|
+
## Pagination and client links
|
|
96
|
+
|
|
97
|
+
Both are decided in Lean; this host passes the fields through.
|
|
98
|
+
|
|
99
|
+
* A container with more than `pageSize` members (default 100) is listed in
|
|
100
|
+
pages (LWS §LWS Media Type, Pagination;
|
|
101
|
+
https://github.com/danbri/factoidal/issues/887). Each page answers 200
|
|
102
|
+
with the `Link` relations `first` and `last`, `next` on every page but the
|
|
103
|
+
last, and `prev` on every page but the first; the page URIs are
|
|
104
|
+
`CONTAINER?page=N`. `totalItems` counts the full membership. The members
|
|
105
|
+
are in increasing path order, each once; the theorem `pageItems_concat` in
|
|
106
|
+
`L4Factoidal/LWS/Operations.lean` states that the pages of an unchanged
|
|
107
|
+
container concatenate to the full listing. Each page has its own `ETag`,
|
|
108
|
+
which starts with the container's clock; the clock changes when the
|
|
109
|
+
membership changes.
|
|
110
|
+
* A client sets its own links (`license`, `author`, any extension relation)
|
|
111
|
+
with `Link` and `Prefer: set-linkset` on PUT (replace) or PATCH (add),
|
|
112
|
+
with `Link` on POST, or through the linkset resource `PATH?linkset` with
|
|
113
|
+
JSON Patch or a PUT of `application/linkset+json` (LWS §Metadata, §Update
|
|
114
|
+
Resource; https://github.com/danbri/factoidal/issues/888). The relations
|
|
115
|
+
`linkset`, `type`, `format`, `size`, `modified`, `up`, `items` and
|
|
116
|
+
`lws#storage` are server-managed: a request that sets or changes one gets
|
|
117
|
+
409. A malformed link gets 400. Without `Prefer: set-linkset`, a `Link`
|
|
118
|
+
field on PUT and PATCH is not stored.
|
|
119
|
+
|
|
120
|
+
## Notifications
|
|
121
|
+
|
|
122
|
+
LWS notifications and the webhook notification suite
|
|
123
|
+
(https://github.com/danbri/factoidal/issues/890) are decided by
|
|
124
|
+
`formal/lean4/L4Factoidal/LWS/Notifications.lean`. They are on unless
|
|
125
|
+
`notifications: false` is passed.
|
|
126
|
+
|
|
127
|
+
* The storage description at `/.well-known/lws-storage` names the
|
|
128
|
+
`NotificationService`: endpoint `/.well-known/lws-subscriptions/`, type
|
|
129
|
+
`WebhookSubscription`, and the Ed25519 key that signs deliveries.
|
|
130
|
+
* A client POSTs `{"type":"WebhookSubscription","topic":[IRI…],"inbox":URL}`
|
|
131
|
+
(`application/lws+json`, with a token when authorization is on) and gets
|
|
132
|
+
201 with the subscription URL. GET on the endpoint lists the agent's
|
|
133
|
+
subscriptions; GET and DELETE manage one.
|
|
134
|
+
* After a write, `lwsStep` names the deliveries: `{ id, url, headers, body }`.
|
|
135
|
+
This host POSTs each one exactly as given, after the response, and reports
|
|
136
|
+
the status to `lwsDelivered`; when the engine answers `retry`, it waits
|
|
137
|
+
`after` seconds and sends the delivery it names. `storage().settled()`
|
|
138
|
+
resolves when no delivery is in flight.
|
|
139
|
+
* Rate limit (https://github.com/danbri/factoidal/issues/915): at most
|
|
140
|
+
`deliveryLimit` deliveries (default 10) per subscription in a window of
|
|
141
|
+
`deliveryWindow` seconds (default 60; `deliveryLimit: 0` turns it off).
|
|
142
|
+
The rest wait; an answer then carries `wake: SECONDS`, and this host calls
|
|
143
|
+
`lwsFlush` after that many seconds and sends the one batched delivery it
|
|
144
|
+
names.
|
|
145
|
+
* Inbox check (issue 915): when an inbox host is a name, `lwsStep` answers
|
|
146
|
+
`resolve: { host }`; this host resolves it (`dns.lookup`, every address)
|
|
147
|
+
and steps the request again with `resolved: [{ host, addresses }]`.
|
|
148
|
+
`listen()` passes `listen: { address, port, addresses }` to `lwsOpen`. The
|
|
149
|
+
engine refuses a container of this storage under another name of this
|
|
150
|
+
server.
|
|
151
|
+
* `controllerInbox: URL` (https://github.com/danbri/factoidal/issues/920):
|
|
152
|
+
each new access request is announced there with a `Create` notification.
|
|
153
|
+
* After a move, a request to a previous path answers 301 with `Location`
|
|
154
|
+
(https://github.com/danbri/factoidal/issues/914); `redirectsMax` (default
|
|
155
|
+
256) bounds the table.
|
|
156
|
+
|
|
90
157
|
## Use
|
|
91
158
|
|
|
92
159
|
```js
|