@factoidal/core 0.3.0 → 0.4.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/NOTICE +31 -0
  3. package/README.md +35 -1
  4. package/bin/factoidal.mjs +250 -14
  5. package/bin/pack-host.mjs +341 -0
  6. package/bin/pack-worker.mjs +31 -0
  7. package/bin/pack.mjs +174 -0
  8. package/bin/store.mjs +32 -0
  9. package/l4-assets/l4factoidal.js +61 -1
  10. package/l4-assets/l4factoidal.mjs +1 -1
  11. package/l4-assets/l4factoidal.wasm +0 -0
  12. package/l4-assets/version.json +4 -4
  13. package/package.json +10 -2
  14. package/sample-store/CURRENT +1 -0
  15. package/sample-store/gen-1/manifest.sbm2 +0 -0
  16. package/sample-store/gen-1/manifest.tsv +14 -0
  17. package/sample-store/gen-1/predicate-0.ibk3 +0 -0
  18. package/sample-store/gen-1/predicate-0.ibk3.merkle +1 -0
  19. package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
  20. package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +1 -0
  21. package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
  22. package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +1 -0
  23. package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
  24. package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
  25. package/sample-store/gen-1/predicate-1.ibk3 +0 -0
  26. package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
  27. package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
  28. package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
  29. package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
  30. package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +1 -0
  31. package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
  32. package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +1 -0
  33. package/sample-store/gen-1/predicate-10.ibk3 +0 -0
  34. package/sample-store/gen-1/predicate-10.ibk3.merkle +1 -0
  35. package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
  36. package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
  37. package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
  38. package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +1 -0
  39. package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
  40. package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +1 -0
  41. package/sample-store/gen-1/predicate-11.ibk3 +0 -0
  42. package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
  43. package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
  44. package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +1 -0
  45. package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
  46. package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +1 -0
  47. package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
  48. package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +1 -0
  49. package/sample-store/gen-1/predicate-12.ibk3 +0 -0
  50. package/sample-store/gen-1/predicate-12.ibk3.merkle +1 -0
  51. package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
  52. package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +1 -0
  53. package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
  54. package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +1 -0
  55. package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
  56. package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +1 -0
  57. package/sample-store/gen-1/predicate-2.ibk3 +0 -0
  58. package/sample-store/gen-1/predicate-2.ibk3.merkle +1 -0
  59. package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
  60. package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +1 -0
  61. package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
  62. package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
  63. package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
  64. package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
  65. package/sample-store/gen-1/predicate-3.ibk3 +0 -0
  66. package/sample-store/gen-1/predicate-3.ibk3.merkle +1 -0
  67. package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
  68. package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +1 -0
  69. package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
  70. package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +1 -0
  71. package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
  72. package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +1 -0
  73. package/sample-store/gen-1/predicate-4.ibk3 +0 -0
  74. package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
  75. package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
  76. package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +1 -0
  77. package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
  78. package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +1 -0
  79. package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
  80. package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +2 -0
  81. package/sample-store/gen-1/predicate-5.ibk3 +0 -0
  82. package/sample-store/gen-1/predicate-5.ibk3.merkle +1 -0
  83. package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
  84. package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +1 -0
  85. package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
  86. package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +1 -0
  87. package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
  88. package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +1 -0
  89. package/sample-store/gen-1/predicate-6.ibk3 +0 -0
  90. package/sample-store/gen-1/predicate-6.ibk3.merkle +1 -0
  91. package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
  92. package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +1 -0
  93. package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
  94. package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +1 -0
  95. package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
  96. package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
  97. package/sample-store/gen-1/predicate-7.ibk3 +0 -0
  98. package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
  99. package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
  100. package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +2 -0
  101. package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
  102. package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +1 -0
  103. package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
  104. package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +1 -0
  105. package/sample-store/gen-1/predicate-8.ibk3 +0 -0
  106. package/sample-store/gen-1/predicate-8.ibk3.merkle +1 -0
  107. package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
  108. package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +1 -0
  109. package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
  110. package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +1 -0
  111. package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
  112. package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +1 -0
  113. package/sample-store/gen-1/predicate-9.ibk3 +0 -0
  114. package/sample-store/gen-1/predicate-9.ibk3.merkle +1 -0
  115. package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
  116. package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +1 -0
  117. package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
  118. package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +3 -0
  119. package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
  120. package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +2 -0
  121. package/sample-store.d.ts +16 -0
  122. package/sample-store.mjs +37 -0
  123. package/store-host/deno.mjs +63 -0
  124. package/store-host/index.mjs +39 -0
  125. package/store-host/node.mjs +62 -1
  126. package/version.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-04
4
+
5
+ The package builds a store of its own. `pack` and `activate` join
6
+ `inspect` and `query`, so `npm install @factoidal/core` gives a complete
7
+ RDF store — import, activate, query — with no native binary on the
8
+ machine. https://github.com/danbri/factoidal/issues/641
9
+
10
+ - **`factoidal pack INPUT OUTPUT`** builds one immutable Shardborough
11
+ generation from a Turtle, TriG, N-Triples or N-Quads file, streaming
12
+ it in 65,536-byte chunks through the Lean engine's WebAssembly module.
13
+ The generation is BYTE-IDENTICAL to what the native
14
+ `l4block-shard-pack` writes: `diff -r` is empty for the `ibk3` triple
15
+ layout and for the `ibk4` quad layout, on inputs up to 888,949
16
+ triples. `--layout ibk3|ibk4`, `--syntax`, `--base`.
17
+ - **`factoidal activate STORE GENERATION`** verifies every artifact
18
+ against the SHA-256 the manifest commits, and every cross-artifact
19
+ relation, then replaces `CURRENT` atomically. A generation that fails
20
+ verification never becomes current.
21
+ - **`factoidal sample-store`** prints the path of a store this package
22
+ now carries, so a fresh install answers a SPARQL query with nothing
23
+ else to download: 4,434 triples in 13 predicate blocks, five IPTC
24
+ NewsCodes vocabularies under CC BY 4.0. Also exported as
25
+ `@factoidal/core/sample-store`. See NOTICE.
26
+ - The engine gained a raw byte path out of the module
27
+ (`l4_call_blob_io`), so artifact bytes cross the boundary with no
28
+ encoding. Hexadecimal doubled them; base64 was refused. Measured on
29
+ the read path: 242,416 bytes and 96 ms hexadecimal against 4,893 bytes
30
+ and 70 ms raw.
31
+ - The pack hashes with HACL* SHA-256, the same primitive the native
32
+ packer uses. It hashed with the pure Lean SHA-256 in development,
33
+ which cost 3.3 times: 104 s against 31 s on 888,949 triples. Now at
34
+ parity, 29.24 s against the native packer's 29.38 s.
35
+ - `packBegin` takes a base IRI, defaulted by the command to
36
+ `file://<input>` so relative IRIs resolve exactly as the native packer
37
+ resolves them. `--base ''` asks for no base, which makes a relative
38
+ IRI a parse error rather than a silently different term.
39
+ - `pack` on a syntax the streaming fold cannot read now says so by name
40
+ rather than raising an unhandled error.
41
+ - `store-host` gained `readChunk` (a short read means end of file, not
42
+ an error), `writeNew` (create and fsync, refusing an existing file so
43
+ a name collision in a generation is reported) and `makeDirectory`, on
44
+ Node and on Deno both.
45
+ - `factoidal activate` runs on the raised stack too. Verification decodes
46
+ the same blocks the pack encoded, so it recurses as deep; the worker was
47
+ given to `pack` alone at first, and a 112,742-row generation packed
48
+ successfully and then failed to activate with `Maximum call stack size
49
+ exceeded`, leaving a store that could be built and not opened. Found by
50
+ installing the tarball and running the command, which is why that step
51
+ is in the release procedure. `tests/store-host/cli.mjs` now gates
52
+ pack-then-activate on both runtimes.
53
+ - `factoidal pack` no longer needs a runtime flag. The pack fold recurses
54
+ deeper than either runtime's default call stack allows, so an input
55
+ above roughly 0.5 MB ended with `Maximum call stack size exceeded`
56
+ (https://github.com/danbri/factoidal/issues/649). Under Node the pack
57
+ now runs on a `worker_threads` worker with a 64 MiB stack; under Deno
58
+ the command re-executes itself once with
59
+ `--v8-flags=--stack-size=65536`, which needs `--allow-run` and
60
+ `--allow-env` in addition to `--allow-read` and `--allow-write`.
61
+ `gene.ttl`, 17,363,312 bytes and 888,949 triples, packs on the default
62
+ stack of both runtimes, byte-identical to `l4block-shard-pack`.
63
+ `--no-worker` forces the in-process path, which still reports the
64
+ frame budget and the flag that raises it rather than crashing. A
65
+ browser tab has a fixed frame budget and no flag, so this does not
66
+ make an in-page packer possible.
67
+
68
+ Known limits, measured:
69
+
70
+ - `update` and `compact` still exit 3. The delta-log operations are
71
+ stage 4 of https://github.com/danbri/factoidal/issues/641.
72
+ - The `ibk4` quad layout reads the whole source rather than streaming,
73
+ because a quad block commits a graph-set summary over the entire
74
+ input. The wasm packer refuses a quad file above 128 MiB.
75
+ https://github.com/danbri/factoidal/issues/650
76
+ - A query plan is refused above 64 artifacts, 8,388,608 blob bytes or
77
+ 100,000 rows. https://github.com/danbri/factoidal/issues/648
78
+ - Packing in a browser tab is limited to about 7,800 distinct terms in
79
+ one block, whatever the file size, and no host flag raises it.
80
+ https://github.com/danbri/factoidal/issues/647
81
+
3
82
  ## 0.3.0 — 2026-09-03
4
83
 
5
84
  - The `factoidal` command answers SPARQL against a persisted
package/NOTICE ADDED
@@ -0,0 +1,31 @@
1
+ @factoidal/core — third-party content notices
2
+ =============================================
3
+
4
+ The code in this package is Apache-2.0; see LICENSE.
5
+
6
+ sample-store/
7
+ -------------
8
+
9
+ The bundled sample Shardborough store holds five IPTC NewsCodes
10
+ vocabularies, converted from the publisher's RDF/XML and packed into
11
+ IBK3 predicate blocks:
12
+
13
+ spamfstat, videoqualifier, subjectqualifier, videocodec, spct
14
+
15
+ Publisher: International Press Telecommunications Council
16
+ https://iptc.org/
17
+ Licence: Creative Commons Attribution 4.0 International (CC BY 4.0)
18
+ https://creativecommons.org/licenses/by/4.0/
19
+ Obtained through: https://github.com/danbri/skosdex third_party/skos
20
+
21
+ The IRIs, labels and structure are the IPTC's. The block layout, the
22
+ dictionary, the indexes and the manifest are this package's encoding of
23
+ them. Redistribution of the vocabulary content stays under CC BY 4.0.
24
+
25
+ hacl-wasm/
26
+ ----------
27
+
28
+ HACL* verified cryptographic primitives, from
29
+ https://github.com/hacl-star/hacl-star, Apache-2.0 / MIT. See the
30
+ provenance recorded in the Factoidal repository under
31
+ skills/node-crypto-haclstar-vc-wasm-build/.
package/README.md CHANGED
@@ -380,7 +380,41 @@ SHA-256, evaluating the SPARQL).
380
380
  > This command is not the native F\* `factoidal` binary that the API
381
381
  > table below refers to. That one is `bin/<platform>/factoidal` in the
382
382
  > repository and takes subcommands such as `shex` and `compact`. This
383
- > one takes `version`, `inspect` and `query`.
383
+ > one takes `version`, `sample-store`, `inspect` and `query`.
384
+
385
+ ### First query, with nothing else to download
386
+
387
+ The package carries an activated store, so a fresh install answers a
388
+ SPARQL query at once:
389
+
390
+ ```console
391
+ $ npm install @factoidal/core
392
+ $ npx factoidal query "$(npx factoidal sample-store)" \
393
+ 'SELECT ?c ?l
394
+ WHERE { ?c <http://www.w3.org/2004/02/skos/core#inScheme>
395
+ <http://cv.iptc.org/newscodes/videocodec/> ;
396
+ <http://www.w3.org/2004/02/skos/core#prefLabel> ?l .
397
+ FILTER(langMatches(lang(?l), "en")) }
398
+ LIMIT 4'
399
+ c l
400
+ <http://cv.iptc.org/newscodes/videocodec/c001> "Analogue Black and White"@en-gb
401
+ <http://cv.iptc.org/newscodes/videocodec/c002> "PAL"@en-gb
402
+ <http://cv.iptc.org/newscodes/videocodec/c003> "NTSC"@en-gb
403
+ <http://cv.iptc.org/newscodes/videocodec/c004> "SECAM"@en-gb
404
+ ```
405
+
406
+ `factoidal sample-store` prints the path; `--json` adds what was
407
+ recorded when the store was packed. From JavaScript:
408
+
409
+ ```js
410
+ import { sampleStorePath, sampleStoreFacts } from '@factoidal/core/sample-store'
411
+ ```
412
+
413
+ The store holds 4,434 triples in 13 predicate blocks: five IPTC
414
+ NewsCodes vocabularies, published by the IPTC under CC BY 4.0 and taken
415
+ from [danbri/skosdex](https://github.com/danbri/skosdex). See `NOTICE`.
416
+
417
+ ### Any other store
384
418
 
385
419
  ```console
386
420
  $ factoidal inspect ./mystore
package/bin/factoidal.mjs CHANGED
@@ -20,13 +20,17 @@
20
20
  // `--format turtle` all print documents the engine produced.
21
21
 
22
22
  import {
23
- StoreHostError, listGeneration, readWhole, runtime
23
+ StoreHostError, atomicReplace, listGeneration, makeDirectory, readWhole,
24
+ runtime
24
25
  } from '../store-host/index.mjs'
25
26
  import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
26
27
  import { loadEngine } from './engine.mjs'
28
+ import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
29
+ import { PackError, packSupported, verifyGeneration } from './pack.mjs'
30
+ import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
27
31
  import {
28
- StoreOperationError, inspectManifest, openStore, planQuery, queryStore,
29
- turtleOfNQuads
32
+ STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
33
+ queryStore, stackLimitAdvice, turtleOfNQuads
30
34
  } from './store.mjs'
31
35
 
32
36
  const EXIT_OK = 0
@@ -34,6 +38,41 @@ const EXIT_FAILURE = 1
34
38
  const EXIT_USAGE = 2
35
39
  const EXIT_NOT_WIRED = 3
36
40
 
41
+ // Progress is reported about every 16 MiB; the packer feeds 65,536 bytes
42
+ // a time, so this is the window that catches one feed per report.
43
+ const FEED_PROGRESS = 65536
44
+
45
+ const PACK_LAYOUTS = ['ibk3', 'ibk4']
46
+ const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
47
+ const PACK_SUFFIXES = [
48
+ ['.ttl', 'turtle'], ['.turtle', 'turtle'],
49
+ ['.trig', 'trig'],
50
+ ['.nq', 'nquads'], ['.nquads', 'nquads'],
51
+ ['.nt', 'ntriples'], ['.ntriples', 'ntriples']
52
+ ]
53
+
54
+ // Suffixes the engine parses elsewhere but the packer's streaming fold does
55
+ // not read. Naming them is better than "cannot tell the syntax from its
56
+ // name", which sends the reader looking for a --syntax value that does not
57
+ // exist.
58
+ const PACK_UNSUPPORTED_SUFFIXES = [
59
+ ['.rdf', 'RDF/XML'], ['.owl', 'RDF/XML'], ['.xml', 'RDF/XML'],
60
+ ['.jsonld', 'JSON-LD'], ['.json', 'JSON-LD'], ['.n3', 'Notation3'],
61
+ ['.csv', 'CSV'], ['.tsv', 'TSV'], ['.hdt', 'HDT']
62
+ ]
63
+
64
+ /** The parent of a path, and its last component. The `activate` hint
65
+ * printed after a pack needs both; neither is a format decision. */
66
+ function dirOf (path) {
67
+ const cut = path.replace(/[/\\]+$/, '').lastIndexOf('/')
68
+ return cut <= 0 ? '.' : path.slice(0, cut)
69
+ }
70
+ function nameOf (path) {
71
+ const trimmed = path.replace(/[/\\]+$/, '')
72
+ const cut = trimmed.lastIndexOf('/')
73
+ return cut < 0 ? trimmed : trimmed.slice(cut + 1)
74
+ }
75
+
37
76
  const ISSUE = 'https://github.com/danbri/factoidal/issues/641'
38
77
 
39
78
  const isDeno = typeof globalThis.Deno !== 'undefined'
@@ -60,6 +99,7 @@ usage: factoidal <command> [options]
60
99
 
61
100
  commands:
62
101
  version print the package and engine versions
102
+ sample-store print the path of the bundled sample store
63
103
  inspect STORE report what the activated manifest commits
64
104
  query STORE [QUERY] evaluate a SPARQL query against a store
65
105
  pack INPUT OUTPUT build one immutable generation from an RDF file
@@ -75,7 +115,10 @@ global options:
75
115
  exit codes:
76
116
  0 success 1 failure 2 usage error 3 not yet wired (${ISSUE})
77
117
 
78
- STORE is a collection root: the directory that holds CURRENT.`
118
+ STORE is a collection root: the directory that holds CURRENT. This package
119
+ carries one, so the first query needs no other download:
120
+
121
+ factoidal query "$(factoidal sample-store)" 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'`
79
122
 
80
123
  const COMMAND_USAGE = {
81
124
  version: `factoidal version - print the package and engine versions
@@ -85,6 +128,21 @@ usage: factoidal version [--json]
85
128
  Prints the npm package version, the Lean engine's WebAssembly digest as
86
129
  recorded by its build, and which host-I/O implementation is loaded.`,
87
130
 
131
+ 'sample-store': `factoidal sample-store - print the bundled store's path
132
+
133
+ usage: factoidal sample-store [--json]
134
+
135
+ Prints the collection root of the Shardborough store this package
136
+ carries, so a fresh install can query something at once:
137
+
138
+ factoidal inspect "$(factoidal sample-store)"
139
+ factoidal query "$(factoidal sample-store)" \\
140
+ 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
141
+
142
+ The store holds five IPTC NewsCodes vocabularies (CC BY 4.0; see NOTICE)
143
+ packed into IBK3 predicate blocks. --json adds what was recorded when it
144
+ was packed.`,
145
+
88
146
  inspect: `factoidal inspect - report what a store's manifest commits
89
147
 
90
148
  usage: factoidal inspect STORE [--json] [--generation NAME]
@@ -157,7 +215,14 @@ options:
157
215
  --layout LAYOUT ibk3 (triples, default) or ibk4 (quads)
158
216
  --syntax SYNTAX turtle, trig or nquads; default from the file extension
159
217
  --chunk-bytes N Merkle chunk size; default is the engine's
160
- --json emit one JSON object`,
218
+ --json emit one JSON object
219
+ --no-worker pack in this process instead of on a worker thread
220
+
221
+ The pack fold recurses deeper than either runtime's default call stack
222
+ allows, so it runs on a worker thread with a raised stack under Node, and
223
+ under Deno the command re-executes itself once with a raised V8 stack
224
+ (https://github.com/danbri/factoidal/issues/649). --no-worker turns both
225
+ off; a pack above about half a megabyte of input then overflows.`,
161
226
 
162
227
  activate: `factoidal activate - make one generation the activated generation
163
228
 
@@ -239,9 +304,10 @@ class UsageError extends Error {}
239
304
 
240
305
  const VALUE_OPTIONS = {
241
306
  version: new Set([]),
307
+ 'sample-store': new Set([]),
242
308
  inspect: new Set(['generation']),
243
309
  query: new Set(['query', 'file', 'format', 'limit', 'base', 'generation']),
244
- pack: new Set(['layout', 'syntax', 'chunk-bytes']),
310
+ pack: new Set(['layout', 'syntax', 'chunk-bytes', 'base']),
245
311
  activate: new Set([]),
246
312
  update: new Set(['update', 'file']),
247
313
  compact: new Set([])
@@ -297,6 +363,16 @@ function commandVersion (options) {
297
363
  return EXIT_OK
298
364
  }
299
365
 
366
+ function commandSampleStore (options) {
367
+ const path = sampleStorePath()
368
+ if (options.json === true) {
369
+ out(JSON.stringify({ path, ...sampleStoreFacts }, null, 2))
370
+ return EXIT_OK
371
+ }
372
+ out(path)
373
+ return EXIT_OK
374
+ }
375
+
300
376
  // ------------------------------------------------------------ rendering
301
377
 
302
378
  /**
@@ -448,10 +524,7 @@ function reportStoreFailure (error) {
448
524
  err(`This query needs more of the store than one WebAssembly call may read: ${error.capValue} against a cap of ${error.capLimit}.`)
449
525
  err('Narrow the query - bind a predicate, or restrict the graph - or use the native l4block-* tools.')
450
526
  } else if (error.stackLimit) {
451
- err('The runtime ran out of call stack inside the engine, not the store.')
452
- err('Some evaluator paths recurse once per row, and a few thousand rows can')
453
- err("exceed Node's default WebAssembly frame budget. Raise it with")
454
- err('node --stack-size=4000, add a LIMIT, or run the query under Deno.')
527
+ for (const line of stackLimitAdvice(STACK_REMEDY.query)) err(line)
455
528
  } else if (error.digestKey !== null) {
456
529
  err(`The bytes of '${error.digestKey}' in the generation directory are not the bytes the manifest commits.`)
457
530
  err('The generation is damaged or was edited after it was packed; repack or restore it.')
@@ -561,14 +634,176 @@ function renderQueryResult (engine, result, format, limit, quiet) {
561
634
  return EXIT_FAILURE
562
635
  }
563
636
 
564
- function commandPack (positional, _options) {
637
+ /** The syntax tag for an input, from --syntax or from the file name. The
638
+ * engine is what actually decides how to read the bytes; this only picks
639
+ * which of its parsers to name. */
640
+ function packSyntax (input, options) {
641
+ if (typeof options.syntax === 'string') {
642
+ const syntax = options.syntax.toLowerCase()
643
+ if (PACK_SYNTAXES.indexOf(syntax) >= 0) return syntax
644
+ throw new UsageError(`--syntax ${options.syntax} is not one of ${PACK_SYNTAXES.join(', ')}`)
645
+ }
646
+ const lower = input.toLowerCase()
647
+ for (const [suffix, syntax] of PACK_SUFFIXES) {
648
+ if (lower.endsWith(suffix)) return syntax
649
+ }
650
+ for (const [suffix, name] of PACK_UNSUPPORTED_SUFFIXES) {
651
+ if (lower.endsWith(suffix)) {
652
+ throw new UsageError(
653
+ `pack does not read ${name}. The packer's streaming fold reads ` +
654
+ `${PACK_SYNTAXES.join(', ')} only. Convert the file first, for ` +
655
+ "example with: factoidal parse FILE --out nquads")
656
+ }
657
+ }
658
+ throw new UsageError(
659
+ `cannot tell the syntax of ${input} from its name; give --syntax ` +
660
+ `(${PACK_SYNTAXES.join(', ')})`)
661
+ }
662
+
663
+ /**
664
+ * The base IRI relative IRIs in the source resolve against.
665
+ *
666
+ * The native packer uses `file://<input>`, so this matches it by default
667
+ * and byte-identical output needs no flag. `--base` overrides it, and
668
+ * `--base ''` asks for no base, which turns a relative IRI into a parse
669
+ * error rather than a silently different term.
670
+ */
671
+ function packBase (input, options) {
672
+ if (typeof options.base === 'string') return options.base
673
+ const absolute = input.startsWith('/') ? input : joinPath(currentDirectory(), input)
674
+ return 'file://' + absolute
675
+ }
676
+
677
+ /** The process's working directory, on Node and on Deno. */
678
+ function currentDirectory () {
679
+ if (isDeno) return globalThis.Deno.cwd()
680
+ return process.cwd()
681
+ }
682
+
683
+ function packLayout (options) {
684
+ if (typeof options.layout !== 'string') return 'ibk3'
685
+ const layout = options.layout.toLowerCase()
686
+ if (PACK_LAYOUTS.indexOf(layout) >= 0) return layout
687
+ throw new UsageError(`--layout ${options.layout} is not one of ${PACK_LAYOUTS.join(', ')}`)
688
+ }
689
+
690
+ async function commandPack (positional, options) {
565
691
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
566
- return notWired('pack', 'The streaming pack operations are stage 3 of the milestone.')
692
+ const [input, output] = positional
693
+ const syntax = packSyntax(input, options)
694
+ const layout = packLayout(options)
695
+ // The pack fold needs a bigger call stack than either runtime gives by
696
+ // default (https://github.com/danbri/factoidal/issues/649). Under Node
697
+ // the work runs on a worker thread with a raised stack; under Deno the
698
+ // command re-executes itself once with --v8-flags=--stack-size, and
699
+ // this is where that happens, before any file is opened. --no-worker
700
+ // keeps the in-process path testable.
701
+ const host = { worker: options['no-worker'] !== true }
702
+ const reexec = await denoReexec(host)
703
+ if (reexec !== null) return reexec
704
+ makeDirectory(output)
705
+ const quiet = options.quiet === true
706
+ let answer
707
+ try {
708
+ answer = await runPack(
709
+ { kind: 'pack', input, output, syntax, layout, base: packBase(input, options) },
710
+ quiet
711
+ ? undefined
712
+ : (progress) => {
713
+ if (progress.bytesRead % (16 * 1024 * 1024) < FEED_PROGRESS) {
714
+ err(`${progress.pass}: ${progress.bytesRead} bytes read, ${progress.artifacts} artifacts written`)
715
+ }
716
+ },
717
+ host)
718
+ } catch (error) {
719
+ if (error instanceof PackError || error instanceof StoreHostError) {
720
+ err(`factoidal pack: ${error.message}`)
721
+ return EXIT_FAILURE
722
+ }
723
+ // Everything the engine refuses -- an unknown grammar tag, a parse
724
+ // error, a cap -- arrives as a plain Error carrying the engine's own
725
+ // words. A stack trace here would hide them.
726
+ if (error instanceof Error && typeof error.message === 'string') {
727
+ err(`factoidal pack: ${error.message.replace(/^l4factoidal:\s*/, '')}`)
728
+ // The raised stack was refused, unavailable, or still not enough.
729
+ if (isStackOverflow(error)) {
730
+ for (const line of stackLimitAdvice(STACK_REMEDY.pack)) err(line)
731
+ }
732
+ return EXIT_FAILURE
733
+ }
734
+ throw error
735
+ }
736
+ if (answer.notWired === true) {
737
+ return notWired('pack',
738
+ 'This install carries an engine built before the streaming pack ' +
739
+ 'operations. Update @factoidal/core, or set FACTOIDAL_L4_ASSETS to ' +
740
+ 'a newer build.')
741
+ }
742
+ const report = answer.report
743
+ if (options.json === true) {
744
+ out(JSON.stringify(report, null, 2))
745
+ return EXIT_OK
746
+ }
747
+ out(`packed ${report.bytesRead} bytes of ${syntax} into ${output}`)
748
+ out(`${report.written.length} artifacts, ${report.bytesWritten} bytes, layout ${layout}`)
749
+ if (typeof report.rows === 'number') out(`${plural(report.rows, 'row')}`)
750
+ out(`activate it with: factoidal activate ${dirOf(output)} ${nameOf(output)}`)
751
+ return EXIT_OK
567
752
  }
568
753
 
569
- function commandActivate (positional, _options) {
754
+ async function commandActivate (positional, options) {
570
755
  if (positional.length !== 2) throw new UsageError('activate needs STORE and GENERATION')
571
- return notWired('activate', 'Activation must verify every artifact before it replaces CURRENT.')
756
+ const [root, generation] = positional
757
+ // Verification decodes the same blocks the pack encoded, so it recurses
758
+ // as deep and needs the same raised stack. Measured 2026-09-04: a
759
+ // 112,742-row generation packed successfully and then failed to
760
+ // activate with `Maximum call stack size exceeded`, leaving a store
761
+ // that could be built and not opened
762
+ // (https://github.com/danbri/factoidal/issues/649).
763
+ const host = { worker: options['no-worker'] !== true }
764
+ const reexec = await denoReexec(host)
765
+ if (reexec !== null) return reexec
766
+ let answer
767
+ try {
768
+ answer = await runPack({ kind: 'activate', root, generation }, undefined, host)
769
+ } catch (error) {
770
+ if (error instanceof PackError || error instanceof StoreHostError) {
771
+ err(`factoidal activate: ${error.code ? error.code + ': ' : ''}${error.message}`)
772
+ return EXIT_FAILURE
773
+ }
774
+ if (error instanceof Error && typeof error.message === 'string') {
775
+ err(`factoidal activate: ${error.message.replace(/^l4factoidal:\s*/, '')}`)
776
+ if (isStackOverflow(error)) {
777
+ for (const line of stackLimitAdvice(STACK_REMEDY.pack)) err(line)
778
+ }
779
+ return EXIT_FAILURE
780
+ }
781
+ throw error
782
+ }
783
+ if (answer.notWired === true) {
784
+ return notWired('activate',
785
+ 'This install carries an engine built before the activation ' +
786
+ 'verification operation. Update @factoidal/core.')
787
+ }
788
+ const verdict = answer.report
789
+ if (verdict.ok !== true) {
790
+ err(`factoidal activate: ${verdict.error}`)
791
+ err('The generation is NOT activated; CURRENT is unchanged.')
792
+ return EXIT_FAILURE
793
+ }
794
+ // Only now does the pointer move, and it moves atomically.
795
+ const pointer = new TextEncoder().encode(generation)
796
+ const synced = atomicReplace(joinPath(root, 'CURRENT'), pointer)
797
+ if (options.json === true) {
798
+ out(JSON.stringify({ ...verdict, generation, directorySynced: synced }, null, 2))
799
+ return EXIT_OK
800
+ }
801
+ out(`activated ${generation}: ${verdict.artifacts} artifacts verified, ${verdict.bytes} bytes`)
802
+ if (!synced) {
803
+ err('CURRENT was replaced, but the directory entry was not synced; a ' +
804
+ 'crash now could lose the pointer update.')
805
+ }
806
+ return EXIT_OK
572
807
  }
573
808
 
574
809
  function commandUpdate (positional, options) {
@@ -589,6 +824,7 @@ function commandCompact (positional, _options) {
589
824
 
590
825
  const COMMANDS = {
591
826
  version: (positional, options) => commandVersion(options),
827
+ 'sample-store': (positional, options) => commandSampleStore(options),
592
828
  inspect: commandInspect,
593
829
  query: commandQuery,
594
830
  pack: commandPack,