@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/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,184 @@ 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] [--page-size N]
336
+ [--paginate-always] [--moves all|data|off]
337
+ [--no-linkset-put] [--unstable-search-results] [--merkle]
338
+ [--controller-inbox URL] [--delivery-limit N]
339
+ [--delivery-window SECONDS] [--redirects-max N]
340
+ [--storage snapshot|files]
348
341
 
349
342
  Starts a Node HTTP server that answers every request through the engine's
350
343
  lwsStep operation.
351
344
 
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).
345
+ DIR names the storage directory. The storage is kept in the engine
346
+ handle and written to DIR/lws-state.json after every request that
347
+ changed it (temporary file, fsync, rename), and read back on the next
348
+ start. The engine decides the snapshot format and refuses a snapshot
349
+ path with a dot segment or other traversal form
350
+ (formal/lean4/L4Factoidal/LWS/Snapshot.lean).
351
+
352
+ With --storage files (off by default) a write costs the same whatever the
353
+ size of the storage: DIR holds one file per resource body (DIR/bodies/),
354
+ a journal of metadata changes (DIR/lws-journal) and a metadata file
355
+ (DIR/lws-files.json) that the journal is folded into from time to time.
356
+ A journal record is valid only when it is complete and its SHA-256
357
+ matches; a torn last record is ignored at start. An existing
358
+ DIR/lws-state.json is migrated at the first start and renamed to
359
+ lws-state.json.migrated. The engine decides every file name and every
360
+ byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean); the storage is still
361
+ held in memory while the server runs
362
+ (docs/designissues/2026-10-22-lws-storage-on-disk.md).
363
+
364
+ Encryption at rest (off by default): when the environment variable
365
+ FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
366
+ base64url), the snapshot is sealed with ChaCha20-Poly1305 (HACL*) under a
367
+ new random nonce for every write, and authenticated when the storage is
368
+ opened. A wrong key, a changed or truncated file, a clear-text file with the
369
+ variable set, or a sealed file without it stops the start with a message.
370
+ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
355
371
 
356
372
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
357
373
  --host ADDRESS the address to bind (default 127.0.0.1)
358
374
  --base IRI the absolute IRI the storage root maps to
359
375
  (default: the bound origin)
376
+ --page-size N the engine's container page size (config member pageSize;
377
+ default 100, 0 turns pagination off)
378
+ --paginate-always
379
+ present every container listing as paginated, also one
380
+ that fits on one page (config member paginateAlways;
381
+ default off): rel="first" and rel="last", no rel="next"
382
+ --moves all|data|off
383
+ which resources a client may move by changing their up
384
+ link (config member moves; default all): data resources
385
+ and containers, data resources only (the MoveResource
386
+ capability's appliesTo is [DataResource]), or none (no
387
+ MoveResource capability; a change of up answers 409)
388
+ --no-linkset-put
389
+ a linkset resource does not accept PUT (config member
390
+ linksetPut false): its Allow leaves PUT out and a PUT
391
+ answers 405 Method Not Allowed; PATCH still applies
392
+ --unstable-search-results
393
+ ANTICIPATORY AND UNSTABLE, off by default: a QUERY answer
394
+ of the type search names a results resource with
395
+ Content-Location (RFC 10008 section 2.3). lws10-index
396
+ defines no such resource; this is likely to change.
397
+ https://github.com/danbri/factoidal/issues/922
398
+ --merkle off by default: keep a SHA-256 digest of every resource
399
+ and a Merkle root of every container, and show them: an
400
+ RFC 9530 Repr-Digest field on GET of a resource, a
401
+ Factoidal-Merkle-Root field and listing member on a
402
+ container, and the storage root's digest in the storage
403
+ description (roots to the storage controller only under
404
+ authorization). Entity tags do not change. The hashes are
405
+ rebuilt when the storage is opened, not stored.
406
+ https://github.com/danbri/factoidal/issues/919
407
+ --controller-inbox URL
408
+ the storage controller's inbox (config member
409
+ controllerInbox; default none): each new access request
410
+ is announced there with a Create notification. The LWS
411
+ draft names no such inbox; this is a server choice.
412
+ https://github.com/danbri/factoidal/issues/920
413
+ --delivery-limit N
414
+ the most webhook deliveries one subscription gets in one
415
+ window (config member deliveryLimit; default 10, 0 turns
416
+ the limit off); the changes over it wait and go in one
417
+ batched notification when the window ends
418
+ https://github.com/danbri/factoidal/issues/915
419
+ --delivery-window SECONDS
420
+ the length of that window (config member deliveryWindow;
421
+ default 60)
422
+ --redirects-max N
423
+ the most previous identifiers of moved resources kept to
424
+ answer 301 Moved Permanently (config member redirectsMax;
425
+ default 256)
426
+ https://github.com/danbri/factoidal/issues/914
427
+ --storage snapshot|files
428
+ how DIR keeps the storage (config member storage;
429
+ default the environment variable FACTOIDAL_LWS_STORAGE,
430
+ else snapshot): one snapshot file written whole after
431
+ each change, or one file per body with a journal, so a
432
+ write costs the same whatever the storage size
433
+ https://github.com/danbri/factoidal/issues/918
434
+
435
+ Authorization (LWS editor's draft, section Authorization; one of the three
436
+ is required):
437
+ --issuer IRI trust this authorization server; several may be
438
+ given separated by commas. Its metadata is read from
439
+ IRI/.well-known/lws-configuration and its keys from
440
+ the jwks_uri there, when a token first needs them.
441
+ --auth-config FILE the engine's "auth" configuration as JSON:
442
+ {"issuers":[{"issuer":IRI,"jwks":{...}?}],
443
+ "audience":IRI?, "leeway":SECONDS?,
444
+ "refreshInterval":SECONDS?}
445
+ --insecure-no-auth development only: no authorization at all; every
446
+ request is served. Never the default.
447
+ --owner WEBID the storage owner: a new storage gets a root ACL
448
+ that gives this agent Read, Write and Control
449
+ --root-acl FILE a Turtle root ACL for a new storage instead
450
+ --audience IRI the aud value tokens must carry (default: the
451
+ storage IRI, BASE.well-known/lws-storage, which
452
+ every lws#storage link names)
453
+
454
+ Requests carry an access token as "Authorization: Bearer TOKEN" (RFC 6750).
455
+ The engine verifies its JWS signature (ES256, RS256 or EdDSA, with the
456
+ vendored HACL* code), its iss, aud, exp, nbf and iat, and then decides
457
+ access with Web Access Control over the storage's ACL resources. A
458
+ request without a valid token answers 401 with
459
+ WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728), and the
460
+ metadata document is served at /.well-known/oauth-protected-resource.
360
461
 
361
462
  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
463
+ Last-Modified, the PATCH blank-node refusal and every authorization
464
+ decision are answered by formal/lean4/L4Factoidal/LWS/. A WebAssembly module built before those
364
465
  operations landed makes this command exit 3 and say so.
365
466
 
366
467
  Specification: https://w3c.github.io/lws-protocol/lws10-core/`,
367
468
 
368
469
  'solid-serve': `factoidal solid-serve - serve the Solid Protocol over HTTP
369
470
 
370
- usage: factoidal solid-serve DIR [--port N] [--host ADDRESS]
471
+ usage: factoidal solid-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
472
+ [--owner WEBID] [--port N] [--host ADDRESS] [--base IRI]
371
473
 
372
474
  Starts a Node HTTP server that answers every request through the engine's
373
475
  solidStep operation.
374
476
 
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).
477
+ DIR names the storage directory. The storage is kept in the engine
478
+ handle and written to DIR/lws-state.json after every request that
479
+ changed it (temporary file, fsync, rename), and read back on the next
480
+ start. The engine decides the snapshot format and refuses a snapshot
481
+ path with a dot segment or other traversal form
482
+ (formal/lean4/L4Factoidal/LWS/Snapshot.lean).
483
+
484
+ Encryption at rest (off by default): when the environment variable
485
+ FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
486
+ base64url), the snapshot is sealed with ChaCha20-Poly1305 (HACL*) under a
487
+ new random nonce for every write, and authenticated when the storage is
488
+ opened. A wrong key, a changed or truncated file, a clear-text file with the
489
+ variable set, or a sealed file without it stops the start with a message.
490
+ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
378
491
 
379
492
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
380
493
  --host ADDRESS the address to bind (default 127.0.0.1)
381
494
  --base IRI the absolute IRI the storage root maps to
382
495
  (default: the bound origin)
383
- --owner WEBID the WebID the storage advertises as its owner
496
+ --owner WEBID the WebID the storage advertises as its owner; under
497
+ authentication it gets acl:Read, acl:Write and
498
+ acl:Control on the root (Web Access Control §3.2)
499
+ --issuer IRI trust this Solid-OIDC issuer (comma separated for more).
500
+ The engine verifies each DPoP-bound access token
501
+ (Solid-OIDC §8, §9.3; RFC 9449): signature under the
502
+ issuer's keys found by OpenID Connect Discovery, aud
503
+ "solid", webid, exp, cnf.jkt bound to the DPoP key,
504
+ htm, htu, iat and a jti replay cache, and that the
505
+ WebID profile lists the issuer (solid:oidcIssuer).
506
+ --auth-config FILE the same as JSON: {"issuers":[{"issuer":IRI,
507
+ "jwks":{...}?}], "leeway"?, "iatWindow"?,
508
+ "refreshInterval"?, "checkProfileIssuer"?}
509
+ --insecure-no-auth development only: no authentication; Web Access
510
+ Control is not enforced
384
511
 
385
512
  The server makes no protocol decision. Storage discovery, containment
386
513
  triples, slash semantics, the Allow and Accept-* headers, the PUT/POST/
@@ -469,8 +596,10 @@ const VALUE_OPTIONS = {
469
596
  update: new Set(['update', 'file']),
470
597
  compact: new Set([]),
471
598
  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']),
599
+ 'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
600
+ 'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
601
+ 'redirects-max', 'storage']),
602
+ 'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
474
603
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret'])
475
604
  }
476
605
 
@@ -599,31 +728,28 @@ async function commandVersion (options) {
599
728
  /**
600
729
  * One graph name as the manifest reports it. `{"kind":"default"}` is the
601
730
  * default graph; the operation marks it that way so a host never has to
602
- * recognise a reserved IRI.
731
+ * recognise a reserved IRI. The engine writes the N-Triples form of a name
732
+ * (op termsToNTriples).
603
733
  */
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)
734
+ function graphLabels (engine, graphs) {
735
+ const terms = graphs.filter((g) => g.kind !== 'default')
736
+ .map((g) => ({ type: g.kind === 'iri' ? 'uri' : 'bnode', value: g.value }))
737
+ const tokens = terms.length === 0 ? [] : engine.call('termsToNTriples', [JSON.stringify(terms)]).tokens
738
+ let k = 0
739
+ return graphs.map((g) => (g.kind === 'default' ? 'default' : tokens[k++]))
609
740
  }
610
741
 
611
742
  /**
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.
743
+ * The SPARQL Query Results JSON terms of a table, as display cells: the
744
+ * engine writes each term's canonical N-Triples form (op termsToNTriples).
745
+ * `--format json` prints the engine's own document.
615
746
  */
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)
747
+ function cellsOfRows (engine, rows) {
748
+ const terms = []
749
+ for (const row of rows) for (const t of row) if (t !== undefined && t !== null) terms.push(t)
750
+ const tokens = terms.length === 0 ? [] : engine.call('termsToNTriples', [JSON.stringify(terms)]).tokens
751
+ let k = 0
752
+ return rows.map((row) => row.map((t) => (t === undefined || t === null ? '' : tokens[k++])))
627
753
  }
628
754
 
629
755
  function plural (count, noun) {
@@ -699,7 +825,7 @@ async function commandInspect (positional, options) {
699
825
  String(entry.rows),
700
826
  String(entry.bytes),
701
827
  entry.blockKind,
702
- entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
828
+ entry.graphs.length === 0 ? '-' : graphLabels(engine, entry.graphs).join(' '),
703
829
  entry.predicate
704
830
  ].concat(zoned
705
831
  ? [String((entry.blobRefs ?? []).length), zoneCell(entry.subjectZone)]
@@ -875,7 +1001,7 @@ function renderQueryResult (engine, result, format, limit, quiet) {
875
1001
  return EXIT_FAILURE
876
1002
  }
877
1003
  const shown = limit === null ? bindings : bindings.slice(0, limit)
878
- printTable(vars, shown.map((row) => vars.map((name) => cellOfTerm(row[name]))))
1004
+ printTable(vars, cellsOfRows(engine, shown.map((row) => vars.map((name) => row[name]))))
879
1005
  if (shown.length < bindings.length) {
880
1006
  err(`showing ${shown.length} of ${bindings.length} rows (--limit ${limit})`)
881
1007
  } else if (!quiet) {
@@ -914,32 +1040,51 @@ function renderQueryResult (engine, result, format, limit, quiet) {
914
1040
  return EXIT_FAILURE
915
1041
  }
916
1042
 
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) {
1043
+ /** The first `length` bytes of a file, as text, for content detection. */
1044
+ function readHead (path, length) {
1045
+ return new TextDecoder('utf-8', { fatal: false }).decode(readChunk(path, 0, length))
1046
+ }
1047
+
1048
+ /** The syntax tag for an input: --syntax when given, else the engine's
1049
+ * suffix table (op engineTables), else the engine's content detection
1050
+ * (op detectFormat) on the first 64 KiB of the file. */
1051
+ function packSyntax (engine, input, options) {
1052
+ const tables = engine.call('engineTables', []).tables
1053
+ const syntaxes = tables.packSyntaxes
921
1054
  if (typeof options.syntax === 'string') {
922
1055
  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(', ')}`)
1056
+ if (syntaxes.indexOf(syntax) >= 0) return syntax
1057
+ throw new UsageError(`--syntax ${options.syntax} is not one of ${syntaxes.join(', ')}`)
925
1058
  }
926
1059
  const lower = input.toLowerCase()
927
- for (const [suffix, syntax] of PACK_SUFFIXES) {
1060
+ for (const [suffix, syntax] of Object.entries(tables.packSuffixes)) {
928
1061
  if (lower.endsWith(suffix)) return syntax
929
1062
  }
930
- for (const [suffix, name] of PACK_UNSUPPORTED_SUFFIXES) {
1063
+ for (const [suffix, name] of Object.entries(tables.packUnsupportedSuffixes)) {
931
1064
  if (lower.endsWith(suffix)) {
932
1065
  throw new UsageError(
933
1066
  `pack does not read ${name}. The packer's streaming fold reads ` +
934
- `${PACK_SYNTAXES.join(', ')} only. Convert the file first, for ` +
1067
+ `${syntaxes.join(', ')} only. Convert the file first, for ` +
935
1068
  "example with: factoidal parse FILE --out nquads")
936
1069
  }
937
1070
  }
1071
+ let head = ''
1072
+ try {
1073
+ head = readHead(input, 65536)
1074
+ } catch (_error) {
1075
+ head = ''
1076
+ }
1077
+ if (head !== '') {
1078
+ const tag = engine.call('detectFormat', [head]).format
1079
+ const syntax = tag.replace(/12$/, '')
1080
+ if (syntaxes.indexOf(syntax) >= 0) return syntax
1081
+ }
938
1082
  throw new UsageError(
939
- `cannot tell the syntax of ${input} from its name; give --syntax ` +
940
- `(${PACK_SYNTAXES.join(', ')})`)
1083
+ `cannot tell the syntax of ${input} from its name or its content; give --syntax ` +
1084
+ `(${syntaxes.join(', ')})`)
941
1085
  }
942
1086
 
1087
+
943
1088
  /**
944
1089
  * The base IRI relative IRIs in the source resolve against.
945
1090
  *
@@ -970,7 +1115,7 @@ function packLayout (options) {
970
1115
  async function commandPack (positional, options) {
971
1116
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
972
1117
  const [input, output] = positional
973
- const syntax = packSyntax(input, options)
1118
+ const syntax = packSyntax(await loadEngine(), input, options)
974
1119
  const layout = packLayout(options)
975
1120
  // The pack fold needs a bigger call stack than either runtime gives by
976
1121
  // default (https://github.com/danbri/factoidal/issues/649). Under Node
@@ -1146,6 +1291,20 @@ function servePort (options, fallback) {
1146
1291
  return port
1147
1292
  }
1148
1293
 
1294
+ // --page-size N: a CLI number, passed unchanged as the engine config member
1295
+ // `pageSize`; what a page holds is decided in L4Factoidal/LWS/Pagination.lean.
1296
+ function pageSizeOption (value) {
1297
+ return natOption('page-size', value)
1298
+ }
1299
+
1300
+ function natOption (flag, value) {
1301
+ const n = Number(value)
1302
+ if (!Number.isInteger(n) || n < 0) {
1303
+ throw new UsageError(`--${flag} must be a non-negative integer, not "${value}"`)
1304
+ }
1305
+ return n
1306
+ }
1307
+
1149
1308
  function serveHost (options) {
1150
1309
  if (options.host === undefined) return '127.0.0.1'
1151
1310
  if (typeof options.host !== 'string' || options.host.length === 0) {
@@ -1248,6 +1407,25 @@ async function commandServe (positional, options) {
1248
1407
  async function commandLwsServe (positional, options) {
1249
1408
  if (positional.length !== 1) throw new UsageError('lws-serve needs one DIR')
1250
1409
  const quiet = options.quiet === true
1410
+ const insecure = options['insecure-no-auth'] === true
1411
+ const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1412
+ const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1413
+ if (!insecure && issuerOption === null && authFile === null) {
1414
+ throw new UsageError('lws-serve needs --issuer IRI or --auth-config FILE, ' +
1415
+ 'or --insecure-no-auth for development')
1416
+ }
1417
+ if (insecure && (issuerOption !== null || authFile !== null)) {
1418
+ throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1419
+ }
1420
+ let auth
1421
+ if (authFile !== null) {
1422
+ auth = readJsonFile(authFile)
1423
+ } else if (issuerOption !== null) {
1424
+ auth = { issuers: issuerOption.split(',').filter((v) => v !== '').map((issuer) => ({ issuer })) }
1425
+ }
1426
+ if (auth !== undefined && typeof options.audience === 'string') {
1427
+ auth = { ...auth, audience: options.audience }
1428
+ }
1251
1429
  const { listen, LwsHostError } = await import('../lws/server.mjs')
1252
1430
  let running
1253
1431
  try {
@@ -1255,15 +1433,45 @@ async function commandLwsServe (positional, options) {
1255
1433
  port: servePort(options, 3000),
1256
1434
  host: serveHost(options),
1257
1435
  ...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
1258
- realClock: true
1436
+ ...(options['page-size'] !== undefined ? { pageSize: pageSizeOption(options['page-size']) } : {}),
1437
+ ...(options['unstable-search-results'] === true ? { searchResults: true } : {}),
1438
+ ...(options.merkle === true ? { merkle: true } : {}),
1439
+ // Passed unread as config members; the engine decides what they mean
1440
+ // and refuses a value it does not know (Wasm/Ops/Lws.lean).
1441
+ ...(options['paginate-always'] === true ? { paginateAlways: true } : {}),
1442
+ ...(typeof options.moves === 'string' ? { moves: options.moves } : {}),
1443
+ ...(options['no-linkset-put'] === true ? { linksetPut: false } : {}),
1444
+ ...(typeof options['controller-inbox'] === 'string' ? { controllerInbox: options['controller-inbox'] } : {}),
1445
+ ...(options['delivery-limit'] !== undefined ? { deliveryLimit: natOption('delivery-limit', options['delivery-limit']) } : {}),
1446
+ ...(options['delivery-window'] !== undefined ? { deliveryWindow: natOption('delivery-window', options['delivery-window']) } : {}),
1447
+ ...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
1448
+ ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
1449
+ ...(insecure ? { insecureNoAuth: true } : { auth }),
1450
+ ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1451
+ ...(typeof options['root-acl'] === 'string'
1452
+ ? { rootAcl: new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options['root-acl'])) }
1453
+ : {}),
1454
+ realClock: true,
1455
+ dir: positional[0]
1259
1456
  })
1260
1457
  } catch (error) {
1261
1458
  if (error instanceof LwsHostError && error.unknownOp) {
1262
1459
  return notWired('lws-serve', error.message)
1263
1460
  }
1461
+ // The engine refused to open the storage (a bad configuration, or a
1462
+ // snapshot it could not read or authenticate, issue 921), or the state
1463
+ // file is not JSON: the message is the whole answer.
1464
+ if (error !== null && typeof error === 'object' &&
1465
+ (error.envelope !== undefined || error.stateFile === true)) {
1466
+ err(`factoidal lws-serve: ${error.envelope !== undefined ? error.envelope.error : error.message}`)
1467
+ return EXIT_FAILURE
1468
+ }
1264
1469
  throw error
1265
1470
  }
1266
- if (!quiet) err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1471
+ if (!quiet) {
1472
+ err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1473
+ if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
1474
+ }
1267
1475
  out(running.origin)
1268
1476
  return untilInterrupted(running, quiet)
1269
1477
  }
@@ -1271,6 +1479,24 @@ async function commandLwsServe (positional, options) {
1271
1479
  async function commandSolidServe (positional, options) {
1272
1480
  if (positional.length !== 1) throw new UsageError('solid-serve needs one DIR')
1273
1481
  const quiet = options.quiet === true
1482
+ // The same rule as lws-serve: no start without a trusted issuer unless
1483
+ // the development mode is asked for by name.
1484
+ const insecure = options['insecure-no-auth'] === true
1485
+ const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1486
+ const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1487
+ if (!insecure && issuerOption === null && authFile === null) {
1488
+ throw new UsageError('solid-serve needs --issuer IRI or --auth-config FILE, ' +
1489
+ 'or --insecure-no-auth for development')
1490
+ }
1491
+ if (insecure && (issuerOption !== null || authFile !== null)) {
1492
+ throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1493
+ }
1494
+ let auth
1495
+ if (authFile !== null) {
1496
+ auth = readJsonFile(authFile)
1497
+ } else if (issuerOption !== null) {
1498
+ auth = { issuers: issuerOption.split(',').filter((v) => v !== '').map((issuer) => ({ issuer })) }
1499
+ }
1274
1500
  const { listen, SolidServerHostError } = await import('../solid/server/index.mjs')
1275
1501
  let running
1276
1502
  try {
@@ -1278,16 +1504,29 @@ async function commandSolidServe (positional, options) {
1278
1504
  port: servePort(options, 3000),
1279
1505
  host: serveHost(options),
1280
1506
  ...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
1507
+ ...(insecure ? { insecureNoAuth: true } : { auth }),
1281
1508
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1282
- realClock: true
1509
+ realClock: true,
1510
+ dir: positional[0]
1283
1511
  })
1284
1512
  } catch (error) {
1285
1513
  if (error instanceof SolidServerHostError && error.unknownOp) {
1286
1514
  return notWired('solid-serve', error.message)
1287
1515
  }
1516
+ // The engine refused to open the storage (a bad configuration, or a
1517
+ // snapshot it could not read or authenticate, issue 921), or the state
1518
+ // file is not JSON: the message is the whole answer.
1519
+ if (error !== null && typeof error === 'object' &&
1520
+ (error.envelope !== undefined || error.stateFile === true)) {
1521
+ err(`factoidal solid-serve: ${error.envelope !== undefined ? error.envelope.error : error.message}`)
1522
+ return EXIT_FAILURE
1523
+ }
1288
1524
  throw error
1289
1525
  }
1290
- if (!quiet) err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
1526
+ if (!quiet) {
1527
+ err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
1528
+ if (insecure) err('solid-serve: WARNING: --insecure-no-auth: every request is served without authentication')
1529
+ }
1291
1530
  out(running.origin)
1292
1531
  return untilInterrupted(running, quiet)
1293
1532
  }
package/browser.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // system, so a path string rejects.
6
6
 
7
7
  export type {
8
+ FactoidalErrorCode,
9
+ FactoidalErrorPosition,
8
10
  NamedNode, BlankNode, Literal, Variable, DefaultGraph, Quad, Term, DataFactory,
9
11
  DataFormat, EntailRegime, Bindings, DataInput, QueryInput, ParseOptions,
10
12
  QueryOptions, SerializeOptions, StoreReader, OpenStoreOptions,
@@ -19,6 +21,10 @@ export {
19
21
  canonicalize,
20
22
  graphs,
21
23
  canonicalHash,
24
+ isomorphic,
25
+ fromQuads,
26
+ ready,
27
+ engineTables,
22
28
  openDataset,
23
29
  closure,
24
30
  formats,
@@ -85,9 +91,8 @@ export {
85
91
  didKeyResolve,
86
92
  capabilities,
87
93
  Dataset,
88
- DatasetHandle,
89
94
  Store,
90
- ParseError,
95
+ FactoidalError,
91
96
  dataFactory,
92
97
  version,
93
98
  engine,
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;
@@ -149,9 +153,8 @@ export const didKeyResolve = api.didKeyResolve;
149
153
  // Types, probes and metadata
150
154
  export const capabilities = api.capabilities;
151
155
  export const Dataset = api.Dataset;
152
- export const DatasetHandle = api.DatasetHandle;
153
156
  export const Store = api.Store;
154
- export const ParseError = api.ParseError;
157
+ export const FactoidalError = api.FactoidalError;
155
158
  export const dataFactory = api.dataFactory;
156
159
  export const version = api.version;
157
160
  export const engine = api.engine;
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> {