@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/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 a TypeError that names the method.
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
- /** Throw a TypeError when `options` has a key outside `allowed`. */
32
- 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) {
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
- if (!allowed.includes(key)) {
39
- throw new TypeError(
40
- `${who}: unknown option '${key}'. Known options: ${allowed.join(', ') || '(none)'}`);
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, what) {
47
- const r = JSON.parse(jsonText);
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 Error('iterate: the engine answered no datasetQuadsBin region');
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 Error('iterate: a term tag outside the DLOG version 2 codec');
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
- 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' });
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, ['sparql12', 'version', 'entail', 'output']));
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, ['format', 'baseIRI']));
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, ['focus', 'shape']));
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
- if (typeof options === 'string') return this._write({ format: options });
271
- 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));
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
- ? { format: formatOrOptions }
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
- if (!tag) {
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({
@@ -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
+ }
@@ -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 (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
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
- eng.callBlobIO('datasetQuadsBin', [String(handle), String(optsJson ?? '')], new Uint8Array(0));
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
- 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.11.0';
5
+ export const version = '0.12.0';
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`, `rootAcl`,
50
- `auth` and `insecureNoAuth` (below). A request
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