@factoidal/core 0.15.0 → 0.17.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,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.17.0 (2026-10-27)
6
+
7
+ - **Breaking:** `canonicalHash(graph)` now returns the SHA-256 (64 lowercase hex digits) of the RDFC-1.0 canonical N-Quads, as the RDF Dataset Canonicalization specification uses it. Before, it returned the canonical text. Use `canonicalize()` for the text. The engine computes the digest in Lean (new op `datasetCanonicalHash`) (https://github.com/danbri/factoidal/issues/1040, https://github.com/danbri/factoidal-core/pull/127).
8
+ - SPARQL string functions follow SPARQL 1.1 §17.4.3 argument types: STRLEN, SUBSTR, UCASE, LCASE, STRSTARTS, STRENDS, CONTAINS, ENCODE_FOR_URI, MD5 and SHA* give a type error on a number, boolean or IRI argument (MD5/SHA* also on a language-tagged literal). STRSTARTS/STRENDS/CONTAINS check argument compatibility. SUBSTR follows `fn:substring`: `SUBSTR("12345", 0, 3)` is now `"12"` (was `"123"`) (https://github.com/danbri/factoidal/issues/1036, https://github.com/danbri/factoidal-core/pull/126).
9
+ - `factoidal pack --layout sbm11` writes an SBM11 generation: the same files as `l4sbm11-pack`, byte for byte (605 of 605 W3C census inputs). This layout also reads RDF/XML and JSON-LD. `factoidal activate`, `inspect` and `query` read SBM11 stores. `activate` checks the length, SHA-256 and Merkle root of every file before it moves `CURRENT`. `serve` does not read SBM11 stores yet. IBK5 stays the default layout. New wasm ops `storeLayout` and `sbm11Verify` (https://github.com/danbri/factoidal/issues/1009, https://github.com/danbri/factoidal-core/pull/131).
10
+ - Store open in Node is faster: on the londat store (142,314 quads, 1,046 blocks) `openStoreHandle` takes 562 ms, not 1485 ms (median of 7 runs, Apple M1, Node 22). The engine decodes the hexadecimal manifest over bytes, reads `JSON.stringify` arguments in place, verifies artifacts with HACL* SHA-256 in `storeOpen`, and decodes the arguments once per call. What remains is the manifest parse, which grows with the number of blocks; an SBM11 store opens in about 5 ms (https://github.com/danbri/factoidal/issues/1039, https://github.com/danbri/factoidal-core/pull/135).
11
+ - The chunked Turtle reader (the default `l4sbm11-pack` path and the source layer's split read) reads a subject such as `prefix:Atto`, `base:x` or `version:v` as a prefixed name. Before, it took the name for a `PREFIX`/`BASE`/`VERSION` directive and refused the document (QUDT-all-in-one-OWL.ttl) (https://github.com/danbri/factoidal/issues/1028, https://github.com/danbri/factoidal-core/pull/128).
12
+ - RML: a reference to a SPARQL result binding (`formats:SPARQL_Results_CSV`) keeps the literal's datatype when the term map states none. `"5"^^xsd:integer` gives `"5"^^xsd:integer`, not `"5"` (https://github.com/danbri/factoidal/issues/1034, https://github.com/danbri/factoidal-core/pull/129).
13
+ - Tests: 996 Apache Jena ARQ expression tests run as SPARQL on the package (`test/jena-arq-expr.test.mjs`). Each case that differs from Jena has a verdict: a Factoidal defect with an open issue (https://github.com/danbri/factoidal/issues/1041, https://github.com/danbri/factoidal/issues/1042, https://github.com/danbri/factoidal/issues/573), or a Jena extension or difference from the SPARQL specification (https://github.com/danbri/factoidal-core/pull/132). Graph names that are blank nodes: `test/graph-names-876.test.mjs` (https://github.com/danbri/factoidal/issues/876 part 2, fixed in 0.16.0 by https://github.com/danbri/factoidal-core/pull/75; https://github.com/danbri/factoidal-core/pull/130).
14
+ - Tests and release process: no npm test skips when the wasm lacks an op; each test file asserts the ops it needs, so a wasm without an op fails. The `serve --tdb2` test, which needs Apache Jena, moved to `npm run test:jena`. Core PR CI runs `tests/dataflow/run.sh` and `npm test`. `tools/npm-release-check.mjs` checks the version in package.json, package-lock.json and lib/version.mjs, and a dated changelog heading; the publish workflow runs it. `tools/fetch-builds.sh` warns when the installed release was built from a commit whose source differs from HEAD; `--strict` fails (https://github.com/danbri/factoidal/issues/1038, https://github.com/danbri/factoidal-core/pull/124, https://github.com/danbri/factoidal-core/pull/125).
15
+ - LWS: `factoidal lws-serve DIR --authz ASDIR` serves the storage and an authorization server on one port, the issuer the storage root without its trailing slash. The token endpoint and the JWK Set are under the storage root's `.well-known/` (`lws-token`, `lws-jwks`), so no storage resource name is taken from clients; the RFC 8414 metadata is at `/.well-known/lws-configuration` + the issuer's path. The engine decides which requests are the authorization server's (new op `authzOwns`, Lean `L4Factoidal/Authz/Mount.lean`, theorem `endpointOf?_mem`); the storage trusts the issuer with the JWK Set the engine publishes. JS: `listen({ authz: { dir, ... } })` from `@factoidal/core/lws/server`. `authzOpen` takes `mountedOn`, `tokenPath` and `jwksPath`, and answers `resources` and `jwks`.
16
+ - LWS and the authorization server read both forms of their well-known paths: the whole path (a client that reaches the server directly) and the path with the storage root's or issuer's path removed (a reverse proxy that serves the server at a path prefix and removes the prefix, as the router of Touchstone's `targetservers` does). Up to 0.16.0 the protected resource metadata named the wrong resource behind such a proxy (`L4Factoidal.LWS.resourceOfMetadataPath`). Test: `tests/authz/mounted.mjs`. Docker target for third-party testing: `deploy/lws-target/`.
17
+
18
+ ## 0.16.0 (2026-10-26)
19
+
20
+ - Node settings in the evidence (https://github.com/danbri/factoidal/issues/975). The params of `node()` are the node's settings. With a wasm module that has the new op `pipelineCheckNodes` (Lean `Wasm/Ops/PipelineNodes.lean`, `Dataflow/NodeSettings.lean`), each evidence record carries `settings` (canonical: keys sorted, defaults of absent settings that narrow the contract such as `lenient: false` and `entail: 'none'`, a Dataset value as `{nquadsSha256}`) and `settingsDigest` (`sha256:` and the hex SHA-256 of the canonical text). A setting outside the proved domain of a proved stage (a lenient reader, an evaluator with `entail` other than `none` or with `output`, a key the stage's settings table does not name) makes the node's status `unproved`, with `registryStatus` and `outside` (each setting and the reason) in the record, so `run()` reports `proved: false`. A reader's `format` must name the stage's syntax (`ttl12` selects RDF 1.2 mode, reported as `derived.mode`); an evaluator's query must parse, else `node()` rejects. Design record: `docs/designissues/2026-10-25-pipeline-settings-and-provenance.md`.
21
+ - The output wire of a `shippedEvaluator` node follows its query form: SELECT gives `Table`, ASK `Answer`, CONSTRUCT and DESCRIBE `Dataset` (owner, 2026-10-25: "select give table? Sure"). So reader, CONSTRUCT, CONSTRUCT is well typed, and reader, SELECT, CONSTRUCT is not. Proved: every answer that meets the evaluator contract has the wire of its query form (`Dataflow.evalContract_wire`) (https://github.com/danbri/factoidal/issues/1026). The Lean wire enumeration has a new wire `table`.
22
+ - CSVW is a registered, bound stage: `node('CSVW (tabular data to RDF)', { metadata, mode, base, url })` runs `csvwToRdf` on CSV text and gives a Dataset and an evidence record with status `tested` (the W3C csv2rdf suite; no Lean contract, so `checked: false`) (https://github.com/danbri/factoidal/issues/975). `stages()` marks a stage `bound` only when the engine also reports it runnable.
23
+ - With the wasm of 0.15.0 or older (for example a mismatched local install), records have no settings, the evaluator's wire is the registry's `Answer`, and the CSVW stage stays `missing` and unbound.
24
+
25
+ - `packSbm11(text, { syntax, base })` packs an RDF document into the files of one SBM11 generation in memory and resolves to a `Map` from file name to bytes (data files, `.merkle` sidecars, `manifest.sbm11`; no `CURRENT`); `mapReader(files)` is a reader over such files for `openSbm11(source, { reader })`. The wasm op is `sbm11PackFiles` (Lean `Storage/Sbm11/PackBytes.lean`), one call per file. The files are byte for byte those of the native `l4sbm11-pack` for the same quads: 23 of 23 files for `test/fixtures/sbm11-tiny`, and all files of 605 of 605 W3C SPARQL 1.0 and 1.1 inputs (`SBM11_PACKFILES=1 tests/sbm11/census.sh`) (https://github.com/danbri/factoidal/issues/1009 stage 2). On the wasm of 0.15.0 or older, `packSbm11` rejects with `code: 'SBM11_NO_PACK_OP'`. The whole document and every file are in memory at once. Not yet: `factoidal pack` on SBM11, and a pack that streams its input.
26
+ - SPARQL: the inverse path `^p` keeps a pair whose object is a literal. SPARQL 1.1 §9.3 and §18.4 make `?x ^p ?y` the same as `?y p ?x`, so `?x` can bind a literal; up to 0.15.0 both evaluators (the in-memory one and the SBM11 id path) dropped those pairs, and `SELECT ?x ?y WHERE { ?x ^<http://e/p> ?y }` over `<http://e/s> <http://e/p> "v" .` gave no row. Apache Jena ARQ 5.6.0 gives the same rows as the fix (https://github.com/danbri/factoidal/issues/1023). Tests: `test/inverse-path-literal-1023.test.mjs` and the `#guard`s in `SPARQL/QueryTests.lean`. The F* tree has the same fix.
27
+ - SPARQL: a `FROM` or `FROM NAMED` IRI that names no graph of the dataset gives a warning of class `dataset-iri-not-found` in the `warnings` member of the query envelope (`class`, `clause` (`FROM` or `FROM NAMED`), `iri`, `message`), and on standard error in the native `l4factoidal query` command. The rows do not change: the clause adds no graph, as before (https://github.com/danbri/factoidal/issues/1032). Lean: `SPARQL.datasetWarnings` beside `applyDataset`, whose merge theorem is unchanged; `#guard`s in `SPARQL/QueryTests.lean` and `Wasm/Ops/Query.lean`. Tests: `test/from-iri-not-found.test.mjs` (plain assertions; https://github.com/danbri/factoidal/issues/1038 records why an expected failure that waits for a rebuild blocked the build of this release).
28
+ - Tests, no engine change: GeoSPARQL `geo:wktLiteral` values made with `STRDT(CONCAT("POINT(", STR(?lon), " ", STR(?lat), ")"), geo:wktLiteral)` in SELECT, in a sub-SELECT that feeds `geof:sfWithin`, and by an INSERT whose stored literal `geof:sfWithin` then tests; from a CSVW column whose datatype object is `{"@id": "http://www.opengis.net/ont/geosparql#wktLiteral", "base": "string"}`; and from an RML object map with `rml:datatype` or a `rml:datatypeMap` with `rml:constant`. `test/wkt-generation.test.mjs` (6 pass on the 0.15.0 wasm) and `#guard`s in `Wasm/Ops/Query.lean` and `Wasm/Ops/Formats.lean`.
29
+
30
+ - Tests, no engine change: `test/sparql-string-arg-types.test.mjs` holds 6 expected failures for SPARQL 1.1 string functions that accept arguments the specification makes type errors, and for SUBSTR rounding (https://github.com/danbri/factoidal/issues/1036), found by porting Apache Jena ARQ expression tests (`docs/designissues/2026-10-25-jena-style-facade-feasibility.md`).
31
+
3
32
  ## 0.15.0
4
33
 
5
34
  - `openSbm11(source)` and `registerSbm11Source(source, reader)` are back, with the wasm ops `sbm11Open`, `sbm11Query`, `sbm11Close`: SPARQL over an SBM11 generation (the Shardborough store) in Node and in the browser, reading each file at an offset through a host reader (https://github.com/danbri/factoidal/issues/1009, https://github.com/danbri/factoidal-core/pull/93). Fix: in 0.14.0 `sbm11Open` trapped (wasm `unreachable`) because the C host-read functions had a signature that differs from the generated one; the wasm build now fails on any such mismatch. Not yet: the Merkle check of each read, a generation with a delta log (refused at open), packing SBM11 from npm.
package/README.md CHANGED
@@ -8,14 +8,15 @@ npm install @factoidal/core
8
8
  ```
9
9
 
10
10
  ```js
11
- import { parse, csvwToRdf } from "@factoidal/core";
11
+ import { parse, csvwToRdf, canonicalHash } from "@factoidal/core";
12
12
 
13
13
  const ds = await parse('<http://x/a> <http://x/n> "A" .', { format: "ntriples" });
14
14
  const rows = await ds.query("SELECT ?n WHERE { ?s <http://x/n> ?n }");
15
15
  console.log(rows[0].get("n").value); // A
16
16
  console.log(ds.toString("turtle"));
17
17
  console.log(await ds.serialize({ format: "rdfxml" }));
18
- console.log(await ds.canonicalize()); // RDFC-1.0
18
+ console.log(await ds.canonicalize()); // RDFC-1.0 canonical N-Quads
19
+ console.log(await canonicalHash(ds)); // its SHA-256, 64 hex digits
19
20
 
20
21
  const table = await csvwToRdf("id,name\n1,a\n", "", { mode: "minimal", url: "http://x/t.csv" });
21
22
  console.log(table.size); // 2
package/authz/server.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // authzFetched [handle, { url, status, body, now } | { host, addresses, now }] -> { ok }
23
23
  // authzKeys [handle, { nonce?, private? }] -> { ok, keys } | { ok, jwks }
24
24
  // authzRotate [handle, { now, random }] -> { ok, changed, kid }
25
+ // authzOwns [handle, target] -> { ok, owns }
25
26
  // authzClose [handle] -> { ok }
26
27
  //
27
28
  // FETCHES. `resolve` names a host: this host resolves it (`dns.lookup`, every
@@ -38,7 +39,7 @@ import { loadEngine } from '../bin/engine.mjs'
38
39
  import { storageKeyText } from '../lws/persist.mjs'
39
40
 
40
41
  /** The ops this host needs from the engine. */
41
- export const AUTHZ_OPS = ['authzOpen', 'authzStep', 'authzFetched', 'authzKeys', 'authzRotate', 'authzClose']
42
+ export const AUTHZ_OPS = ['authzOpen', 'authzStep', 'authzFetched', 'authzKeys', 'authzRotate', 'authzOwns', 'authzClose']
42
43
 
43
44
  /** The key file in DIR. */
44
45
  export const KEY_FILE = 'authz-keys.json'
@@ -174,12 +175,16 @@ export async function openAuthorizationServer (options = {}) {
174
175
  const engine = options.engine !== undefined ? options.engine : await loadEngine()
175
176
  const dir = typeof options.dir === 'string' ? options.dir : null
176
177
  const config = {
177
- issuer: options.issuer,
178
178
  resources: Array.isArray(options.resources) ? options.resources : [],
179
179
  now: nowSeconds(),
180
180
  entropy: randomBase64(64)
181
181
  }
182
- for (const name of ['alg', 'tokenLifetime', 'leeway', 'rotateAfter', 'docTtl']) {
182
+ // `mountedOn` (an LWS storage root that shares this issuer) or `issuer`:
183
+ // passed unread; the engine derives the issuer, the resource and the
184
+ // endpoint paths from `mountedOn` (L4Factoidal/Authz/Mount.lean).
185
+ if (typeof options.mountedOn === 'string') config.mountedOn = options.mountedOn
186
+ else config.issuer = options.issuer
187
+ for (const name of ['alg', 'tokenLifetime', 'leeway', 'rotateAfter', 'docTtl', 'tokenPath', 'jwksPath', 'audience']) {
183
188
  if (options[name] !== undefined) config[name] = options[name]
184
189
  }
185
190
  if (options.allowHttp === true) config.allowHttp = true
@@ -208,6 +213,15 @@ export async function openAuthorizationServer (options = {}) {
208
213
  engine,
209
214
  handle,
210
215
  issuer: opened.issuer,
216
+ /** The resources tokens may be issued for, as the engine set them. */
217
+ resources: opened.resources,
218
+ /** The published JWK Set when the handle was opened. */
219
+ jwks: opened.jwks,
220
+ /** Whether a request target is this server's (the engine decides:
221
+ * L4Factoidal.Authz.owns). */
222
+ owns (target) {
223
+ return engine.call('authzOwns', [handle, String(target)]).owns === true
224
+ },
211
225
  /**
212
226
  * One request.
213
227
  * @param {{method: string, target: string, headers: Array<[string, string]>, body: string}} request
package/bin/engine.mjs CHANGED
@@ -120,13 +120,8 @@ export function loadEngine (name = 'l4factoidal') {
120
120
  return promise
121
121
  }
122
122
 
123
- const HEX = (() => {
124
- const table = new Array(256)
125
- for (let byte = 0; byte < 256; byte += 1) {
126
- table[byte] = byte.toString(16).padStart(2, '0')
127
- }
128
- return table
129
- })()
123
+ const HEX_DIGIT = Uint8Array.from('0123456789abcdef', (c) => c.charCodeAt(0))
124
+ const HEX_DECODER = new TextDecoder()
130
125
 
131
126
  /**
132
127
  * Lowercase hexadecimal of a byte array. This is a transport encoding for
@@ -137,7 +132,14 @@ const HEX = (() => {
137
132
  * @returns {string}
138
133
  */
139
134
  export function hexOfBytes (bytes) {
140
- const parts = new Array(bytes.length)
141
- for (let index = 0; index < bytes.length; index += 1) parts[index] = HEX[bytes[index]]
142
- return parts.join('')
135
+ // One byte buffer of ASCII digits, decoded once: about 20 times faster
136
+ // than joining one string per byte for a 753 kB store manifest
137
+ // (https://github.com/danbri/factoidal/issues/1039).
138
+ const out = new Uint8Array(bytes.length * 2)
139
+ for (let index = 0, at = 0; index < bytes.length; index += 1, at += 2) {
140
+ const byte = bytes[index]
141
+ out[at] = HEX_DIGIT[byte >> 4]
142
+ out[at + 1] = HEX_DIGIT[byte & 15]
143
+ }
144
+ return HEX_DECODER.decode(out)
143
145
  }
package/bin/factoidal.mjs CHANGED
@@ -35,6 +35,8 @@ import {
35
35
  STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
36
36
  queryStore, stackLimitAdvice, turtleOfNQuads
37
37
  } from './store.mjs'
38
+ import { sbm11Api, setDefaultSbm11Reader } from '../lib/sbm11.mjs'
39
+ import { nodeDefaultReaderFactory } from '../lib/sbm11-node.mjs'
38
40
 
39
41
  const EXIT_OK = 0
40
42
  const EXIT_FAILURE = 1
@@ -45,7 +47,12 @@ const EXIT_NOT_WIRED = 3
45
47
  // a time, so this is the window that catches one feed per report.
46
48
  const FEED_PROGRESS = 65536
47
49
 
48
- const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
50
+ const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5', 'sbm11']
51
+ // https://github.com/danbri/factoidal/issues/1009: an SBM11 generation is
52
+ // packed by the wasm op sbm11PackFiles, verified by sbm11Verify and queried
53
+ // by sbm11Open / sbm11Query (formal/lean4/Wasm/Ops/Sbm11*.lean). The op
54
+ // storeLayout says which store a directory holds; this file does not decide
55
+ // it from file names (iron rule 7).
49
56
  // The syntaxes the packer reads and the file-name suffixes of each are the
50
57
  // engine's tables (op engineTables: packSyntaxes, packSuffixes,
51
58
  // packUnsupportedSuffixes), https://github.com/danbri/factoidal/issues/894.
@@ -226,8 +233,11 @@ normally STORE/gen-N. Packing does not activate: run activate next.
226
233
  options:
227
234
  --layout LAYOUT ibk5 (quads, wire version 10: RDF 1.2 terms,
228
235
  out-of-line literals, zone maps; default), ibk4
229
- (quads, wire version 9) or ibk3 (triples)
230
- --syntax SYNTAX turtle, trig or nquads; default from the file extension
236
+ (quads, wire version 9), ibk3 (triples) or sbm11
237
+ (the files l4sbm11-pack writes; the document is read
238
+ whole, in memory; serve does not read it yet)
239
+ --syntax SYNTAX turtle, trig or nquads, and with sbm11 also rdfxml or
240
+ jsonld; default from the file extension
231
241
  --chunk-bytes N Merkle chunk size; default is the engine's
232
242
  --batch-bytes N ibk4 only: source bytes between publication batches;
233
243
  default is the engine's. Blocks are published during
@@ -248,7 +258,10 @@ usage: factoidal activate STORE GENERATION [--json]
248
258
 
249
259
  Verifies every artifact of GENERATION and every cross-artifact relation,
250
260
  then atomically replaces STORE/CURRENT. A generation that fails
251
- verification never becomes current.`,
261
+ verification never becomes current. For an SBM11 generation the engine
262
+ checks the length, SHA-256 and Merkle root of every file and every
263
+ .merkle sidecar against the manifest (op sbm11Verify); it does not run
264
+ the decoder checks of l4sbm11 activate.`,
252
265
 
253
266
  update: `factoidal update - apply a SPARQL Update through the delta log
254
267
 
@@ -379,8 +392,10 @@ private keys (with d): for a conformance harness that mints test tokens
379
392
 
380
393
  'lws-serve': `factoidal lws-serve - serve Linked Web Storage 1.0 over HTTP
381
394
 
382
- usage: factoidal lws-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
395
+ usage: factoidal lws-serve DIR (--authz DIR | --issuer IRI | --auth-config FILE | --insecure-no-auth)
383
396
  [--owner WEBID] [--root-acl FILE] [--audience IRI]
397
+ [--alg ES256|EdDSA] [--token-lifetime SECONDS] [--rotate-after SECONDS]
398
+ [--allow-http] [--allow-private-fetch]
384
399
  [--port N] [--host ADDRESS] [--base IRI] [--page-size N]
385
400
  [--paginate-always] [--moves all|data|off]
386
401
  [--no-linkset-put] [--unstable-search-results] [--merkle]
@@ -494,8 +509,21 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
494
509
  --no-cors send no CORS fields; a page on another origin cannot read
495
510
  any answer
496
511
 
497
- Authorization (LWS editor's draft, section Authorization; one of the three
512
+ Authorization (LWS editor's draft, section Authorization; one of the four
498
513
  is required):
514
+ --authz DIR also serve an authorization server on this port, its
515
+ issuer the storage root without the trailing slash
516
+ (BASE less "/"): RFC 8414 metadata at
517
+ /.well-known/lws-configuration, the token endpoint at
518
+ BASE.well-known/lws-token, the JWK Set at
519
+ BASE.well-known/lws-jwks, signing keys in DIR (as
520
+ authz-serve; --alg, --token-lifetime, --rotate-after,
521
+ --allow-http and --allow-private-fetch as there). The
522
+ storage trusts it, and any --issuer too. Each request
523
+ is the authorization server's when the engine says so
524
+ (L4Factoidal/Authz/Mount.lean); every other is the
525
+ storage's. A proxy may serve BASE at a path prefix and
526
+ remove the prefix: both forms of every path are read.
499
527
  --issuer IRI trust this authorization server; several may be
500
528
  given separated by commas. Its metadata is read from
501
529
  IRI/.well-known/lws-configuration and its keys from
@@ -750,7 +778,7 @@ const VALUE_OPTIONS = {
750
778
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
751
779
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
752
780
  'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
753
- 'redirects-max', 'storage', 'cors-origin']),
781
+ 'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after']),
754
782
  'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
755
783
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
756
784
  'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
@@ -931,14 +959,154 @@ function printTable (headers, rows) {
931
959
  for (const row of rows) out(line(row))
932
960
  }
933
961
 
962
+ // --------------------------------------------------------------- SBM11
963
+
964
+ let sbm11 = null
965
+
966
+ /** The SBM11 functions of lib/sbm11.mjs over this command's engine, with
967
+ * the Node directory reader as the default reader (it moves bytes only). */
968
+ function sbm11Functions () {
969
+ if (sbm11 === null) {
970
+ setDefaultSbm11Reader(nodeDefaultReaderFactory())
971
+ sbm11 = sbm11Api(loadEngine)
972
+ }
973
+ return sbm11
974
+ }
975
+
976
+ /**
977
+ * The engine's answer (op storeLayout) when `source` holds an SBM11
978
+ * generation, or names one through CURRENT: `{layout, generation,
979
+ * manifest}`. null for an IBK store, for a directory the op does not
980
+ * recognise, and for an engine built before the op; the IBK path then
981
+ * runs, and reports a missing store in its own words.
982
+ */
983
+ function sbm11Layout (engine, source) {
984
+ sbm11Functions()
985
+ let answer
986
+ try {
987
+ answer = engine.call('storeLayout', [source])
988
+ } catch (_error) {
989
+ return null
990
+ }
991
+ return answer && answer.layout === 'sbm11' ? answer : null
992
+ }
993
+
994
+ /** The directory a command reads: STORE, or STORE/GENERATION when
995
+ * --generation names one. */
996
+ function storeSource (root, named) {
997
+ return named === null ? root : joinPath(root, named)
998
+ }
999
+
1000
+ /** The engine's words of a refusal, without the loader's prefix. */
1001
+ function engineWords (error) {
1002
+ const message = error && error.message ? String(error.message) : String(error)
1003
+ return message.replace(/^l4factoidal:\s*/, '')
1004
+ }
1005
+
1006
+ async function inspectSbm11 (root, named, layout, options) {
1007
+ let store
1008
+ try {
1009
+ store = await sbm11Functions().openSbm11(storeSource(root, named))
1010
+ } catch (error) {
1011
+ err(`factoidal inspect: ${engineWords(error)}`)
1012
+ return EXIT_FAILURE
1013
+ }
1014
+ const terms = store.terms
1015
+ store.close()
1016
+ const generation = named !== null ? named : layout.generation
1017
+ const generationDir = named !== null || layout.generation === ''
1018
+ ? storeSource(root, named)
1019
+ : joinPath(root, layout.generation)
1020
+ const files = listGeneration(generationDir)
1021
+ let directoryBytes = 0
1022
+ for (const file of files) directoryBytes += file.size
1023
+ if (options.json) {
1024
+ out(JSON.stringify({
1025
+ ok: true,
1026
+ layout: 'sbm11',
1027
+ generation,
1028
+ activated: named === null && layout.generation !== '',
1029
+ manifest: layout.manifest,
1030
+ terms,
1031
+ files: files.length,
1032
+ bytes: directoryBytes
1033
+ }, null, 2))
1034
+ return EXIT_OK
1035
+ }
1036
+ out(`store ${root}`)
1037
+ if (named !== null) out(`generation ${generation} (named on the command line)`)
1038
+ else if (layout.generation !== '') out(`generation ${generation} (activated through CURRENT)`)
1039
+ else out('generation directory (no CURRENT)')
1040
+ out(`manifest ${layout.manifest}`)
1041
+ out('layout sbm11')
1042
+ out(plural(terms, 'term'))
1043
+ out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
1044
+ return EXIT_OK
1045
+ }
1046
+
1047
+ /**
1048
+ * Answer a query on an SBM11 store (ops sbm11Open / sbm11Query /
1049
+ * sbm11Close) and print it as `query` prints an IBK answer. The engine
1050
+ * answers SPARQL Query Results JSON for SELECT and ASK and N-Triples for
1051
+ * CONSTRUCT and DESCRIBE; this only chooses which renderer prints it.
1052
+ */
1053
+ async function querySbm11 (engine, root, named, sparql, format, limit, options) {
1054
+ const quiet = options.quiet === true
1055
+ if (options.explain === true) {
1056
+ err('factoidal query: --explain prints the plan of an IBK store; there is ' +
1057
+ 'no plan operation for an SBM11 store yet ' +
1058
+ '(https://github.com/danbri/factoidal/issues/1009)')
1059
+ return EXIT_FAILURE
1060
+ }
1061
+ let answer
1062
+ try {
1063
+ const store = await sbm11Functions().openSbm11(storeSource(root, named))
1064
+ try {
1065
+ const got = store.query(sparql)
1066
+ // read the bytes while the handle is open
1067
+ answer = { path: got.path, format: got.format, reads: got.reads,
1068
+ bytesRead: got.bytesRead, text: got.result }
1069
+ } finally {
1070
+ store.close()
1071
+ }
1072
+ } catch (error) {
1073
+ err(`factoidal query: ${engineWords(error)}`)
1074
+ if (isStackOverflow(error)) {
1075
+ for (const line of stackLimitAdvice(STACK_REMEDY.query)) err(line)
1076
+ }
1077
+ return EXIT_FAILURE
1078
+ }
1079
+ if (!quiet) {
1080
+ err(`${answer.path}, ${plural(answer.reads, 'read')}, ${answer.bytesRead} bytes read`)
1081
+ }
1082
+ let result
1083
+ if (answer.format === 'srj') {
1084
+ const document = JSON.parse(answer.text)
1085
+ if (typeof document.boolean === 'boolean') {
1086
+ if (format === 'json') {
1087
+ out(JSON.stringify(document, null, 2))
1088
+ return EXIT_OK
1089
+ }
1090
+ result = { kind: 'ask', boolean: document.boolean }
1091
+ } else {
1092
+ result = { kind: 'select', srj: document }
1093
+ }
1094
+ } else {
1095
+ result = { kind: 'construct', nquads: answer.text }
1096
+ }
1097
+ return renderQueryResult(engine, result, format, limit, quiet)
1098
+ }
1099
+
934
1100
  // ------------------------------------------------------------- inspect
935
1101
 
936
1102
  async function commandInspect (positional, options) {
937
1103
  if (positional.length !== 1) throw new UsageError('inspect needs exactly one STORE')
938
1104
  const root = positional[0]
939
1105
  const named = typeof options.generation === 'string' ? options.generation : null
940
- const store = openStore(root, named)
941
1106
  const engine = await loadEngine()
1107
+ const layout = sbm11Layout(engine, storeSource(root, named))
1108
+ if (layout !== null) return inspectSbm11(root, named, layout, options)
1109
+ const store = openStore(root, named)
942
1110
  const envelope = inspectManifest(engine, store)
943
1111
 
944
1112
  if (options.json) {
@@ -1102,8 +1270,11 @@ async function commandQuery (positional, options) {
1102
1270
  const quiet = options.quiet === true
1103
1271
 
1104
1272
  const named = typeof options.generation === 'string' ? options.generation : null
1105
- const store = openStore(root, named)
1106
1273
  const engine = await loadEngine()
1274
+ if (sbm11Layout(engine, storeSource(root, named)) !== null) {
1275
+ return querySbm11(engine, root, named, sparql, format, limit, options)
1276
+ }
1277
+ const store = openStore(root, named)
1107
1278
 
1108
1279
  if (options.explain === true) {
1109
1280
  let plan
@@ -1208,9 +1379,15 @@ function readHead (path, length) {
1208
1379
  /** The syntax tag for an input: --syntax when given, else the engine's
1209
1380
  * suffix table (op engineTables), else the engine's content detection
1210
1381
  * (op detectFormat) on the first 64 KiB of the file. */
1211
- function packSyntax (engine, input, options) {
1382
+ function packSyntax (engine, input, options, layout) {
1212
1383
  const tables = engine.call('engineTables', []).tables
1213
- const syntaxes = tables.packSyntaxes
1384
+ // The SBM11 pack reads the whole document (op sbm11PackFiles), so it also
1385
+ // reads the syntaxes of packWholeSuffixes (RDF/XML, JSON-LD).
1386
+ const whole = layout === 'sbm11' ? (tables.packWholeSuffixes ?? {}) : {}
1387
+ const syntaxes = tables.packSyntaxes.slice()
1388
+ for (const syntax of Object.values(whole)) {
1389
+ if (syntaxes.indexOf(syntax) < 0) syntaxes.push(syntax)
1390
+ }
1214
1391
  if (typeof options.syntax === 'string') {
1215
1392
  const syntax = options.syntax.toLowerCase()
1216
1393
  if (syntaxes.indexOf(syntax) >= 0) return syntax
@@ -1220,11 +1397,14 @@ function packSyntax (engine, input, options) {
1220
1397
  for (const [suffix, syntax] of Object.entries(tables.packSuffixes)) {
1221
1398
  if (lower.endsWith(suffix)) return syntax
1222
1399
  }
1400
+ for (const [suffix, syntax] of Object.entries(whole)) {
1401
+ if (lower.endsWith(suffix)) return syntax
1402
+ }
1223
1403
  for (const [suffix, name] of Object.entries(tables.packUnsupportedSuffixes)) {
1224
1404
  if (lower.endsWith(suffix)) {
1225
1405
  throw new UsageError(
1226
- `pack does not read ${name}. The packer's streaming fold reads ` +
1227
- `${syntaxes.join(', ')} only. Convert the file first, for ` +
1406
+ `pack does not read ${name}. The packer reads ` +
1407
+ `${syntaxes.join(', ')} only${layout === 'sbm11' ? '' : ' (--layout sbm11 also reads rdfxml and jsonld)'}. Convert the file first, for ` +
1228
1408
  "example with: factoidal parse FILE --out nquads")
1229
1409
  }
1230
1410
  }
@@ -1253,8 +1433,11 @@ function packSyntax (engine, input, options) {
1253
1433
  * `--base ''` asks for no base, which turns a relative IRI into a parse
1254
1434
  * error rather than a silently different term.
1255
1435
  */
1256
- function packBase (input, options) {
1436
+ function packBase (input, options, layout) {
1257
1437
  if (typeof options.base === 'string') return options.base
1438
+ // `l4sbm11-pack` uses no base unless --base names one; the same default
1439
+ // gives the same bytes.
1440
+ if (layout === 'sbm11') return ''
1258
1441
  const absolute = input.startsWith('/') ? input : joinPath(currentDirectory(), input)
1259
1442
  return 'file://' + absolute
1260
1443
  }
@@ -1275,8 +1458,8 @@ function packLayout (options) {
1275
1458
  async function commandPack (positional, options) {
1276
1459
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
1277
1460
  const [input, output] = positional
1278
- const syntax = packSyntax(await loadEngine(), input, options)
1279
1461
  const layout = packLayout(options)
1462
+ const syntax = packSyntax(await loadEngine(), input, options, layout)
1280
1463
  // The pack fold needs a bigger call stack than either runtime gives by
1281
1464
  // default (https://github.com/danbri/factoidal/issues/649). Under Node
1282
1465
  // the work runs on a worker thread with a raised stack; under Deno the
@@ -1291,8 +1474,10 @@ async function commandPack (positional, options) {
1291
1474
  let answer
1292
1475
  try {
1293
1476
  answer = await runPack(
1294
- { kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
1295
- batchBytes: options['batch-bytes'] },
1477
+ layout === 'sbm11'
1478
+ ? { kind: 'packSbm11', input, output, syntax, base: packBase(input, options, layout) }
1479
+ : { kind: 'pack', input, output, syntax, layout, base: packBase(input, options, layout),
1480
+ batchBytes: options['batch-bytes'] },
1296
1481
  quiet
1297
1482
  ? undefined
1298
1483
  : (progress) => {
@@ -1331,7 +1516,7 @@ async function commandPack (positional, options) {
1331
1516
  return EXIT_OK
1332
1517
  }
1333
1518
  out(`packed ${report.bytesRead} bytes of ${syntax} into ${output}`)
1334
- out(`${report.written.length} artifacts, ${report.bytesWritten} bytes, layout ${layout}`)
1519
+ out(`${report.written.length} ${layout === 'sbm11' ? 'files' : 'artifacts'}, ${report.bytesWritten} bytes, layout ${layout}`)
1335
1520
  if (typeof report.rows === 'number') out(`${plural(report.rows, 'row')}`)
1336
1521
  out(`activate it with: factoidal activate ${dirOf(output)} ${nameOf(output)}`)
1337
1522
  return EXIT_OK
@@ -1382,6 +1567,10 @@ async function commandActivate (positional, options) {
1382
1567
  const host = { worker: options['no-worker'] !== true }
1383
1568
  const reexec = await denoReexec(host)
1384
1569
  if (reexec !== null) return reexec
1570
+ const engine = await loadEngine()
1571
+ if (sbm11Layout(engine, joinPath(root, generation)) !== null) {
1572
+ return activateSbm11(engine, root, generation, options)
1573
+ }
1385
1574
  let answer
1386
1575
  try {
1387
1576
  answer = await runPack({ kind: 'activate', root, generation }, undefined, host)
@@ -1425,6 +1614,32 @@ async function commandActivate (positional, options) {
1425
1614
  return EXIT_OK
1426
1615
  }
1427
1616
 
1617
+ /**
1618
+ * Activate an SBM11 generation: the engine checks every file against the
1619
+ * manifest (op sbm11Verify), and only after its "ok" does CURRENT move.
1620
+ */
1621
+ function activateSbm11 (engine, root, generation, options) {
1622
+ let verdict
1623
+ try {
1624
+ verdict = engine.call('sbm11Verify', [joinPath(root, generation)])
1625
+ } catch (error) {
1626
+ err(`factoidal activate: ${engineWords(error)}`)
1627
+ err('The generation is NOT activated; CURRENT is unchanged.')
1628
+ return EXIT_FAILURE
1629
+ }
1630
+ const synced = atomicReplace(joinPath(root, 'CURRENT'), new TextEncoder().encode(generation))
1631
+ if (options.json === true) {
1632
+ out(JSON.stringify({ ...verdict, layout: 'sbm11', generation, directorySynced: synced }, null, 2))
1633
+ return EXIT_OK
1634
+ }
1635
+ out(`activated ${generation}: ${verdict.artifacts} artifacts verified (${verdict.files} files, ${verdict.bytes} bytes), layout sbm11`)
1636
+ if (!synced) {
1637
+ err('CURRENT was replaced, but the directory entry was not synced; a ' +
1638
+ 'crash now could lose the pointer update.')
1639
+ }
1640
+ return EXIT_OK
1641
+ }
1642
+
1428
1643
  function commandUpdate (positional, options) {
1429
1644
  if (positional.length < 1) throw new UsageError('update needs a STORE')
1430
1645
  const text = positional.length > 1
@@ -1529,6 +1744,10 @@ async function commandServe (positional, options) {
1529
1744
  const { join } = await import('node:path')
1530
1745
  const root = mkdtempSync(join(tmpdir(), 'factoidal-serve-'))
1531
1746
  const packOptions = { quiet: true, layout: packLayout(options) }
1747
+ if (packOptions.layout === 'sbm11') {
1748
+ throw new UsageError('serve --file packs a store that accepts updates; --layout sbm11 ' +
1749
+ 'packs a store that serve does not read yet. Use ibk3, ibk4 or ibk5.')
1750
+ }
1532
1751
  let rc = await commandPack([options.file, join(root, 'gen-1')], packOptions)
1533
1752
  if (rc !== 0) return rc
1534
1753
  rc = await commandActivate([root, 'gen-1'], { quiet: true })
@@ -1549,6 +1768,12 @@ async function commandServe (positional, options) {
1549
1768
  options = { ...options, 'query-path': `${dsName}/sparql`, 'gsp-path': `${dsName}/data` }
1550
1769
  }
1551
1770
  if (positional.length !== 1) throw new UsageError('serve needs one STORE')
1771
+ if (!tdb2 && sbm11Layout(await loadEngine(), positional[0]) !== null) {
1772
+ err(`factoidal serve: ${positional[0]} is an SBM11 store, and serve reads IBK ` +
1773
+ 'stores only (the storeServe operations). Query it with: factoidal query ' +
1774
+ `${positional[0]} QUERY (https://github.com/danbri/factoidal/issues/1009)`)
1775
+ return EXIT_FAILURE
1776
+ }
1552
1777
  const { listen, listenOnWorker, ServeHostError } = await import('./serve.mjs')
1553
1778
  const serveOptions = {
1554
1779
  port: servePort(options, 3030),
@@ -1590,12 +1815,25 @@ async function commandLwsServe (positional, options) {
1590
1815
  const insecure = options['insecure-no-auth'] === true
1591
1816
  const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1592
1817
  const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1593
- if (!insecure && issuerOption === null && authFile === null) {
1594
- throw new UsageError('lws-serve needs --issuer IRI or --auth-config FILE, ' +
1818
+ const authzDir = typeof options.authz === 'string' ? options.authz : null
1819
+ if (!insecure && issuerOption === null && authFile === null && authzDir === null) {
1820
+ throw new UsageError('lws-serve needs --authz DIR, --issuer IRI or --auth-config FILE, ' +
1595
1821
  'or --insecure-no-auth for development')
1596
1822
  }
1597
- if (insecure && (issuerOption !== null || authFile !== null)) {
1598
- throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1823
+ if (insecure && (issuerOption !== null || authFile !== null || authzDir !== null)) {
1824
+ throw new UsageError('--insecure-no-auth cannot be combined with --authz, --issuer or --auth-config')
1825
+ }
1826
+ // --authz DIR: an authorization server on the same port, its issuer the
1827
+ // storage root (L4Factoidal/Authz/Mount.lean). Its options are passed
1828
+ // unread; the engine checks them (Wasm/Ops/Authz.lean).
1829
+ const authz = authzDir === null ? undefined : {
1830
+ dir: authzDir,
1831
+ ...(typeof options.alg === 'string' ? { alg: options.alg } : {}),
1832
+ ...(options['token-lifetime'] !== undefined ? { tokenLifetime: natOption('token-lifetime', options['token-lifetime']) } : {}),
1833
+ ...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
1834
+ ...(options['allow-http'] === true ? { allowHttp: true } : {}),
1835
+ ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
1836
+ ...corsConfig(options)
1599
1837
  }
1600
1838
  let auth
1601
1839
  if (authFile !== null) {
@@ -1628,6 +1866,7 @@ async function commandLwsServe (positional, options) {
1628
1866
  ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
1629
1867
  ...corsConfig(options),
1630
1868
  ...(insecure ? { insecureNoAuth: true } : { auth }),
1869
+ ...(authz !== undefined ? { authz } : {}),
1631
1870
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1632
1871
  ...(typeof options['root-acl'] === 'string'
1633
1872
  ? { rootAcl: new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options['root-acl'])) }
@@ -1651,6 +1890,9 @@ async function commandLwsServe (positional, options) {
1651
1890
  }
1652
1891
  if (!quiet) {
1653
1892
  err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1893
+ if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
1894
+ if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
1895
+ if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
1654
1896
  if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
1655
1897
  }
1656
1898
  out(running.origin)