@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/CHANGELOG.md CHANGED
@@ -1,6 +1,501 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.0 (unreleased)
3
+ ## 0.12.0 — 2026-10-22
4
+
5
+ One error model for the whole package
6
+ (https://github.com/danbri/factoidal/issues/882 item 4,
7
+ https://github.com/danbri/factoidal/issues/901 KR 3.1). Every op error
8
+ envelope of the engine carries a stable `code`, the input `position` where
9
+ the input defines one, and the `spec` section where one applies; the engine
10
+ decides all three (`formal/lean4/Wasm/Ops/ErrorModel.lean`). The package
11
+ copies them into one class, `FactoidalError`. The README "Errors" table is
12
+ generated from the engine's list.
13
+
14
+ ### Breaking changes
15
+
16
+ - `DatasetHandle`, the name of `Dataset` before 0.10.0, is removed. Use
17
+ `Dataset`.
18
+
19
+ - `ParseError` is removed. Every function rejects with `FactoidalError`
20
+ (`code`, `position`, `spec`, `cause`); a parse error has
21
+ `code: 'parse.syntax'` (or `parse.rdf12-required`, `term.invalid-iri`,
22
+ `term.invalid-langtag`) and `err.position.{line, column, offset}` in place
23
+ of `err.line`, `err.column`, `err.offset`. `position.byteOffset` is new.
24
+ - Error messages are the engine's text, with no `<function>: l4factoidal:`
25
+ prefix. A parse with an inferred format no longer appends "(format inferred
26
+ as ...)" to the message; `err.formatInferred` and `err.format` say it.
27
+ - An unknown option key, an unknown format name, an `entail`, `output`,
28
+ `version`, `owlClosure` mode, `rmlMap` source kind or `Dataset.closure`
29
+ regime outside the engine's tables, and options that cannot be combined
30
+ were a `TypeError`; each is now a `FactoidalError` (`option.unknown`,
31
+ `format.unknown`, `option.invalid-value`, `regime.refused`,
32
+ `option.conflict`) decided by the new engine op `apiCheck`. A `TypeError`
33
+ stays only for an argument of the wrong JavaScript type.
34
+ - A method of a disposed `Dataset` was a `TypeError`; it is now the engine's
35
+ `FactoidalError` with code `handle.disposed`. A closed `Store` is the same.
36
+ - `queryRaw` rejected with an `Error` whose message began "SPARQL query
37
+ failed:" and which had `stderr`; it now rejects with the engine's
38
+ `FactoidalError` (`query.syntax` with a position, or `query.failed`).
39
+ - File-system and IndexedDB failures (`openStore`, the delta log) are
40
+ `FactoidalError` with code `host.io` and the original error as `cause`.
41
+ - `@factoidal/core/fn` `entail()` with an `x-ikl-*` regime rejects with code
42
+ `regime.refused` and the message names `query`, the function that refuses.
43
+
44
+ Linked Web Storage kept in files, stage 1 (https://github.com/danbri/factoidal/issues/918;
45
+ design record `docs/designissues/2026-10-22-lws-storage-on-disk.md`), decided in
46
+ Lean (`L4Factoidal/LWS/Journal.lean`, `FileLayout.lean`):
47
+
48
+ - `factoidal lws-serve DIR --storage files` (config member `storage: "files"`,
49
+ `listen({ storage: 'files' })`; the default stays `snapshot`): DIR holds one
50
+ file per resource body under `bodies/`, a journal `lws-journal` with one
51
+ record per change, and a metadata file `lws-files.json` that the journal is
52
+ folded into when it passes max(1 MiB, the size of the metadata file). A
53
+ write no longer rewrites the whole storage: a PUT into a storage of 1,000,
54
+ 2,000 and 4,000 resources took 2.70, 2.40 and 2.89 ms, against 42.6, 83.9 and
55
+ 170.4 ms with the snapshot (`tests/lws/scaling.mjs`, rows "storage").
56
+ - A journal record is valid only when it is complete and its SHA-256 matches;
57
+ a torn last record is ignored at start (theorem `decodeFrames_torn`).
58
+ Replaying the records of a sequence of steps gives the metadata of the store
59
+ after them (`replay_records`, `persist_replay`).
60
+ - With `FACTOIDAL_LWS_KEY` every body file and journal record is sealed with
61
+ ChaCha20-Poly1305 (HACL*), the file's own name in the associated data, and
62
+ `lws-files.json` as the snapshot is.
63
+ - An existing `lws-state.json` is migrated at the first start in files mode and
64
+ renamed to `lws-state.json.migrated`.
65
+ - New ops `lwsLoad` and `lwsPersist` (byte-region entry only): the engine names
66
+ the files to read and gives every file operation and byte; the host
67
+ (`lws/persist.mjs`) performs them. `tools/lws-touchstone.sh --storage files`.
68
+ - Stage 1 still holds the whole storage in memory; stage 2 (bodies read on GET,
69
+ the metadata in an on-disk index) is an expected failure in
70
+ `tests/lws/scaling.mjs` naming issue 918.
71
+
72
+ Linked Web Storage Search and Type Index Services (`lws10-index`, w3c/lws-protocol
73
+ `9a08ff65`; https://github.com/danbri/factoidal/issues/912), decided in Lean
74
+ (`L4Factoidal/LWS/TypeIndex.lean`, `Search.lean`):
75
+
76
+ - The storage description advertises a `TypeIndexService`
77
+ (`GET /.well-known/lws-type-index`, a paged `TypeIndex`) and a
78
+ `TypeSearchService` (`QUERY /.well-known/lws-type-search` with an
79
+ `application/lws-query+json` filter in conjunctive normal form over `type`
80
+ and indexed relations such as `describedby`; a paged ContainerPage).
81
+ Option `typeIndex: false` of `listen`/`openLwsStorage` turns both off.
82
+ - Types come from the native class, from `Link: <T>; rel="type"` on POST and
83
+ on `Prefer: set-linkset` PUT and PATCH, and from `rdf:type` in Turtle
84
+ content up to 1 MiB. Such a `type` link was refused with 409 before; it is
85
+ now a declared type and appears in the linkset document. A `type` link
86
+ that names an LWS or LDP class is still refused with 409. A PUT or PATCH
87
+ that applies `Prefer: set-linkset` answers `Preference-Applied: set-linkset`.
88
+ - The index is kept by the store on every write and rebuilt in one pass when
89
+ a snapshot is opened. Answers, `totalItems` and the type index hold only
90
+ what the requesting agent may read now. Theorems `ixSearch_eq_specSearch`
91
+ and `searchAnswer_authorized`.
92
+
93
+ Linked Web Storage Access Requests and Grants (LWS core §Access Requests
94
+ and Grants, w3c/lws-protocol `9a08ff65`;
95
+ https://github.com/danbri/factoidal/issues/913), decided in Lean
96
+ (`L4Factoidal/LWS/AccessPolicy.lean`, `AccessGrants.lean`):
97
+
98
+ - The storage description advertises an `AccessRequestService`
99
+ (`/.well-known/lws-access-requests/`) and an `AccessGrantService`
100
+ (`/.well-known/lws-access-grants/`), each with `conformsTo`
101
+ `https://www.w3.org/ns/lws#AccessProfile`. Both endpoints are LWS
102
+ containers: GET lists (paged), POST of an `application/lws+json` document
103
+ answers 201 with `Location`, GET and DELETE act on one member. Option
104
+ `accessGrants: false` of `listen`/`openLwsStorage` turns both off.
105
+ - Any authenticated agent creates an access request; only the storage
106
+ controller (`acl:Control` on the storage root) creates or revokes a grant.
107
+ A document without `type`, `storage` (this storage), `access`, a policy
108
+ `type` with `AccessPolicy`, `action` (`read`, `modify`, `create`,
109
+ `delete`), `assignee`, or with a bad `inbox`, `target` or constraint,
110
+ answers 400.
111
+ - A grant is part of the access policy beside Web Access Control: it permits
112
+ exactly its actions on exactly its targets (a container grant does not
113
+ reach the members) to its assignee (`foaf:Agent` for the public), when
114
+ every constraint holds: `dateTime` (`eq`, `gt`, `gteq`, `lt`, `lteq`),
115
+ `format` and `type` of the resource, `client` (the token's `client_id`)
116
+ with `eq` or `isAnyOf`; `purpose` is accepted and no request satisfies it.
117
+ A move needs `delete` of the resource and `create` in the destination.
118
+ Notification subscriptions and deliveries, and the type index answers,
119
+ see the same access. Theorems `grantsPermit_sound`,
120
+ `policyPermits_excluded`, `recordPermits_expired`, `grantsPermit_revoke`,
121
+ `grantAllows_acl`.
122
+ - A new grant is announced (one `Create` notification) to its `inbox` and
123
+ to the inbox of each access request naming one of its assignees. Grants
124
+ and requests are kept in the storage snapshot (member `access`).
125
+ - The storage controller hears of a new access request
126
+ (https://github.com/danbri/factoidal/issues/920). Core §Access Requests and
127
+ Grants: "When a new access request is submitted, the storage controller
128
+ SHOULD be notified." The draft names no inbox for the controller, so this
129
+ server's is configuration: `listen({ controllerInbox: URL })` or
130
+ `factoidal lws-serve --controller-inbox URL`. When it is set, each new
131
+ access request gives one `Create` notification there (signed, retried, as
132
+ every delivery); when it is not set, nothing is sent. An inbox that is not
133
+ `http` or `https` stops the start.
134
+
135
+ Linked Web Storage moves and notifications, three more rules
136
+ (w3c/lws-protocol `9a08ff65`), decided in Lean:
137
+
138
+ - 301 at the previous identifiers of a moved resource
139
+ (https://github.com/danbri/factoidal/issues/914;
140
+ `L4Factoidal/LWS/MoveRedirect.lean`). §Moving Resources: "Such a server
141
+ SHOULD respond to subsequent requests on the previous identifiers of the
142
+ resource and of its auxiliary resources with 301 Moved Permanently,
143
+ including a `Location` header field pointing at the new identifier." After
144
+ a move, a request to the old path of the resource, of each descendant of a
145
+ moved container, of its `.acl` and `.meta` and of its `?linkset` answers
146
+ 301 with the new IRI, when the same request at the new path would be
147
+ authorized (§Privacy Considerations, "Redirects": "Servers SHOULD apply the
148
+ same access control to such responses as to the moved resource itself");
149
+ otherwise the old path answers as before. A chain of moves answers the
150
+ current path. The table holds at most `redirectsMax` pairs (default 256,
151
+ `lws-serve --redirects-max N`), is kept in the snapshot (member `moved`),
152
+ and loses a pair when a new resource is created at its old path or the
153
+ moved resource is deleted. Theorems `redirect_current`, `after_ok`,
154
+ `redirect_after_move`.
155
+ - A webhook inbox that names this server under another host name, an IP
156
+ address or `localhost`, at its port, is refused with 400
157
+ (https://github.com/danbri/factoidal/issues/915;
158
+ `L4Factoidal/LWS/InboxGuard.lean`). The engine compares the inbox
159
+ authority with the base IRI, the bound address and the interface
160
+ addresses; for a host name it answers `lwsStep` with `resolve: {host}`, the
161
+ host resolves the name and steps the request again with `resolved`. The
162
+ host passes `listen: {address, port, addresses}` to `lwsOpen`.
163
+ - A delivery rate limit per subscription (issue 915; the webhook suite:
164
+ "Rate limiting and endpoint verification are suggested to prevent abuse of
165
+ the subscription mechanism"): at most `deliveryLimit` deliveries (default
166
+ 10) in a window of `deliveryWindow` seconds (default 60;
167
+ `lws-serve --delivery-limit N --delivery-window S`; `deliveryLimit: 0`
168
+ turns it off). The activities over the limit wait and go, when the window
169
+ ends, in one envelope with an `activity` array (core §Batching
170
+ Notifications). An answer may carry `wake: SECONDS`; the host then calls
171
+ the new op `lwsFlush`. Theorem `admits_le`: at most `deliveryLimit`
172
+ deliveries per subscription in one window. Endpoint verification: the
173
+ drafts define no procedure (no request, no challenge, no expected answer),
174
+ so there is no behaviour to implement.
175
+
176
+ Linked Web Storage, three rules of the editor's draft (w3c/lws-protocol
177
+ `9a08ff65`, 2026-10-19), all decided in Lean:
178
+
179
+ - Container pagination (§LWS Media Type, Pagination;
180
+ https://github.com/danbri/factoidal/issues/887). A container with more
181
+ than `pageSize` members (option of `listen`/`openLwsStorage`, default 100,
182
+ 0 turns it off) answers in pages: `Link` relations `first`, `last`, `next`
183
+ (not on the last page) and `prev` (not on the first page), page URIs
184
+ `CONTAINER?page=N`, `totalItems` over the full membership, one `ETag` per
185
+ page. Members are listed in increasing path order, each once (theorem
186
+ `pageItems_concat`).
187
+ - Client-managed links (§Metadata, §Update Resource;
188
+ https://github.com/danbri/factoidal/issues/888): `Link` with
189
+ `Prefer: set-linkset` on PUT (replace) and PATCH (add), `Link` on POST, and
190
+ JSON Patch or PUT of `application/linkset+json` on `PATH?linkset`.
191
+ Server-managed relations (`linkset`, `type`, `format`, `size`, `modified`,
192
+ `up`, `items`, `lws#storage`) answer 409; a malformed link answers 400.
193
+ Client links are kept in the storage snapshot.
194
+ - Notifications (§Notifications and the webhook notification suite
195
+ `lws10-notifications-webhook`;
196
+ https://github.com/danbri/factoidal/issues/890). The storage description
197
+ advertises a `NotificationService` (`subscriptionType`
198
+ `["WebhookSubscription"]`, endpoint `/.well-known/lws-subscriptions/`) and
199
+ an Ed25519 `JsonWebKey` verification method. An authenticated POST of a
200
+ `WebhookSubscription` (`topic`, `inbox`, optional `expires`) answers 201;
201
+ each topic must be readable by the agent (403 otherwise). Every successful
202
+ write gives Activity Streams 2.0 `Create` (with `target`), `Update` or
203
+ `Delete` (with `origin`) activities, sent only to subscribers who may read
204
+ the resource at that time, as an `application/lws+json` POST to the inbox,
205
+ signed with HTTP Message Signatures (RFC 9421) and `Content-Digest`
206
+ (RFC 9530). The endpoint lists the agent's subscriptions; GET and DELETE
207
+ manage one. A delivery answered 5xx, 408, 425, 429 or not at all is tried
208
+ up to three times; 410 or three failed deliveries deactivate the
209
+ subscription. Subscriptions and the signing key are kept in the storage
210
+ snapshot. The host makes the requests the engine names and reports each
211
+ status to the new op `lwsDelivered`; `listen`/`openLwsStorage` take
212
+ `notifications: false` to turn it off.
213
+ - Moving resources (§Moving Resources;
214
+ https://github.com/danbri/factoidal/issues/914). The storage description
215
+ describes a `MoveResource` capability (`appliesTo` DataResource and
216
+ Container, `preservesIdentifier` false). A JSON Patch (or PUT) of a
217
+ resource's linkset that changes its `up` link moves it to that container,
218
+ with its descendants when it is a container, in one step: the answer is
219
+ 200 with the new linkset and `Content-Location`, both containers get a new
220
+ ETag, and a subscriber hears one `Move` activity (`origin`, `target`). A
221
+ destination that is missing or not a container, a cycle, the storage
222
+ root, a name already taken, another storage or a removed `up` link is
223
+ 409. The agent needs the delete of every moved resource and the create in
224
+ the destination. `listen`/`openLwsStorage` take `moves: false` to turn it
225
+ off. Theorem `move_tree`: a move keeps the containment hierarchy a tree.
226
+ - Three server options, each off by default and decided in Lean
227
+ configuration, so that every Touchstone test that one of our
228
+ configurations can satisfy runs (`tools/lws-touchstone.sh --config all`):
229
+ `lws-serve --moves all|data|off` (`moves: "data"` describes `appliesTo`
230
+ `[DataResource]` and answers a change of a container's `up` link with 409;
231
+ `"off"` describes no `MoveResource`), `--no-linkset-put` (`linksetPut:
232
+ false`: a linkset's `Allow` leaves out PUT and a PUT answers 405 with
233
+ `Allow`, as lws10-core §Metadata requires when PUT is not advertised) and
234
+ `--paginate-always` (`paginateAlways: true`: a container listing that fits
235
+ on one page is still one paginated page, with `rel="first"` and
236
+ `rel="last"` and no `rel="next"` or `rel="prev"`). An unknown `moves`
237
+ value stops the start.
238
+ - Fixed: on a paginated container, `If-Match` with the `ETag` of its first
239
+ page (which a GET of the container URI answers) failed with 412; the
240
+ first page's tags are now current tags of the container.
241
+
242
+ Encryption at rest of the LWS and Solid storage snapshot, off by default
243
+ (https://github.com/danbri/factoidal/issues/921; owner, 2026-10-21: "Yes env
244
+ var please. Again off by default but good to show we can."):
245
+
246
+ - When the environment variable `FACTOIDAL_LWS_KEY` holds a 32-byte key (64
247
+ hex digits or base64url), `lws-serve` and `solid-serve` write
248
+ `DIR/lws-state.json` as format `lws-snapshot-sealed-1`: format id, cipher
249
+ id `chacha20-poly1305`, key id (first 8 bytes of SHA-256 of the key), a
250
+ 12-byte nonce new for every write, and the ChaCha20-Poly1305 (RFC 8439,
251
+ HACL*) ciphertext of the snapshot, with the header as associated data.
252
+ `listen`/`openLwsStorage`/`openSolidStorage` take `storageKey` (text, or
253
+ `null` to read no key).
254
+ - At start the file is authenticated before use. A wrong key, a changed or
255
+ truncated file, a clear-text file with the variable set and a sealed file
256
+ without it each stop the start with a one-line message (exit 1). With the
257
+ variable unset, behaviour is unchanged.
258
+ - It protects the file on disk only: not the memory of a running server, not
259
+ the key in its environment, not the answers to clients.
260
+ - Decided in Lean (`L4Factoidal/LWS/SnapshotSeal.lean`), with theorems over
261
+ any AEAD with RFC 8439 correctness (`unsealDoc_sealDoc`,
262
+ `unsealDoc_authenticated`, `unsealDoc_wrongKey`, ...). `lwsSnapshot` and
263
+ `solidSnapshot` take an optional second argument `{"nonce":…}`.
264
+ - Cost with a key (2026-10-21, about 1,050 resources): a write takes about
265
+ 2.7 times as long (133 to 143 ms against 49 to 54 ms), the file is 1.33
266
+ times larger, a restart about 2.1 times as long.
267
+
268
+ ### Breaking changes
269
+
270
+ - The storage description has a second service, `NotificationService`, and
271
+ `verificationMethod` and `authentication` members; its `ETag` is a digest
272
+ of the document.
273
+ - A container listing is in increasing path order. Before, it was in the
274
+ order of the store (newest write first).
275
+ - A container with more than 100 members returns only its first 100 members
276
+ unless `pageSize` is set; follow `rel="next"`.
277
+ - PUT on a linkset resource replaces the client links. Before, it answered
278
+ 405.
279
+
280
+ ### Unstable
281
+
282
+ - ANTICIPATORY AND UNSTABLE, off by default: a type search results resource
283
+ (https://github.com/danbri/factoidal/issues/922). With
284
+ `listen({ searchResults: true })` or `factoidal lws-serve
285
+ --unstable-search-results`, a QUERY answer of the Type Search Service by an
286
+ authenticated agent carries `Content-Location` naming
287
+ `/.well-known/lws-type-search/results/<id>` (RFC 10008 section 2.3).
288
+ lws10-index defines no such resource; its path, lifetime, snapshot member
289
+ and behaviour are likely to change. A GET by the same agent and client lists
290
+ the QUERY's results that a new QUERY would still answer (paged, filtered by
291
+ current read access); anyone else gets 404; after `searchResultsTtl`
292
+ (600 s) 410. The storage description marks it with the extension member
293
+ `https://github.com/danbri/factoidal/issues/922#unstableResultsResource`.
294
+ `lwsStep` request documents may carry `random` (16 bytes, base64); the LWS
295
+ host sends it with every request. Decided in Lean
296
+ (`L4Factoidal/LWS/SearchResults.lean`).
297
+ - Off by default: Merkle hashes of the LWS storage
298
+ (https://github.com/danbri/factoidal/issues/919; owner, 2026-10-21: "As an
299
+ optional, probably default off until we settle on stronger motivating
300
+ usecase but good to have the fundamentals there"). With
301
+ `listen({ merkle: true })` or `factoidal lws-serve --merkle`: a GET or HEAD
302
+ of a resource carries `Repr-Digest: sha-256=:<base64>:` (RFC 9530, SHA-256
303
+ of the stored bytes, HACL*); a container listing carries
304
+ `Factoidal-Merkle-Root` and the member
305
+ `https://github.com/danbri/factoidal/issues/919#merkleRoot` (a Merkle digest
306
+ over its members and their digests); the storage description carries
307
+ `https://github.com/danbri/factoidal/issues/919#storageMerkleRoot`. Under
308
+ authorization the roots go to the storage controller only. Entity tags do
309
+ not change. The hashes are kept by the store on every write, delete and
310
+ move (only the containers above the change are recomputed) and rebuilt when
311
+ a storage is opened; no hash is in the snapshot. Decided in Lean
312
+ (`L4Factoidal/LWS/Merkle.lean`, `MerkleHttp.lean`), with theorems
313
+ `MkState.dig_eq_recompute` (the kept digests equal a recomputation from the
314
+ store) and `change_reaches_ancestor` (a change reaches every container
315
+ above it, with collision-freedom of SHA-256 as a stated hypothesis).
316
+ Cost (2026-10-21, wasm in Node): a write into a container of 2,000 members
317
+ takes about 6.3 ms with the option on against 0.9 ms off (linear in the
318
+ members of each container above the write); a reopen of 2,000 resources
319
+ with the hashes rebuilt takes 246 ms.
320
+
321
+ ### Performance
322
+
323
+ - LWS and Solid PUT and GET of a large body
324
+ (https://github.com/danbri/factoidal/issues/903): one 100,000-triple
325
+ N-Triples body (7.7 MB) took about 17 s to PUT and the GET stopped the
326
+ engine with out of memory; now the PUT takes 85 ms and the GET 40 ms
327
+ (`npm run test:costs`). The cause was the FNV-1a digest of the entity tag,
328
+ computed in `Nat`: on wasm32 each byte made heap numbers. It now runs in
329
+ `UInt32` (theorem `fnv1a32_eq_spec`: same value as the `Nat` definition).
330
+ Peak memory of the 100,000-triple PUT and GET fell from 2,640 MiB to
331
+ 288 MiB. Scaling check: `node tests/lws/scaling.mjs`.
332
+
333
+ ### Fixed
334
+
335
+ Seven defects of `lws-serve` that the Touchstone LWS conformance harness found
336
+ (https://github.com/danbri/factoidal/issues/911; record:
337
+ `docs/designissues/2026-10-21-lws-touchstone-run.md`). All are decided in
338
+ Lean (`formal/lean4/L4Factoidal/LWS/`):
339
+
340
+ - A POST or PUT with no content and no `Content-Type` is accepted
341
+ (`Operations.writeMediaType`). Before, it answered 400, so the draft's own
342
+ container-creation example (`POST`, `Content-Length: 0`, the
343
+ `lws#Container` type link) failed. An empty data resource made this way
344
+ has type `application/octet-stream`. Content without `Content-Type` still
345
+ answers 400.
346
+ - The Turtle form of a container uses `http://schema.org/size`, as the
347
+ `application/lws+json` context and the core draft's examples do. Before,
348
+ it used `https://schema.org/size`, so the two forms were not RDF
349
+ isomorphic. The WG's `lws10-vocab/vocabulary.yml` declares
350
+ `https://schema.org/`; the conflict is recorded in `Binding.containerTurtle`.
351
+ - A container's `ETag` carries the FNV-1a digest of its listing in the
352
+ negotiated media type (`Binding.containerTag`). Before, it was the
353
+ container's clock only, and a host with a real clock sets the clock to
354
+ the current second, so two changes in one second gave one strong tag.
355
+ `Last-Modified` is unchanged.
356
+ - The `lws#storage` link of a 401 and the default token audience are the
357
+ storage description IRI `<base>.well-known/lws-storage`, the IRI every
358
+ other response links and the description's `id` (`Authorization.storageIri`).
359
+ Before, both were the storage root `<base>`.
360
+ - `If-Unmodified-Since` is evaluated for every method (412 when the resource
361
+ changed after the date), and `If-Modified-Since` and `If-Unmodified-Since`
362
+ are compared as instants in all three HTTP-date formats of RFC 9110
363
+ §5.6.7 (`HttpDate.parseHttpDate`; theorem `unixOfCivil_civilOfUnix`).
364
+ Before, `If-Unmodified-Since` was ignored and `If-Modified-Since` matched
365
+ only the exact `Last-Modified` text.
366
+ - A POST names its new resource with the first free name of the hint (or
367
+ `res<clock>`), `-1`, `-2`, ... (`Operations.childPathFor`, theorem
368
+ `childPathFor_fresh`). Before, a clash got the clock as its one suffix,
369
+ and under a real clock the third POST to one container in one second
370
+ REPLACED the second resource.
371
+ - The Turtle form of a container gives a contained container no
372
+ `dcterms:modified`: the `application/lws+json` context defines
373
+ `modified` for a DataResource only, so the JSON item's `modified` of a
374
+ container is no triple, and the two forms must be RDF isomorphic.
375
+ - `factoidal lws-serve --page-size N` passes `N` to the engine's
376
+ `pageSize`.
377
+ - Result: Touchstone (221 tests) runs with no failure, open 70 pass, 0
378
+ fail, 151 inapplicable; with authorization 146 pass, 0 fail, 75
379
+ inapplicable.
380
+
381
+ Breaking: an LWS access token must name the storage description IRI in
382
+ `aud` (or the `--audience` given). A token for the storage root IRI is now
383
+ refused with 401 `invalid_token`.
384
+
385
+ ## 0.11.0 — 2026-10-21
386
+
387
+ No RDF, SPARQL, datatype or IRI decision is made in JavaScript any more
388
+ (https://github.com/danbri/factoidal/issues/894; owner, 2026-10-21: "The ja
389
+ leak fix is p0"). The engine parses, writes, escapes, compares and chooses
390
+ datatypes; the JavaScript copies term components and calls ops.
391
+ `tools/host-purity-lint.sh` group 3 scans the npm and hub JavaScript for RDF
392
+ syntax and runs in `npm test`.
393
+
394
+ ### Breaking changes
395
+
396
+ These shapes could not be kept:
397
+
398
+ - `shexValidate` and `Dataset.validateShex`: a focus or shape given as a
399
+ string is read as one RDF 1.2 N-Triples term (`'<http://…>'` or `'_:b'`);
400
+ a bare IRI string is refused. Every API that takes a term as a string
401
+ now reads it through one engine reader (`TermJson.readTermString`), so
402
+ `_:x` is always a blank node and `<…>` always an IRI.
403
+ - `graphs()` returns `[NamedNode|BlankNode, Dataset]` pairs, not
404
+ `[string, Dataset]`, so an IRI graph and a blank-node graph with the same
405
+ text are never confused.
406
+ - A literal with a language and a direction has datatype
407
+ `rdf:dirLangString` (RDF 1.2 Concepts; RDF/JS Data Model); before, the
408
+ RDF/JS default gave `rdf:langString`.
409
+
410
+ - `factoidal lws-serve` and `@factoidal/core/lws` enforce storage-server
411
+ authorization from the LWS editor's draft (401 with
412
+ `WWW-Authenticate: Bearer resource_metadata`, the RFC 9728 metadata
413
+ document, Bearer JWT access tokens checked for signature, `iss`, `aud`,
414
+ `exp`, `nbf` and `iat`, then Web Access Control). `lws-serve` needs
415
+ `--issuer IRI`, `--auth-config FILE` or, for development only,
416
+ `--insecure-no-auth`; `listen()` with neither `auth` nor
417
+ `insecureNoAuth` serves only what the ACLs give to the public. New op
418
+ `lwsFetched`. https://github.com/danbri/factoidal/issues/886
419
+
420
+ - The N-Quads text functions of `@factoidal/core/rdfjs` (`nquadsToQuads`,
421
+ `nquadsLineToQuad`, `quadsToNQuads`, `quadToNQuads`, `termToNQuads`,
422
+ `DatasetCore.fromNQuads`, `DatasetCore.prototype.toNQuads`) call the engine.
423
+ They throw until the engine is loaded: `await ready()` (new) or any other
424
+ `@factoidal/core` call first. `nquadsToQuads` now rejects a line the
425
+ N-Quads grammar rejects (before, `<http://e/a b>` was read as an IRI), and
426
+ the writers escape as the engine's canonical N-Triples form does (before,
427
+ `\b`, `\f` and other control characters were written raw).
428
+ - A JavaScript number answered by a registered extension function becomes
429
+ `xsd:integer` only for an integer within ±(2^53 − 1); every other number is
430
+ an `xsd:double` in the XSD 1.1 canonical form (`1e21` is `"1.0E21"`, before
431
+ `"1e+21"^^xsd:integer`; `0.1` is `"1.0E-1"`, before `"0.1"`). `NaN`,
432
+ `Infinity` and `-Infinity` answer `"NaN"`, `"INF"`, `"-INF"`; before they
433
+ were the §17.6 error. A `bigint` answers `xsd:integer`
434
+ (`L4Factoidal.XSD.JsValue.jsLiteral`).
435
+ - `queryHdt` answers a Dataset for CONSTRUCT and DESCRIBE; before it threw a
436
+ TypeError chosen by a regular expression over the query text.
437
+ - `Literal` has a `direction` member (`''`, `'ltr'`, `'rtl'`), and a
438
+ directional language-tagged string from the engine keeps its direction and
439
+ its `rdf:dirLangString` datatype; `equals` compares it.
440
+
441
+ ### New
442
+
443
+ - `isomorphic(a, b)`: dataset isomorphism by the engine
444
+ (`Dataset.isomorphicOutcome`, blank nodes in triple terms included).
445
+ `@factoidal/core/fn`'s `equals` uses it.
446
+ - `fromQuads(quads)`: a Dataset from RDF/JS quads, sent as term components.
447
+ - `ready()`: load the engine. `engineTables()`: the engine's format,
448
+ regime, query-output and pack-syntax name tables.
449
+ - Ops (formal/lean4/Wasm/Ops/Handles.lean): `datasetQuadsJson`,
450
+ `datasetAddQuadsJson`, `datasetDeleteQuadsJson`, `datasetFromQuadsJson`,
451
+ `datasetGraphNames`, `datasetGraphAsDefault`, `datasetIsomorphic`,
452
+ `parseQuadsJson`, `quadsToNQuads`, `termsToNTriples`, `resolveIri`,
453
+ `queryForm`, `engineTables`; `datasetMatch`/`datasetMatchCount` take term
454
+ objects. Theorems: `TermJson.decodeTerm_encodeTerm`,
455
+ `TermJson.decodeQuad_encodeQuad`, `TermJson.mem_quadList_default`,
456
+ `TermJson.mem_quadList_named`, `TermJson.toCanonicalNQuads_eq_quadList`,
457
+ `TermJson.quadPage_append`, `XSD.JsValue.doubleLexical_inLexicalSpace`,
458
+ `XSD.JsValue.integerLexical_inLexicalSpace`,
459
+ `XSD.JsValue.jsLiteral_number_double`, `XSD.JsValue.jsLiteral_number_integer`.
460
+
461
+ ### Added
462
+
463
+ - An extension function may answer an exact term: an RDF/JS term
464
+ (Literal, NamedNode, BlankNode, or a Quad as an RDF 1.2 triple term), or
465
+ `{value, datatype}`, or `{value, language, direction?}`. The lexical form
466
+ and the datatype IRI are kept as given, ill-typed literals included (RDF
467
+ 1.1 Concepts §3.3). The engine checks only what RDF requires: absolute
468
+ IRIs, a well-formed BCP 47 language tag, direction `ltr` or `rtl`; a term
469
+ that fails is the §17.6 error for that call. A plain JS number still gets
470
+ its datatype from `jsLiteral`. Theorem `TermJson.extAnswerJson_encodeTerm`:
471
+ an app-supplied term is the answer, unchanged.
472
+
473
+ ### Performance
474
+
475
+ - Dataset iteration reads the quads from the engine as one byte region (op
476
+ `datasetQuadsBin`: a term table in the delta-log v2 term codec and four
477
+ u32 indices per quad), decoded in JavaScript with `DataView` and
478
+ `TextDecoder`. 60,000 quads: 178–205 ms under load, against 214–280 ms in
479
+ 0.10.0 and about the time of the N-Quads writer op in the same run.
480
+ Theorems `decodeBin_quadsBin` and `quadsBin_binSpec`
481
+ (`formal/lean4/L4Factoidal/RDF/QuadsBinTheorems.lean`) prove the region
482
+ reads back as exactly the dataset's quads, so no run-time check is made.
483
+ https://github.com/danbri/factoidal/issues/900
484
+
485
+ ### Fixed
486
+
487
+ - `graphs()` (both APIs) no longer merges an IRI graph and a blank-node graph
488
+ whose strings are equal.
489
+ - `Dataset` iteration builds its quads from the engine's term objects; it no
490
+ longer parses N-Quads text in JavaScript.
491
+ - The Solid client resolves a relative target against `baseIri` by RFC 3986
492
+ §5.2 (op `resolveIri`); before it concatenated strings.
493
+ - `factoidal query` table cells and `factoidal inspect` graph names are the
494
+ engine's canonical N-Triples forms (escaped); `factoidal pack` reads its
495
+ suffix table from the engine and detects the syntax of a file with an
496
+ unknown suffix from its content.
497
+
498
+ ## 0.10.0 — 2026-10-21
4
499
 
5
500
  ### Breaking changes
6
501
 
package/README.md CHANGED
@@ -43,6 +43,12 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
43
43
  - Servers: SPARQL 1.1 Protocol and Graph Store (Fuseki-compatible paths,
44
44
  Jena TDB2 read only), Linked Web Storage 1.0, Solid
45
45
  - A persisted store with a delta log for updates (early stage)
46
+ - Terms: a JavaScript extension function can answer with an exact term
47
+ (`{value: "007", datatype: XSD + "integer"}`, an RDF/JS term, a triple
48
+ term); the engine keeps the lexical form and the datatype IRI as given.
49
+ A term given as a string is one N-Triples term everywhere (`<http://…>`
50
+ is an IRI, `_:b` a blank node). See API.md, "Custom extension functions" and
51
+ "Terms given as strings".
46
52
  - RDF/JS DataFactory and DatasetCore (`/rdfjs`), a functional API (`/fn`)
47
53
 
48
54
  Formats (generated from `formats()`):
@@ -64,6 +70,62 @@ Formats (generated from `formats()`):
64
70
  <!-- formats-table:end -->
65
71
 
66
72
  Missing writers (HDT, N3, RDFa): https://github.com/danbri/factoidal/issues/883.
73
+
74
+ ## Errors
75
+
76
+ Every function rejects with one class, `FactoidalError`, except that an
77
+ argument of the wrong JavaScript type is a `TypeError`. The engine decides
78
+ three fields: `code` (below), `position` (`{line, column, offset,
79
+ byteOffset}` where the input defines one: `offset` is a UTF-16 index into
80
+ your string, `byteOffset` counts UTF-8 bytes) and `spec` (the specification
81
+ section of the condition). `cause` holds the underlying error of `host.io`.
82
+
83
+ ```js
84
+ import { parse, FactoidalError } from '@factoidal/core';
85
+ try { await parse('<http://e/a> <http://e/p> .', 'turtle'); }
86
+ catch (e) { if (e instanceof FactoidalError) console.log(e.code, e.position.line, e.spec); }
87
+ ```
88
+
89
+ Codes (generated from `engineTables().errorCodes`):
90
+
91
+ <!-- errors-table:start (generated by test/errors.test.mjs) -->
92
+ | code | specification | meaning |
93
+ |---|---|---|
94
+ | `parse.syntax` | <https://www.w3.org/TR/rdf12-turtle/#sec-grammar> | The input text does not match the grammar of its RDF syntax. |
95
+ | `parse.rdf12-required` | <https://www.w3.org/TR/rdf12-concepts/#section-triple-terms> | The text uses an RDF 1.2 form (triple term, reified triple, base direction) and was read as RDF 1.1. |
96
+ | `term.invalid-iri` | <https://www.w3.org/TR/rdf12-concepts/#section-IRIs> | A term that must be an IRI is not one. |
97
+ | `term.invalid-langtag` | <https://www.w3.org/TR/rdf12-concepts/#section-Graph-Literal> | A language tag is not well-formed (BCP 47). |
98
+ | `term.invalid` | <https://www.w3.org/TR/sparql11-results-json/#select-encode-terms> | A term or quad given as JSON is not an RDF term or quad. |
99
+ | `query.syntax` | <https://www.w3.org/TR/sparql11-query/#grammar> | The SPARQL query does not match the SPARQL grammar. |
100
+ | `query.failed` | <https://www.w3.org/TR/sparql11-query/#sparqlDefinition> | The SPARQL query parsed but its evaluation was refused. |
101
+ | `update.syntax` | <https://www.w3.org/TR/sparql11-update/#grammar> | The SPARQL update does not match the SPARQL Update grammar. |
102
+ | `update.failed` | <https://www.w3.org/TR/sparql11-update/#updateLanguage> | The SPARQL update parsed but was refused. |
103
+ | `format.unknown` | - | The format name is not one the engine reads or writes. |
104
+ | `serialize.unsupported-rdf12` | <https://www.w3.org/TR/rdf12-concepts/#section-triple-terms> | The data holds an RDF 1.2 term and the requested RDF 1.1 output has no form for it. |
105
+ | `serialize.failed` | - | The writer refused the data. |
106
+ | `canon.unsupported-rdf12` | <https://www.w3.org/TR/rdf-canon/#canon-algorithm> | RDFC-1.0 defines no canonical labelling for RDF 1.2 triple terms or directional strings. |
107
+ | `canon.failed` | <https://www.w3.org/TR/rdf-canon/#canon-algorithm> | Canonicalization was refused. |
108
+ | `owl.unsupported-rdf12` | <https://www.w3.org/TR/owl2-mapping-to-rdf/#Mapping_from_RDF_Graphs_to_the_Structural_Specification> | OWL 2 Direct Semantics has no rule for RDF 1.2 triple terms or directional strings. |
109
+ | `reason.failed` | - | A closure or entailment check was refused. |
110
+ | `regime.refused` | <https://www.w3.org/TR/sparql11-entailment/> | The entailment regime is not implemented. |
111
+ | `validate.failed` | - | A validation (SHACL, ShEx, JSON Schema, Schematron) could not run on its inputs. |
112
+ | `xml.failed` | <https://www.w3.org/TR/xml/#sec-well-formed> | An XML, XPath, XSLT, MathML or XForms input was refused. |
113
+ | `crypto.failed` | - | A cryptographic or Verifiable Credentials operation refused its inputs. |
114
+ | `store.failed` | - | A persisted-store, pack or HDT operation was refused. |
115
+ | `registry.failed` | <https://www.w3.org/TR/sparql11-query/#extensionFunctions> | An extension-function or SERVICE registration was refused. |
116
+ | `handle.unknown` | - | The handle was never issued by this engine. |
117
+ | `handle.disposed` | - | The handle was issued and then closed. |
118
+ | `option.unknown` | - | The options object has a key the function does not take. |
119
+ | `option.invalid-value` | - | An option or mode argument has a value outside its table. |
120
+ | `option.conflict` | - | Two options were given that cannot be combined. |
121
+ | `capacity.exceeded` | - | The input is above a fixed size limit of the engine. |
122
+ | `call.arity` | - | The op was called with the wrong number of arguments. |
123
+ | `call.arguments` | - | The arguments document is not a JSON array of strings. |
124
+ | `op.unknown` | - | The loaded engine has no op of this name. |
125
+ | `op.failed` | - | Any other refusal of an op. |
126
+ | `host.io` | - | The host could not read or write a file, a store or a log (no engine decision). |
127
+ <!-- errors-table:end -->
128
+
67
129
  Full API: [API.md](https://github.com/danbri/factoidal-core/blob/main/npm/factoidal/API.md).
68
130
  Changes: [CHANGELOG.md](CHANGELOG.md).
69
131
 
@@ -75,7 +137,7 @@ $ npx factoidal serve --tdb2 DIR /ds # serve a Jena TDB2 database
75
137
  $ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1
76
138
  $ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
77
139
  $ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
78
- $ npx factoidal lws-serve DIR # Linked Web Storage
140
+ $ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
79
141
  $ npx factoidal solid-serve DIR # Solid Protocol
80
142
  ```
81
143
 
@@ -91,10 +153,11 @@ Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
91
153
  | SPARQL 1.1 Query, Update, Protocol | 631 / 0 / 631 |
92
154
  | SPARQL 1.2 | 254 / 0 / 254 |
93
155
  | SPARQL Protocol, Graph Store, Service Description against `serve` | 50 / 0 / 50 [P] |
156
+ | Linked Web Storage 1.0, Touchstone (another implementer's harness), `lws-serve` | 215 / 0 / 221 (6 not applicable) [L] |
94
157
  | RDF 1.1 N-Triples, N-Quads, Turtle, TriG, RDF/XML | 992 / 0 / 992 |
95
158
  | RDF 1.2 syntax suites | all pass |
96
159
  | RDF 1.1 Semantics | 39 / 0 / 39 |
97
- | RDF 1.2 Semantics | 40 / 7 / 47 |
160
+ | RDF 1.2 Semantics | 29 / 0 / 29 [S] |
98
161
  | JSON-LD 1.1 toRdf, expand, flatten | 910 / 0 / 910 |
99
162
  | JSON-LD 1.1 compact, fromRdf | 298 / 0 / 300 (2 not run) |
100
163
  | JSON-LD 1.1 Framing | 29 / 63 / 92 |
@@ -105,7 +168,7 @@ Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
105
168
  | SHACL 1.0 Core, SHACL-SPARQL | 120 / 0 / 120 |
106
169
  | SHACL 1.2 Core | 103 / 30 / 133 |
107
170
  | ShEx 2 validation, ShExC | 1624 / 0 / 1624 |
108
- | CSVW | 821 / 1 / 822 |
171
+ | CSVW | 821 / 0 / 821 (1 defective W3C test excluded) [C] |
109
172
  | RIF Core | 43 / 0 / 46 (3 undecided) |
110
173
  | RML Core | 60 / 0 / 76 (16 not attempted) |
111
174
  | XML 1.0 (xmlconf) | 1861 / 0 (516 not run) |
@@ -118,10 +181,13 @@ Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
118
181
  [B] https://github.com/danbri/factoidal-core/blob/03e2e6deee8146ce7e6fdc3f8f34b8245af5ab54/docs/designissues/2026-10-09-lean-conformance-baseline.md
119
182
  [P] https://github.com/danbri/factoidal-core/blob/9a4045f1572a032b53ccb867f8395fe0c8d97bb9/docs/designissues/2026-10-10-store-serve.md
120
183
  [X] https://github.com/danbri/factoidal-core/blob/cb871c2de8354362ae6226eb57fb6c549624ea1a/docs/designissues/2026-10-10-xml-profile-widened.md
184
+ [L] 2026-10-22, https://github.com/danbri/factoidal-core/blob/2b9d2e39af53be31ed92463b58d3e132e65c4fe4/docs/designissues/2026-10-21-lws-touchstone-run.md (with server options, 220 of 221 pass in at least one configuration)
185
+ [S] 2026-10-21, the current W3C manifest (29 active tests), https://github.com/danbri/factoidal-core/blob/2b9d2e39af53be31ed92463b58d3e132e65c4fe4/docs/claude-rules/current-state.md
186
+ [C] 2026-10-21, validation test308, https://github.com/danbri/factoidal/issues/700
121
187
 
122
188
  ## Limits
123
189
 
124
- - Size: 2.5 MB packed, 12.4 MB unpacked; one 11.7 MB wasm module.
190
+ - Size: 2.9 MB packed, 14.0 MB unpacked; one 13.2 MB wasm module.
125
191
  - `factoidal update` and `compact` exit 3; updates work through `serve`
126
192
  (https://github.com/danbri/factoidal/issues/641).
127
193
  - Fuseki: read-only administration API, one dataset per process, no
package/api.d.ts CHANGED
@@ -42,11 +42,10 @@ export function buildApi(driver: {
42
42
  export function entryFromEngine(engine: object): Record<string, unknown>;
43
43
 
44
44
  export const Dataset: typeof import('./index').Dataset;
45
- export const DatasetHandle: typeof import('./index').DatasetHandle;
46
45
  export const Store: typeof import('./index').Store;
47
46
  export const dataFactory: import('./index').DataFactory;
48
- /** Position-carrying parse failure; see index.d.ts's ParseError. */
49
- export const ParseError: typeof import('./index').ParseError;
47
+ /** The one error class; see index.d.ts's FactoidalError. */
48
+ export const FactoidalError: typeof import('./index').FactoidalError;
50
49
  /** Package version string, e.g. '0.9.0'. */
51
50
  export const version: string;
52
51
 
package/api.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // openStore() here takes a reader function only: there is no file system.
20
20
 
21
- import { buildApi, ParseError, DatasetHandle, Store } from './lib/api.mjs';
21
+ import { buildApi, FactoidalError, Store } from './lib/api.mjs';
22
22
  import { entryFromEngine } from './lib/lean-entry.mjs';
23
23
  import { dataFactory } from './rdfjs.mjs';
24
24
  import { Dataset } from './lib/dataset.mjs';
@@ -52,5 +52,5 @@ export function createApi(engine, options) {
52
52
  });
53
53
  }
54
54
 
55
- export { buildApi, entryFromEngine, ParseError, DatasetHandle, Store, Dataset, dataFactory, version };
55
+ export { buildApi, entryFromEngine, FactoidalError, Store, Dataset, dataFactory, version };
56
56
  export default createApi;