@factoidal/core 0.10.0 → 0.11.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,119 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.0 (unreleased)
3
+ ## 0.11.0 — 2026-10-21
4
+
5
+ No RDF, SPARQL, datatype or IRI decision is made in JavaScript any more
6
+ (https://github.com/danbri/factoidal/issues/894; owner, 2026-10-21: "The ja
7
+ leak fix is p0"). The engine parses, writes, escapes, compares and chooses
8
+ datatypes; the JavaScript copies term components and calls ops.
9
+ `tools/host-purity-lint.sh` group 3 scans the npm and hub JavaScript for RDF
10
+ syntax and runs in `npm test`.
11
+
12
+ ### Breaking changes
13
+
14
+ These shapes could not be kept:
15
+
16
+ - `shexValidate` and `Dataset.validateShex`: a focus or shape given as a
17
+ string is read as one RDF 1.2 N-Triples term (`'<http://…>'` or `'_:b'`);
18
+ a bare IRI string is refused. Every API that takes a term as a string
19
+ now reads it through one engine reader (`TermJson.readTermString`), so
20
+ `_:x` is always a blank node and `<…>` always an IRI.
21
+ - `graphs()` returns `[NamedNode|BlankNode, Dataset]` pairs, not
22
+ `[string, Dataset]`, so an IRI graph and a blank-node graph with the same
23
+ text are never confused.
24
+ - A literal with a language and a direction has datatype
25
+ `rdf:dirLangString` (RDF 1.2 Concepts; RDF/JS Data Model); before, the
26
+ RDF/JS default gave `rdf:langString`.
27
+
28
+ - `factoidal lws-serve` and `@factoidal/core/lws` enforce storage-server
29
+ authorization from the LWS editor's draft (401 with
30
+ `WWW-Authenticate: Bearer resource_metadata`, the RFC 9728 metadata
31
+ document, Bearer JWT access tokens checked for signature, `iss`, `aud`,
32
+ `exp`, `nbf` and `iat`, then Web Access Control). `lws-serve` needs
33
+ `--issuer IRI`, `--auth-config FILE` or, for development only,
34
+ `--insecure-no-auth`; `listen()` with neither `auth` nor
35
+ `insecureNoAuth` serves only what the ACLs give to the public. New op
36
+ `lwsFetched`. https://github.com/danbri/factoidal/issues/886
37
+
38
+ - The N-Quads text functions of `@factoidal/core/rdfjs` (`nquadsToQuads`,
39
+ `nquadsLineToQuad`, `quadsToNQuads`, `quadToNQuads`, `termToNQuads`,
40
+ `DatasetCore.fromNQuads`, `DatasetCore.prototype.toNQuads`) call the engine.
41
+ They throw until the engine is loaded: `await ready()` (new) or any other
42
+ `@factoidal/core` call first. `nquadsToQuads` now rejects a line the
43
+ N-Quads grammar rejects (before, `<http://e/a b>` was read as an IRI), and
44
+ the writers escape as the engine's canonical N-Triples form does (before,
45
+ `\b`, `\f` and other control characters were written raw).
46
+ - A JavaScript number answered by a registered extension function becomes
47
+ `xsd:integer` only for an integer within ±(2^53 − 1); every other number is
48
+ an `xsd:double` in the XSD 1.1 canonical form (`1e21` is `"1.0E21"`, before
49
+ `"1e+21"^^xsd:integer`; `0.1` is `"1.0E-1"`, before `"0.1"`). `NaN`,
50
+ `Infinity` and `-Infinity` answer `"NaN"`, `"INF"`, `"-INF"`; before they
51
+ were the §17.6 error. A `bigint` answers `xsd:integer`
52
+ (`L4Factoidal.XSD.JsValue.jsLiteral`).
53
+ - `queryHdt` answers a Dataset for CONSTRUCT and DESCRIBE; before it threw a
54
+ TypeError chosen by a regular expression over the query text.
55
+ - `Literal` has a `direction` member (`''`, `'ltr'`, `'rtl'`), and a
56
+ directional language-tagged string from the engine keeps its direction and
57
+ its `rdf:dirLangString` datatype; `equals` compares it.
58
+
59
+ ### New
60
+
61
+ - `isomorphic(a, b)`: dataset isomorphism by the engine
62
+ (`Dataset.isomorphicOutcome`, blank nodes in triple terms included).
63
+ `@factoidal/core/fn`'s `equals` uses it.
64
+ - `fromQuads(quads)`: a Dataset from RDF/JS quads, sent as term components.
65
+ - `ready()`: load the engine. `engineTables()`: the engine's format,
66
+ regime, query-output and pack-syntax name tables.
67
+ - Ops (formal/lean4/Wasm/Ops/Handles.lean): `datasetQuadsJson`,
68
+ `datasetAddQuadsJson`, `datasetDeleteQuadsJson`, `datasetFromQuadsJson`,
69
+ `datasetGraphNames`, `datasetGraphAsDefault`, `datasetIsomorphic`,
70
+ `parseQuadsJson`, `quadsToNQuads`, `termsToNTriples`, `resolveIri`,
71
+ `queryForm`, `engineTables`; `datasetMatch`/`datasetMatchCount` take term
72
+ objects. Theorems: `TermJson.decodeTerm_encodeTerm`,
73
+ `TermJson.decodeQuad_encodeQuad`, `TermJson.mem_quadList_default`,
74
+ `TermJson.mem_quadList_named`, `TermJson.toCanonicalNQuads_eq_quadList`,
75
+ `TermJson.quadPage_append`, `XSD.JsValue.doubleLexical_inLexicalSpace`,
76
+ `XSD.JsValue.integerLexical_inLexicalSpace`,
77
+ `XSD.JsValue.jsLiteral_number_double`, `XSD.JsValue.jsLiteral_number_integer`.
78
+
79
+ ### Added
80
+
81
+ - An extension function may answer an exact term: an RDF/JS term
82
+ (Literal, NamedNode, BlankNode, or a Quad as an RDF 1.2 triple term), or
83
+ `{value, datatype}`, or `{value, language, direction?}`. The lexical form
84
+ and the datatype IRI are kept as given, ill-typed literals included (RDF
85
+ 1.1 Concepts §3.3). The engine checks only what RDF requires: absolute
86
+ IRIs, a well-formed BCP 47 language tag, direction `ltr` or `rtl`; a term
87
+ that fails is the §17.6 error for that call. A plain JS number still gets
88
+ its datatype from `jsLiteral`. Theorem `TermJson.extAnswerJson_encodeTerm`:
89
+ an app-supplied term is the answer, unchanged.
90
+
91
+ ### Performance
92
+
93
+ - Dataset iteration reads the quads from the engine as one byte region (op
94
+ `datasetQuadsBin`: a term table in the delta-log v2 term codec and four
95
+ u32 indices per quad), decoded in JavaScript with `DataView` and
96
+ `TextDecoder`. 60,000 quads: 178–205 ms under load, against 214–280 ms in
97
+ 0.10.0 and about the time of the N-Quads writer op in the same run.
98
+ Theorems `decodeBin_quadsBin` and `quadsBin_binSpec`
99
+ (`formal/lean4/L4Factoidal/RDF/QuadsBinTheorems.lean`) prove the region
100
+ reads back as exactly the dataset's quads, so no run-time check is made.
101
+ https://github.com/danbri/factoidal/issues/900
102
+
103
+ ### Fixed
104
+
105
+ - `graphs()` (both APIs) no longer merges an IRI graph and a blank-node graph
106
+ whose strings are equal.
107
+ - `Dataset` iteration builds its quads from the engine's term objects; it no
108
+ longer parses N-Quads text in JavaScript.
109
+ - The Solid client resolves a relative target against `baseIri` by RFC 3986
110
+ §5.2 (op `resolveIri`); before it concatenated strings.
111
+ - `factoidal query` table cells and `factoidal inspect` graph names are the
112
+ engine's canonical N-Triples forms (escaped); `factoidal pack` reads its
113
+ suffix table from the engine and detects the syntax of a file with an
114
+ unknown suffix from its content.
115
+
116
+ ## 0.10.0 — 2026-10-21
4
117
 
5
118
  ### Breaking changes
6
119
 
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()`):
@@ -75,7 +81,7 @@ $ npx factoidal serve --tdb2 DIR /ds # serve a Jena TDB2 database
75
81
  $ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1
76
82
  $ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
77
83
  $ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
78
- $ npx factoidal lws-serve DIR # Linked Web Storage
84
+ $ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
79
85
  $ npx factoidal solid-serve DIR # Solid Protocol
80
86
  ```
81
87
 
package/bin/ext.mjs CHANGED
@@ -33,11 +33,12 @@
33
33
  // times the physical plan evaluates the expression, and across
34
34
  // re-evaluation rounds.
35
35
 
36
- const XSD = 'http://www.w3.org/2001/XMLSchema#'
37
36
 
38
37
  /** How many evaluation rounds an async resolution may take. */
39
38
  export const EXT_MAX_ROUNDS = 25
40
39
 
40
+ import { appTermToJson } from '../rdfjs.mjs'
41
+
41
42
  const extFunctions = new Map()
42
43
  const extRegistered = new Set()
43
44
  let extCache = new Map()
@@ -45,29 +46,24 @@ let extPending = []
45
46
  let bridgeInstalled = false
46
47
 
47
48
  /**
48
- * Encode one JavaScript answer as an SRJ binding-value object, as a JSON
49
- * string. `''` means "no value" — the §17.6 error.
49
+ * Encode one JavaScript answer for Wasm/Ops/ExtFns.lean, as a JSON string:
50
+ * the value tagged with its JavaScript type ({js, v}; a number as its
51
+ * Number::toString text), or a term object. The engine chooses the datatype
52
+ * and the lexical form (L4Factoidal.XSD.JsValue.jsLiteral,
53
+ * https://github.com/danbri/factoidal/issues/894 item 3). `''` means
54
+ * "no value", the section 17.6 error.
50
55
  */
51
56
  function encodeAnswer (out) {
52
57
  if (out === null || out === undefined) return ''
53
- if (typeof out === 'boolean') {
54
- return JSON.stringify({ type: 'literal', value: out ? 'true' : 'false', datatype: XSD + 'boolean' })
55
- }
56
- if (typeof out === 'number') {
57
- if (!Number.isFinite(out)) return ''
58
- return Number.isInteger(out)
59
- ? JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'integer' })
60
- : JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'double' })
61
- }
62
- if (typeof out === 'string') {
63
- return JSON.stringify({ type: 'literal', value: out })
64
- }
65
- if (typeof out === 'object' && typeof out.type === 'string') {
66
- // Already an SRJ binding-value object (or an RDF/JS-shaped term the
67
- // caller built): pass it through and let the Lean decoder judge it.
68
- return JSON.stringify(out)
58
+ switch (typeof out) {
59
+ case 'boolean': return JSON.stringify({ js: 'boolean', v: out })
60
+ case 'number': return JSON.stringify({ js: 'number', v: Object.is(out, -0) ? '-0' : String(out) })
61
+ case 'bigint': return JSON.stringify({ js: 'bigint', v: out.toString() })
62
+ case 'string': return JSON.stringify({ js: 'string', v: out })
63
+ case 'object':
64
+ try { return JSON.stringify(appTermToJson(out)) } catch (_error) { return '' }
65
+ default: return ''
69
66
  }
70
- return ''
71
67
  }
72
68
 
73
69
  /** The synchronous bridge the wasm module calls. */
package/bin/factoidal.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  // `--format turtle` all print documents the engine produced.
21
21
 
22
22
  import {
23
- StoreHostError, atomicReplace, listGeneration, makeDirectory, readWhole,
23
+ StoreHostError, atomicReplace, listGeneration, makeDirectory, readChunk, readWhole,
24
24
  runtime
25
25
  } from '../store-host/index.mjs'
26
26
  import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
@@ -46,23 +46,9 @@ const EXIT_NOT_WIRED = 3
46
46
  const FEED_PROGRESS = 65536
47
47
 
48
48
  const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
49
- const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
50
- const PACK_SUFFIXES = [
51
- ['.ttl', 'turtle'], ['.turtle', 'turtle'],
52
- ['.trig', 'trig'],
53
- ['.nq', 'nquads'], ['.nquads', 'nquads'],
54
- ['.nt', 'ntriples'], ['.ntriples', 'ntriples']
55
- ]
56
-
57
- // Suffixes the engine parses elsewhere but the packer's streaming fold does
58
- // not read. Naming them is better than "cannot tell the syntax from its
59
- // name", which sends the reader looking for a --syntax value that does not
60
- // exist.
61
- const PACK_UNSUPPORTED_SUFFIXES = [
62
- ['.rdf', 'RDF/XML'], ['.owl', 'RDF/XML'], ['.xml', 'RDF/XML'],
63
- ['.jsonld', 'JSON-LD'], ['.json', 'JSON-LD'], ['.n3', 'Notation3'],
64
- ['.csv', 'CSV'], ['.tsv', 'TSV'], ['.hdt', 'HDT']
65
- ]
49
+ // The syntaxes the packer reads and the file-name suffixes of each are the
50
+ // engine's tables (op engineTables: packSyntaxes, packSuffixes,
51
+ // packUnsupportedSuffixes), https://github.com/danbri/factoidal/issues/894.
66
52
 
67
53
  /** The parent of a path, and its last component. The `activate` hint
68
54
  * printed after a pack needs both; neither is a format decision. */
@@ -344,43 +330,92 @@ https://www.w3.org/TR/sparql11-service-description/`,
344
330
 
345
331
  'lws-serve': `factoidal lws-serve - serve Linked Web Storage 1.0 over HTTP
346
332
 
347
- usage: factoidal lws-serve DIR [--port N] [--host ADDRESS]
333
+ usage: factoidal lws-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
334
+ [--owner WEBID] [--root-acl FILE] [--audience IRI]
335
+ [--port N] [--host ADDRESS] [--base IRI]
348
336
 
349
337
  Starts a Node HTTP server that answers every request through the engine's
350
338
  lwsStep operation.
351
339
 
352
- DIR names the storage directory. The first slice keeps the resource tree
353
- in the engine handle, so DIR is not read or written yet; it is the place
354
- a later snapshot goes (design record, section 2).
340
+ DIR names the storage directory. The storage is kept in the engine
341
+ handle and written to DIR/lws-state.json after every request that
342
+ changed it (temporary file, fsync, rename), and read back on the next
343
+ start. The engine decides the snapshot format and refuses a snapshot
344
+ path with a dot segment or other traversal form
345
+ (formal/lean4/L4Factoidal/LWS/Snapshot.lean).
355
346
 
356
347
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
357
348
  --host ADDRESS the address to bind (default 127.0.0.1)
358
349
  --base IRI the absolute IRI the storage root maps to
359
350
  (default: the bound origin)
360
351
 
352
+ Authorization (LWS editor's draft, section Authorization; one of the three
353
+ is required):
354
+ --issuer IRI trust this authorization server; several may be
355
+ given separated by commas. Its metadata is read from
356
+ IRI/.well-known/lws-configuration and its keys from
357
+ the jwks_uri there, when a token first needs them.
358
+ --auth-config FILE the engine's "auth" configuration as JSON:
359
+ {"issuers":[{"issuer":IRI,"jwks":{...}?}],
360
+ "audience":IRI?, "leeway":SECONDS?,
361
+ "refreshInterval":SECONDS?}
362
+ --insecure-no-auth development only: no authorization at all; every
363
+ request is served. Never the default.
364
+ --owner WEBID the storage owner: a new storage gets a root ACL
365
+ that gives this agent Read, Write and Control
366
+ --root-acl FILE a Turtle root ACL for a new storage instead
367
+ --audience IRI the aud value tokens must carry (default: the
368
+ storage root IRI)
369
+
370
+ Requests carry an access token as "Authorization: Bearer TOKEN" (RFC 6750).
371
+ The engine verifies its JWS signature (ES256, RS256 or EdDSA, with the
372
+ vendored HACL* code), its iss, aud, exp, nbf and iat, and then decides
373
+ access with Web Access Control over the storage's ACL resources. A
374
+ request without a valid token answers 401 with
375
+ WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728), and the
376
+ metadata document is served at /.well-known/oauth-protected-resource.
377
+
361
378
  The server makes no protocol decision: status codes, Link relations,
362
- Last-Modified and the PATCH blank-node refusal are all answered by
363
- formal/lean4/L4Factoidal/LWS/. A WebAssembly module built before those
379
+ Last-Modified, the PATCH blank-node refusal and every authorization
380
+ decision are answered by formal/lean4/L4Factoidal/LWS/. A WebAssembly module built before those
364
381
  operations landed makes this command exit 3 and say so.
365
382
 
366
383
  Specification: https://w3c.github.io/lws-protocol/lws10-core/`,
367
384
 
368
385
  'solid-serve': `factoidal solid-serve - serve the Solid Protocol over HTTP
369
386
 
370
- usage: factoidal solid-serve DIR [--port N] [--host ADDRESS]
387
+ usage: factoidal solid-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
388
+ [--owner WEBID] [--port N] [--host ADDRESS] [--base IRI]
371
389
 
372
390
  Starts a Node HTTP server that answers every request through the engine's
373
391
  solidStep operation.
374
392
 
375
- DIR names the storage directory. The first slice keeps the resource tree
376
- in the engine handle, so DIR is not read or written yet; it is the place
377
- a later snapshot goes (design record, section 2).
393
+ DIR names the storage directory. The storage is kept in the engine
394
+ handle and written to DIR/lws-state.json after every request that
395
+ changed it (temporary file, fsync, rename), and read back on the next
396
+ start. The engine decides the snapshot format and refuses a snapshot
397
+ path with a dot segment or other traversal form
398
+ (formal/lean4/L4Factoidal/LWS/Snapshot.lean).
378
399
 
379
400
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
380
401
  --host ADDRESS the address to bind (default 127.0.0.1)
381
402
  --base IRI the absolute IRI the storage root maps to
382
403
  (default: the bound origin)
383
- --owner WEBID the WebID the storage advertises as its owner
404
+ --owner WEBID the WebID the storage advertises as its owner; under
405
+ authentication it gets acl:Read, acl:Write and
406
+ acl:Control on the root (Web Access Control §3.2)
407
+ --issuer IRI trust this Solid-OIDC issuer (comma separated for more).
408
+ The engine verifies each DPoP-bound access token
409
+ (Solid-OIDC §8, §9.3; RFC 9449): signature under the
410
+ issuer's keys found by OpenID Connect Discovery, aud
411
+ "solid", webid, exp, cnf.jkt bound to the DPoP key,
412
+ htm, htu, iat and a jti replay cache, and that the
413
+ WebID profile lists the issuer (solid:oidcIssuer).
414
+ --auth-config FILE the same as JSON: {"issuers":[{"issuer":IRI,
415
+ "jwks":{...}?}], "leeway"?, "iatWindow"?,
416
+ "refreshInterval"?, "checkProfileIssuer"?}
417
+ --insecure-no-auth development only: no authentication; Web Access
418
+ Control is not enforced
384
419
 
385
420
  The server makes no protocol decision. Storage discovery, containment
386
421
  triples, slash semantics, the Allow and Accept-* headers, the PUT/POST/
@@ -469,8 +504,9 @@ const VALUE_OPTIONS = {
469
504
  update: new Set(['update', 'file']),
470
505
  compact: new Set([]),
471
506
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
472
- 'lws-serve': new Set(['port', 'host', 'base']),
473
- 'solid-serve': new Set(['port', 'host', 'base', 'owner']),
507
+ 'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
508
+ 'audience']),
509
+ 'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
474
510
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret'])
475
511
  }
476
512
 
@@ -599,31 +635,28 @@ async function commandVersion (options) {
599
635
  /**
600
636
  * One graph name as the manifest reports it. `{"kind":"default"}` is the
601
637
  * default graph; the operation marks it that way so a host never has to
602
- * recognise a reserved IRI.
638
+ * recognise a reserved IRI. The engine writes the N-Triples form of a name
639
+ * (op termsToNTriples).
603
640
  */
604
- function graphLabel (graph) {
605
- if (graph.kind === 'default') return 'default'
606
- if (graph.kind === 'iri') return `<${graph.value}>`
607
- if (graph.kind === 'bnode') return `_:${graph.value}`
608
- return JSON.stringify(graph)
641
+ function graphLabels (engine, graphs) {
642
+ const terms = graphs.filter((g) => g.kind !== 'default')
643
+ .map((g) => ({ type: g.kind === 'iri' ? 'uri' : 'bnode', value: g.value }))
644
+ const tokens = terms.length === 0 ? [] : engine.call('termsToNTriples', [JSON.stringify(terms)]).tokens
645
+ let k = 0
646
+ return graphs.map((g) => (g.kind === 'default' ? 'default' : tokens[k++]))
609
647
  }
610
648
 
611
649
  /**
612
- * One SPARQL Query Results JSON term as a table cell. This is a display,
613
- * not a serialization: `--format json` prints the engine's own document
614
- * and `--format nquads` prints the engine's own N-Triples.
650
+ * The SPARQL Query Results JSON terms of a table, as display cells: the
651
+ * engine writes each term's canonical N-Triples form (op termsToNTriples).
652
+ * `--format json` prints the engine's own document.
615
653
  */
616
- function cellOfTerm (term) {
617
- if (term === undefined || term === null) return ''
618
- if (term.type === 'uri') return `<${term.value}>`
619
- if (term.type === 'bnode') return `_:${term.value}`
620
- if (term.type === 'literal') {
621
- const lang = term['xml:lang']
622
- if (typeof lang === 'string' && lang.length > 0) return `"${term.value}"@${lang}`
623
- if (typeof term.datatype === 'string') return `"${term.value}"^^<${term.datatype}>`
624
- return `"${term.value}"`
625
- }
626
- return JSON.stringify(term)
654
+ function cellsOfRows (engine, rows) {
655
+ const terms = []
656
+ for (const row of rows) for (const t of row) if (t !== undefined && t !== null) terms.push(t)
657
+ const tokens = terms.length === 0 ? [] : engine.call('termsToNTriples', [JSON.stringify(terms)]).tokens
658
+ let k = 0
659
+ return rows.map((row) => row.map((t) => (t === undefined || t === null ? '' : tokens[k++])))
627
660
  }
628
661
 
629
662
  function plural (count, noun) {
@@ -699,7 +732,7 @@ async function commandInspect (positional, options) {
699
732
  String(entry.rows),
700
733
  String(entry.bytes),
701
734
  entry.blockKind,
702
- entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
735
+ entry.graphs.length === 0 ? '-' : graphLabels(engine, entry.graphs).join(' '),
703
736
  entry.predicate
704
737
  ].concat(zoned
705
738
  ? [String((entry.blobRefs ?? []).length), zoneCell(entry.subjectZone)]
@@ -875,7 +908,7 @@ function renderQueryResult (engine, result, format, limit, quiet) {
875
908
  return EXIT_FAILURE
876
909
  }
877
910
  const shown = limit === null ? bindings : bindings.slice(0, limit)
878
- printTable(vars, shown.map((row) => vars.map((name) => cellOfTerm(row[name]))))
911
+ printTable(vars, cellsOfRows(engine, shown.map((row) => vars.map((name) => row[name]))))
879
912
  if (shown.length < bindings.length) {
880
913
  err(`showing ${shown.length} of ${bindings.length} rows (--limit ${limit})`)
881
914
  } else if (!quiet) {
@@ -914,32 +947,51 @@ function renderQueryResult (engine, result, format, limit, quiet) {
914
947
  return EXIT_FAILURE
915
948
  }
916
949
 
917
- /** The syntax tag for an input, from --syntax or from the file name. The
918
- * engine is what actually decides how to read the bytes; this only picks
919
- * which of its parsers to name. */
920
- function packSyntax (input, options) {
950
+ /** The first `length` bytes of a file, as text, for content detection. */
951
+ function readHead (path, length) {
952
+ return new TextDecoder('utf-8', { fatal: false }).decode(readChunk(path, 0, length))
953
+ }
954
+
955
+ /** The syntax tag for an input: --syntax when given, else the engine's
956
+ * suffix table (op engineTables), else the engine's content detection
957
+ * (op detectFormat) on the first 64 KiB of the file. */
958
+ function packSyntax (engine, input, options) {
959
+ const tables = engine.call('engineTables', []).tables
960
+ const syntaxes = tables.packSyntaxes
921
961
  if (typeof options.syntax === 'string') {
922
962
  const syntax = options.syntax.toLowerCase()
923
- if (PACK_SYNTAXES.indexOf(syntax) >= 0) return syntax
924
- throw new UsageError(`--syntax ${options.syntax} is not one of ${PACK_SYNTAXES.join(', ')}`)
963
+ if (syntaxes.indexOf(syntax) >= 0) return syntax
964
+ throw new UsageError(`--syntax ${options.syntax} is not one of ${syntaxes.join(', ')}`)
925
965
  }
926
966
  const lower = input.toLowerCase()
927
- for (const [suffix, syntax] of PACK_SUFFIXES) {
967
+ for (const [suffix, syntax] of Object.entries(tables.packSuffixes)) {
928
968
  if (lower.endsWith(suffix)) return syntax
929
969
  }
930
- for (const [suffix, name] of PACK_UNSUPPORTED_SUFFIXES) {
970
+ for (const [suffix, name] of Object.entries(tables.packUnsupportedSuffixes)) {
931
971
  if (lower.endsWith(suffix)) {
932
972
  throw new UsageError(
933
973
  `pack does not read ${name}. The packer's streaming fold reads ` +
934
- `${PACK_SYNTAXES.join(', ')} only. Convert the file first, for ` +
974
+ `${syntaxes.join(', ')} only. Convert the file first, for ` +
935
975
  "example with: factoidal parse FILE --out nquads")
936
976
  }
937
977
  }
978
+ let head = ''
979
+ try {
980
+ head = readHead(input, 65536)
981
+ } catch (_error) {
982
+ head = ''
983
+ }
984
+ if (head !== '') {
985
+ const tag = engine.call('detectFormat', [head]).format
986
+ const syntax = tag.replace(/12$/, '')
987
+ if (syntaxes.indexOf(syntax) >= 0) return syntax
988
+ }
938
989
  throw new UsageError(
939
- `cannot tell the syntax of ${input} from its name; give --syntax ` +
940
- `(${PACK_SYNTAXES.join(', ')})`)
990
+ `cannot tell the syntax of ${input} from its name or its content; give --syntax ` +
991
+ `(${syntaxes.join(', ')})`)
941
992
  }
942
993
 
994
+
943
995
  /**
944
996
  * The base IRI relative IRIs in the source resolve against.
945
997
  *
@@ -970,7 +1022,7 @@ function packLayout (options) {
970
1022
  async function commandPack (positional, options) {
971
1023
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
972
1024
  const [input, output] = positional
973
- const syntax = packSyntax(input, options)
1025
+ const syntax = packSyntax(await loadEngine(), input, options)
974
1026
  const layout = packLayout(options)
975
1027
  // The pack fold needs a bigger call stack than either runtime gives by
976
1028
  // default (https://github.com/danbri/factoidal/issues/649). Under Node
@@ -1248,6 +1300,25 @@ async function commandServe (positional, options) {
1248
1300
  async function commandLwsServe (positional, options) {
1249
1301
  if (positional.length !== 1) throw new UsageError('lws-serve needs one DIR')
1250
1302
  const quiet = options.quiet === true
1303
+ const insecure = options['insecure-no-auth'] === true
1304
+ const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1305
+ const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1306
+ if (!insecure && issuerOption === null && authFile === null) {
1307
+ throw new UsageError('lws-serve needs --issuer IRI or --auth-config FILE, ' +
1308
+ 'or --insecure-no-auth for development')
1309
+ }
1310
+ if (insecure && (issuerOption !== null || authFile !== null)) {
1311
+ throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1312
+ }
1313
+ let auth
1314
+ if (authFile !== null) {
1315
+ auth = readJsonFile(authFile)
1316
+ } else if (issuerOption !== null) {
1317
+ auth = { issuers: issuerOption.split(',').filter((v) => v !== '').map((issuer) => ({ issuer })) }
1318
+ }
1319
+ if (auth !== undefined && typeof options.audience === 'string') {
1320
+ auth = { ...auth, audience: options.audience }
1321
+ }
1251
1322
  const { listen, LwsHostError } = await import('../lws/server.mjs')
1252
1323
  let running
1253
1324
  try {
@@ -1255,7 +1326,13 @@ async function commandLwsServe (positional, options) {
1255
1326
  port: servePort(options, 3000),
1256
1327
  host: serveHost(options),
1257
1328
  ...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
1258
- realClock: true
1329
+ ...(insecure ? { insecureNoAuth: true } : { auth }),
1330
+ ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1331
+ ...(typeof options['root-acl'] === 'string'
1332
+ ? { rootAcl: new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options['root-acl'])) }
1333
+ : {}),
1334
+ realClock: true,
1335
+ dir: positional[0]
1259
1336
  })
1260
1337
  } catch (error) {
1261
1338
  if (error instanceof LwsHostError && error.unknownOp) {
@@ -1263,7 +1340,10 @@ async function commandLwsServe (positional, options) {
1263
1340
  }
1264
1341
  throw error
1265
1342
  }
1266
- if (!quiet) err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1343
+ if (!quiet) {
1344
+ err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1345
+ if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
1346
+ }
1267
1347
  out(running.origin)
1268
1348
  return untilInterrupted(running, quiet)
1269
1349
  }
@@ -1271,6 +1351,24 @@ async function commandLwsServe (positional, options) {
1271
1351
  async function commandSolidServe (positional, options) {
1272
1352
  if (positional.length !== 1) throw new UsageError('solid-serve needs one DIR')
1273
1353
  const quiet = options.quiet === true
1354
+ // The same rule as lws-serve: no start without a trusted issuer unless
1355
+ // the development mode is asked for by name.
1356
+ const insecure = options['insecure-no-auth'] === true
1357
+ const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1358
+ const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1359
+ if (!insecure && issuerOption === null && authFile === null) {
1360
+ throw new UsageError('solid-serve needs --issuer IRI or --auth-config FILE, ' +
1361
+ 'or --insecure-no-auth for development')
1362
+ }
1363
+ if (insecure && (issuerOption !== null || authFile !== null)) {
1364
+ throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1365
+ }
1366
+ let auth
1367
+ if (authFile !== null) {
1368
+ auth = readJsonFile(authFile)
1369
+ } else if (issuerOption !== null) {
1370
+ auth = { issuers: issuerOption.split(',').filter((v) => v !== '').map((issuer) => ({ issuer })) }
1371
+ }
1274
1372
  const { listen, SolidServerHostError } = await import('../solid/server/index.mjs')
1275
1373
  let running
1276
1374
  try {
@@ -1278,8 +1376,10 @@ async function commandSolidServe (positional, options) {
1278
1376
  port: servePort(options, 3000),
1279
1377
  host: serveHost(options),
1280
1378
  ...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
1379
+ ...(insecure ? { insecureNoAuth: true } : { auth }),
1281
1380
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1282
- realClock: true
1381
+ realClock: true,
1382
+ dir: positional[0]
1283
1383
  })
1284
1384
  } catch (error) {
1285
1385
  if (error instanceof SolidServerHostError && error.unknownOp) {
@@ -1287,7 +1387,10 @@ async function commandSolidServe (positional, options) {
1287
1387
  }
1288
1388
  throw error
1289
1389
  }
1290
- if (!quiet) err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
1390
+ if (!quiet) {
1391
+ err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
1392
+ if (insecure) err('solid-serve: WARNING: --insecure-no-auth: every request is served without authentication')
1393
+ }
1291
1394
  out(running.origin)
1292
1395
  return untilInterrupted(running, quiet)
1293
1396
  }
package/browser.d.ts CHANGED
@@ -19,6 +19,10 @@ export {
19
19
  canonicalize,
20
20
  graphs,
21
21
  canonicalHash,
22
+ isomorphic,
23
+ fromQuads,
24
+ ready,
25
+ engineTables,
22
26
  openDataset,
23
27
  closure,
24
28
  formats,
package/browser.js CHANGED
@@ -67,6 +67,10 @@ export const serialize = api.serialize;
67
67
  export const canonicalize = api.canonicalize;
68
68
  export const graphs = api.graphs;
69
69
  export const canonicalHash = api.canonicalHash;
70
+ export const isomorphic = api.isomorphic;
71
+ export const fromQuads = api.fromQuads;
72
+ export const ready = api.ready;
73
+ export const engineTables = api.engineTables;
70
74
  export const openDataset = api.openDataset;
71
75
  export const closure = api.closure;
72
76
  export const formats = api.formats;
package/fn.d.ts CHANGED
@@ -306,7 +306,7 @@ export function hash(ds: FnDataset): Promise<string>;
306
306
  export function equals(a: FnDataset, b: FnDataset): Promise<boolean>;
307
307
 
308
308
  /** Enumerate named graphs (default graph excluded), first-seen order. */
309
- export function graphs(ds: FnDataset): Array<[iri: string, graph: FnDataset]>;
309
+ export function graphs(ds: FnDataset): Array<[name: NamedNode | BlankNode, graph: FnDataset]>;
310
310
 
311
311
  /** A minimal mutable box holding one dataflow input value. */
312
312
  export interface Cell<T> {