@factoidal/core 0.1.0 → 0.3.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.
@@ -0,0 +1,658 @@
1
+ #!/usr/bin/env node
2
+ // The `factoidal` command: pack, activate, query, update and compact a
3
+ // persisted Shardborough store from Node or Deno, with no native binary.
4
+ // https://github.com/danbri/factoidal/issues/641
5
+ //
6
+ // STATE TODAY. The argument surface below is complete and settled, so that
7
+ // wiring the remaining subcommands changes no user-visible syntax.
8
+ // `version`, `inspect` and `query` run: the first needs only host file
9
+ // I/O, and the other two drive the three WebAssembly store operations of
10
+ // `formal/lean4/Wasm/Ops/Store.lean`. `pack`, `activate`, `update` and
11
+ // `compact` parse their arguments, report what they would do, and exit 3;
12
+ // they need operations that do not exist yet.
13
+ //
14
+ // This file reads files by name, moves bytes and renders what the engine
15
+ // answered. It parses no manifest, verifies no digest, decodes no block
16
+ // and chooses no artifact -- every one of those is a format decision and
17
+ // it lives in the Lean source (iron rule 7). The table renderer is a
18
+ // human display of the engine's own SPARQL Query Results JSON, not a
19
+ // second serializer: `--format json`, `--format nquads` and
20
+ // `--format turtle` all print documents the engine produced.
21
+
22
+ import {
23
+ StoreHostError, listGeneration, readWhole, runtime
24
+ } from '../store-host/index.mjs'
25
+ import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
26
+ import { loadEngine } from './engine.mjs'
27
+ import {
28
+ StoreOperationError, inspectManifest, openStore, planQuery, queryStore,
29
+ turtleOfNQuads
30
+ } from './store.mjs'
31
+
32
+ const EXIT_OK = 0
33
+ const EXIT_FAILURE = 1
34
+ const EXIT_USAGE = 2
35
+ const EXIT_NOT_WIRED = 3
36
+
37
+ const ISSUE = 'https://github.com/danbri/factoidal/issues/641'
38
+
39
+ const isDeno = typeof globalThis.Deno !== 'undefined'
40
+ const argv = isDeno ? globalThis.Deno.args.slice() : process.argv.slice(2)
41
+
42
+ // Node writes to a PIPE asynchronously, and `process.exit()` drops
43
+ // whatever is still buffered. A SELECT that prints a few megabytes of
44
+ // SPARQL Query Results JSON into `| jq` therefore arrived truncated at
45
+ // the 64 KiB pipe boundary (measured 2026-09-03, 6455 rows). Setting the
46
+ // exit code and letting the process end on its own flushes it.
47
+ function exit (code) {
48
+ if (isDeno) globalThis.Deno.exit(code)
49
+ else process.exitCode = code
50
+ }
51
+
52
+ function out (line) { console.log(line) }
53
+ function err (line) { console.error(line) }
54
+
55
+ // ---------------------------------------------------------------- usage
56
+
57
+ const USAGE = `factoidal - the Factoidal persisted store, from Node or Deno
58
+
59
+ usage: factoidal <command> [options]
60
+
61
+ commands:
62
+ version print the package and engine versions
63
+ inspect STORE report what the activated manifest commits
64
+ query STORE [QUERY] evaluate a SPARQL query against a store
65
+ pack INPUT OUTPUT build one immutable generation from an RDF file
66
+ activate STORE GENERATION make one generation the activated generation
67
+ update STORE [UPDATE] apply a SPARQL Update through the delta log
68
+ compact STORE GENERATION fold the delta log into a new generation
69
+
70
+ global options:
71
+ -h, --help print this text, or a command's own help
72
+ --json machine-readable output where a command has it
73
+ --quiet print results only, no progress lines
74
+
75
+ exit codes:
76
+ 0 success 1 failure 2 usage error 3 not yet wired (${ISSUE})
77
+
78
+ STORE is a collection root: the directory that holds CURRENT.`
79
+
80
+ const COMMAND_USAGE = {
81
+ version: `factoidal version - print the package and engine versions
82
+
83
+ usage: factoidal version [--json]
84
+
85
+ Prints the npm package version, the Lean engine's WebAssembly digest as
86
+ recorded by its build, and which host-I/O implementation is loaded.`,
87
+
88
+ inspect: `factoidal inspect - report what a store's manifest commits
89
+
90
+ usage: factoidal inspect STORE [--json] [--generation NAME]
91
+
92
+ Reads CURRENT, hands the manifest bytes to the engine's
93
+ storeManifestInspect operation, and prints what it decoded: the wire
94
+ version, the layout, the blank-node publication profile, the term-registry
95
+ version, whether the manifest carries a fixed-chunk Merkle commitment, and
96
+ one row per entry with its predicate, row count, byte length, block kind
97
+ and graph set.
98
+
99
+ options:
100
+ --generation NAME inspect this generation instead of the activated one
101
+ --json print the operation's envelope unchanged`,
102
+
103
+ query: `factoidal query - evaluate a SPARQL query against a store
104
+
105
+ usage: factoidal query STORE [QUERY] [options]
106
+
107
+ QUERY is the query text. Give it as the second argument, or with --query,
108
+ or in a file with --file.
109
+
110
+ The command reads CURRENT and the manifest, asks the engine which
111
+ artifacts the query needs, reads exactly those, and hands their bytes to
112
+ the engine's storeQuery operation. The engine verifies every artifact
113
+ against the SHA-256 the manifest commits before it answers.
114
+
115
+ options:
116
+ --query TEXT the query text
117
+ --file PATH read the query text from a file
118
+ --format FORMAT table (default), json, nquads, turtle
119
+ --limit N print at most N table rows; the total is always named
120
+ --explain print the artifact plan instead of the results
121
+ --generation NAME query this generation instead of the activated one
122
+ --json shorthand for --format json
123
+ --quiet print the result only, no plan line on stderr
124
+
125
+ formats:
126
+ table a human display of the engine's SPARQL Query Results JSON;
127
+ ASK prints true or false, CONSTRUCT prints its N-Triples
128
+ json SELECT prints the engine's SPARQL 1.1 Query Results JSON
129
+ document; ASK and CONSTRUCT print the operation's envelope,
130
+ because the operation answers those two with a boolean and a
131
+ serialized graph rather than with a results document
132
+ nquads CONSTRUCT only: the graph the engine serialized
133
+ turtle CONSTRUCT only: that graph through the engine's own Turtle
134
+ writer, which flattens named graphs into the default graph
135
+
136
+ not available, and why:
137
+ --base IRI the store query operation takes no base argument;
138
+ put a BASE clause in the query text instead
139
+ xml, csv, tsv the engine has no operation that writes the SPARQL
140
+ Results XML, CSV or TSV documents, and writing one
141
+ here would be a second serializer (iron rule 7)
142
+ DESCRIBE the engine answers "DESCRIBE is not supported by the
143
+ npm entry yet"
144
+
145
+ A store carrying a non-empty delta log is not served by this path: the
146
+ operation reads the manifest's committed artifacts only. Use the native
147
+ l4block-* tools for a store with uncompacted updates.`,
148
+
149
+ pack: `factoidal pack - build one immutable generation from an RDF file
150
+
151
+ usage: factoidal pack INPUT OUTPUT [options]
152
+
153
+ INPUT is an RDF file. OUTPUT is the generation directory to create; it is
154
+ normally STORE/gen-N. Packing does not activate: run activate next.
155
+
156
+ options:
157
+ --layout LAYOUT ibk3 (triples, default) or ibk4 (quads)
158
+ --syntax SYNTAX turtle, trig or nquads; default from the file extension
159
+ --chunk-bytes N Merkle chunk size; default is the engine's
160
+ --json emit one JSON object`,
161
+
162
+ activate: `factoidal activate - make one generation the activated generation
163
+
164
+ usage: factoidal activate STORE GENERATION [--json]
165
+
166
+ Verifies every artifact of GENERATION and every cross-artifact relation,
167
+ then atomically replaces STORE/CURRENT. A generation that fails
168
+ verification never becomes current.`,
169
+
170
+ update: `factoidal update - apply a SPARQL Update through the delta log
171
+
172
+ usage: factoidal update STORE [UPDATE] [options]
173
+
174
+ UPDATE is the update text. Give it as the second argument, or with
175
+ --update, or in a file with --file. The batch is appended to the
176
+ activated generation's delta log and is visible to the next query.
177
+
178
+ options:
179
+ --update TEXT the update text
180
+ --file PATH read the update text from a file
181
+ --json emit one JSON object`,
182
+
183
+ compact: `factoidal compact - fold the delta log into a new generation
184
+
185
+ usage: factoidal compact STORE GENERATION [options]
186
+
187
+ Reads the activated generation and its committed delta batches, writes
188
+ GENERATION as a new immutable generation under STORE, and records its
189
+ compacted epoch. It does not activate unless --activate is given.
190
+
191
+ options:
192
+ --activate activate the new generation when it verifies
193
+ --json emit one JSON object`
194
+ }
195
+
196
+ // ------------------------------------------------------------- arguments
197
+
198
+ /**
199
+ * Split argv into positional arguments and options. `--name value` and
200
+ * `--name=value` both work; a flag with no value is `true`.
201
+ */
202
+ function parseArguments (args, valueOptions) {
203
+ const positional = []
204
+ const options = Object.create(null)
205
+ for (let index = 0; index < args.length; index += 1) {
206
+ const argument = args[index]
207
+ if (argument === '--') {
208
+ positional.push(...args.slice(index + 1))
209
+ break
210
+ }
211
+ if (argument === '-h') {
212
+ options.help = true
213
+ continue
214
+ }
215
+ if (!argument.startsWith('--')) {
216
+ positional.push(argument)
217
+ continue
218
+ }
219
+ const equals = argument.indexOf('=')
220
+ const name = equals < 0 ? argument.slice(2) : argument.slice(2, equals)
221
+ if (equals >= 0) {
222
+ options[name] = argument.slice(equals + 1)
223
+ continue
224
+ }
225
+ if (valueOptions.has(name)) {
226
+ index += 1
227
+ if (index >= args.length) {
228
+ throw new UsageError(`option --${name} needs a value`)
229
+ }
230
+ options[name] = args[index]
231
+ continue
232
+ }
233
+ options[name] = true
234
+ }
235
+ return { positional, options }
236
+ }
237
+
238
+ class UsageError extends Error {}
239
+
240
+ const VALUE_OPTIONS = {
241
+ version: new Set([]),
242
+ inspect: new Set(['generation']),
243
+ query: new Set(['query', 'file', 'format', 'limit', 'base', 'generation']),
244
+ pack: new Set(['layout', 'syntax', 'chunk-bytes']),
245
+ activate: new Set([]),
246
+ update: new Set(['update', 'file']),
247
+ compact: new Set([])
248
+ }
249
+
250
+ // ------------------------------------------------------------- commands
251
+
252
+ function packageDirectory () {
253
+ return fileUrlToPath(new URL('..', import.meta.url).href)
254
+ }
255
+
256
+ function readJsonFile (path) {
257
+ const bytes = readWhole(path)
258
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
259
+ }
260
+
261
+ function commandVersion (options) {
262
+ const root = packageDirectory()
263
+ const own = readJsonFile(joinPath(root, 'package.json'))
264
+ let engine = null
265
+ try {
266
+ engine = readJsonFile(joinPath(root, 'l4-assets/version.json'))
267
+ } catch (_error) {
268
+ engine = null
269
+ }
270
+ const report = {
271
+ package: own.name,
272
+ version: own.version,
273
+ host: runtime,
274
+ engine: engine === null
275
+ ? null
276
+ : {
277
+ engine: engine.engine,
278
+ version: engine.version,
279
+ gitSha: engine.gitSha,
280
+ leanToolchain: engine.leanToolchain,
281
+ wasmSha256: engine.wasmSha256,
282
+ wasmBytes: engine.wasmBytes
283
+ }
284
+ }
285
+ if (options.json) {
286
+ out(JSON.stringify(report, null, 2))
287
+ return EXIT_OK
288
+ }
289
+ out(`${report.package} ${report.version}`)
290
+ out(`host-io ${report.host}`)
291
+ if (report.engine !== null) {
292
+ out(`engine ${report.engine.engine} ${report.engine.version} (${report.engine.leanToolchain})`)
293
+ out(`wasm sha256 ${report.engine.wasmSha256} (${report.engine.wasmBytes} bytes)`)
294
+ } else {
295
+ out('engine (no l4-assets/version.json in this install)')
296
+ }
297
+ return EXIT_OK
298
+ }
299
+
300
+ // ------------------------------------------------------------ rendering
301
+
302
+ /**
303
+ * One graph name as the manifest reports it. `{"kind":"default"}` is the
304
+ * default graph; the operation marks it that way so a host never has to
305
+ * recognise a reserved IRI.
306
+ */
307
+ function graphLabel (graph) {
308
+ if (graph.kind === 'default') return 'default'
309
+ if (graph.kind === 'iri') return `<${graph.value}>`
310
+ if (graph.kind === 'bnode') return `_:${graph.value}`
311
+ return JSON.stringify(graph)
312
+ }
313
+
314
+ /**
315
+ * One SPARQL Query Results JSON term as a table cell. This is a display,
316
+ * not a serialization: `--format json` prints the engine's own document
317
+ * and `--format nquads` prints the engine's own N-Triples.
318
+ */
319
+ function cellOfTerm (term) {
320
+ if (term === undefined || term === null) return ''
321
+ if (term.type === 'uri') return `<${term.value}>`
322
+ if (term.type === 'bnode') return `_:${term.value}`
323
+ if (term.type === 'literal') {
324
+ const lang = term['xml:lang']
325
+ if (typeof lang === 'string' && lang.length > 0) return `"${term.value}"@${lang}`
326
+ if (typeof term.datatype === 'string') return `"${term.value}"^^<${term.datatype}>`
327
+ return `"${term.value}"`
328
+ }
329
+ return JSON.stringify(term)
330
+ }
331
+
332
+ function plural (count, noun) {
333
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
334
+ }
335
+
336
+ /** Print rows as columns padded to their widest cell. */
337
+ function printTable (headers, rows) {
338
+ const widths = headers.map((header) => header.length)
339
+ for (const row of rows) {
340
+ for (let index = 0; index < row.length; index += 1) {
341
+ if (row[index].length > widths[index]) widths[index] = row[index].length
342
+ }
343
+ }
344
+ const line = (cells) => cells
345
+ .map((cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index]))
346
+ .join(' ')
347
+ out(line(headers))
348
+ for (const row of rows) out(line(row))
349
+ }
350
+
351
+ // ------------------------------------------------------------- inspect
352
+
353
+ async function commandInspect (positional, options) {
354
+ if (positional.length !== 1) throw new UsageError('inspect needs exactly one STORE')
355
+ const root = positional[0]
356
+ const named = typeof options.generation === 'string' ? options.generation : null
357
+ const store = openStore(root, named)
358
+ const engine = await loadEngine()
359
+ const envelope = inspectManifest(engine, store)
360
+
361
+ if (options.json) {
362
+ // The operation's envelope, unchanged.
363
+ out(JSON.stringify(envelope, null, 2))
364
+ return EXIT_OK
365
+ }
366
+
367
+ const files = listGeneration(store.generationDir)
368
+ let directoryBytes = 0
369
+ for (const file of files) directoryBytes += file.size
370
+
371
+ out(`store ${root}`)
372
+ out(`generation ${store.generation}${store.activated ? ' (activated through CURRENT)' : ' (named on the command line)'}`)
373
+ out(`manifest ${store.manifestName}, ${store.manifest.length} bytes, wire version ${envelope.wireVersion}`)
374
+ out(`layout ${envelope.layout}`)
375
+ out(`blank-node profile ${envelope.blankNodeProfile === '' ? '(none recorded)' : envelope.blankNodeProfile}`)
376
+ out(`term registry ${envelope.termRegistryVersion === '' ? '(none recorded)' : envelope.termRegistryVersion}`)
377
+ out(`fixed-chunk Merkle commitment ${envelope.rangeCommitted ? 'yes' : 'no'}`)
378
+ out(`${envelope.entries.length} ${envelope.entries.length === 1 ? 'entry' : 'entries'}, ${envelope.totalBytes} bytes, ${plural(envelope.totalRows, 'row')}`)
379
+ out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
380
+ out('')
381
+ printTable(
382
+ ['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate'],
383
+ envelope.entries.map((entry) => [
384
+ String(entry.ordinal),
385
+ String(entry.rows),
386
+ String(entry.bytes),
387
+ entry.blockKind,
388
+ entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
389
+ entry.predicate
390
+ ]))
391
+ return EXIT_OK
392
+ }
393
+
394
+ function notWired (command, detail) {
395
+ err(`factoidal ${command}: not yet wired. ${detail}`)
396
+ err(`This needs a WebAssembly operation from the Lean engine; see ${ISSUE}.`)
397
+ return EXIT_NOT_WIRED
398
+ }
399
+
400
+ const QUERY_FORMATS = ['table', 'json', 'nquads', 'turtle']
401
+ const UNAVAILABLE_FORMATS = {
402
+ xml: 'the SPARQL Results XML document',
403
+ csv: 'the SPARQL Results CSV document',
404
+ tsv: 'the SPARQL Results TSV document'
405
+ }
406
+
407
+ function queryText (positional, options) {
408
+ if (typeof options.file === 'string') {
409
+ return new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options.file))
410
+ }
411
+ if (positional.length > 1) return positional.slice(1).join(' ')
412
+ if (typeof options.query === 'string') return options.query
413
+ throw new UsageError('query needs QUERY, --query TEXT or --file PATH')
414
+ }
415
+
416
+ function queryFormat (options) {
417
+ if (options.json === true && typeof options.format !== 'string') return 'json'
418
+ if (typeof options.format !== 'string') return 'table'
419
+ const format = options.format.toLowerCase()
420
+ if (QUERY_FORMATS.indexOf(format) >= 0) return format
421
+ if (Object.prototype.hasOwnProperty.call(UNAVAILABLE_FORMATS, format)) {
422
+ throw new UsageError(
423
+ `--format ${format} needs an engine operation that writes ` +
424
+ `${UNAVAILABLE_FORMATS[format]}; there is none, and writing one here ` +
425
+ 'would be a second serializer. Use --format json.')
426
+ }
427
+ throw new UsageError(`--format ${options.format} is not one of ${QUERY_FORMATS.join(', ')}`)
428
+ }
429
+
430
+ function queryLimit (options, format) {
431
+ if (typeof options.limit !== 'string') return null
432
+ const limit = Number(options.limit)
433
+ if (!Number.isSafeInteger(limit) || limit < 0) {
434
+ throw new UsageError('--limit needs a non-negative whole number')
435
+ }
436
+ if (format !== 'table') {
437
+ throw new UsageError(
438
+ '--limit truncates the printed table only; it is not carried into the ' +
439
+ "engine's own documents. Put a LIMIT clause in the query instead.")
440
+ }
441
+ return limit
442
+ }
443
+
444
+ /** Report a refusal the store operations made, and what to do about it. */
445
+ function reportStoreFailure (error) {
446
+ err(`factoidal query: ${error.message}`)
447
+ if (error.capLimit !== null) {
448
+ err(`This query needs more of the store than one WebAssembly call may read: ${error.capValue} against a cap of ${error.capLimit}.`)
449
+ err('Narrow the query - bind a predicate, or restrict the graph - or use the native l4block-* tools.')
450
+ } 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.')
455
+ } else if (error.digestKey !== null) {
456
+ err(`The bytes of '${error.digestKey}' in the generation directory are not the bytes the manifest commits.`)
457
+ err('The generation is damaged or was edited after it was packed; repack or restore it.')
458
+ }
459
+ return EXIT_FAILURE
460
+ }
461
+
462
+ async function commandQuery (positional, options) {
463
+ if (positional.length < 1) throw new UsageError('query needs a STORE')
464
+ if (typeof options.base === 'string') {
465
+ throw new UsageError(
466
+ 'the store query operation takes no base argument; put a BASE clause ' +
467
+ 'in the query text instead')
468
+ }
469
+ const root = positional[0]
470
+ const sparql = queryText(positional, options)
471
+ const format = queryFormat(options)
472
+ const limit = queryLimit(options, format)
473
+ const quiet = options.quiet === true
474
+
475
+ const named = typeof options.generation === 'string' ? options.generation : null
476
+ const store = openStore(root, named)
477
+ const engine = await loadEngine()
478
+
479
+ if (options.explain === true) {
480
+ let plan
481
+ try {
482
+ plan = planQuery(engine, store, sparql)
483
+ } catch (error) {
484
+ if (error instanceof StoreOperationError) return reportStoreFailure(error)
485
+ throw error
486
+ }
487
+ if (format === 'json' || options.json === true) {
488
+ out(JSON.stringify(plan, null, 2))
489
+ return EXIT_OK
490
+ }
491
+ out(`layout ${plan.layout} (wire version ${plan.wireVersion})`)
492
+ out(`mode ${plan.mode}`)
493
+ out(`${plural(plan.shards, 'artifact')}, ${plan.bytes} bytes, ${plural(plan.rows, 'row')}`)
494
+ for (const key of plan.keys) out(` ${key}`)
495
+ return EXIT_OK
496
+ }
497
+
498
+ let answer
499
+ try {
500
+ answer = queryStore(engine, store, sparql)
501
+ } catch (error) {
502
+ if (error instanceof StoreOperationError) return reportStoreFailure(error)
503
+ throw error
504
+ }
505
+ const { plan, result, blobBytes } = answer
506
+ if (!quiet) {
507
+ err(`mode ${result.mode}, ${plural(result.shards, 'artifact')}, ${blobBytes} bytes read, plan declares ${plural(plan.rows, 'block row')}`)
508
+ }
509
+ return renderQueryResult(engine, result, format, limit, quiet)
510
+ }
511
+
512
+ function renderQueryResult (engine, result, format, limit, quiet) {
513
+ if (result.kind === 'select') {
514
+ const vars = result.srj.head.vars
515
+ const bindings = result.srj.results.bindings
516
+ if (format === 'json') {
517
+ out(JSON.stringify(result.srj, null, 2))
518
+ return EXIT_OK
519
+ }
520
+ if (format !== 'table') {
521
+ err(`factoidal query: --format ${format} needs a CONSTRUCT query; this one is a SELECT`)
522
+ return EXIT_FAILURE
523
+ }
524
+ const shown = limit === null ? bindings : bindings.slice(0, limit)
525
+ printTable(vars, shown.map((row) => vars.map((name) => cellOfTerm(row[name]))))
526
+ if (shown.length < bindings.length) {
527
+ err(`showing ${shown.length} of ${bindings.length} rows (--limit ${limit})`)
528
+ } else if (!quiet) {
529
+ err(plural(bindings.length, 'row'))
530
+ }
531
+ return EXIT_OK
532
+ }
533
+ if (result.kind === 'ask') {
534
+ if (format === 'json') {
535
+ // The operation answers ASK with a boolean, not with a results
536
+ // document, so the envelope is what there is to print.
537
+ out(JSON.stringify(result, null, 2))
538
+ return EXIT_OK
539
+ }
540
+ if (format !== 'table') {
541
+ err(`factoidal query: --format ${format} needs a CONSTRUCT query; this one is an ASK`)
542
+ return EXIT_FAILURE
543
+ }
544
+ out(result.boolean ? 'true' : 'false')
545
+ return EXIT_OK
546
+ }
547
+ if (result.kind === 'construct') {
548
+ if (format === 'json') {
549
+ out(JSON.stringify(result, null, 2))
550
+ return EXIT_OK
551
+ }
552
+ if (format === 'turtle') {
553
+ out(turtleOfNQuads(engine, result.nquads))
554
+ return EXIT_OK
555
+ }
556
+ // table and nquads both print what the engine serialized.
557
+ if (result.nquads.length > 0) out(result.nquads.replace(/\n$/, ''))
558
+ return EXIT_OK
559
+ }
560
+ err(`factoidal query: the engine answered an unknown result kind "${result.kind}"`)
561
+ return EXIT_FAILURE
562
+ }
563
+
564
+ function commandPack (positional, _options) {
565
+ 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.')
567
+ }
568
+
569
+ function commandActivate (positional, _options) {
570
+ 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.')
572
+ }
573
+
574
+ function commandUpdate (positional, options) {
575
+ if (positional.length < 1) throw new UsageError('update needs a STORE')
576
+ const text = positional.length > 1
577
+ ? positional.slice(1).join(' ')
578
+ : (typeof options.update === 'string' ? options.update : null)
579
+ if (text === null && typeof options.file !== 'string') {
580
+ throw new UsageError('update needs UPDATE, --update TEXT or --file PATH')
581
+ }
582
+ return notWired('update', 'The delta-log operations are stage 4 of the milestone.')
583
+ }
584
+
585
+ function commandCompact (positional, _options) {
586
+ if (positional.length !== 2) throw new UsageError('compact needs STORE and GENERATION')
587
+ return notWired('compact', 'Compaction is stage 4 of the milestone.')
588
+ }
589
+
590
+ const COMMANDS = {
591
+ version: (positional, options) => commandVersion(options),
592
+ inspect: commandInspect,
593
+ query: commandQuery,
594
+ pack: commandPack,
595
+ activate: commandActivate,
596
+ update: commandUpdate,
597
+ compact: commandCompact
598
+ }
599
+
600
+ // ----------------------------------------------------------------- main
601
+
602
+ async function main () {
603
+ if (argv.length === 0) {
604
+ out(USAGE)
605
+ return EXIT_USAGE
606
+ }
607
+ const command = argv[0]
608
+ if (command === '--help' || command === '-h' || command === 'help') {
609
+ const topic = argv[1]
610
+ if (typeof topic === 'string' && Object.prototype.hasOwnProperty.call(COMMAND_USAGE, topic)) {
611
+ out(COMMAND_USAGE[topic])
612
+ } else {
613
+ out(USAGE)
614
+ }
615
+ return EXIT_OK
616
+ }
617
+ if (command === '--version') return commandVersion({})
618
+ if (!Object.prototype.hasOwnProperty.call(COMMANDS, command)) {
619
+ err(`factoidal: unknown command "${command}"`)
620
+ err(USAGE)
621
+ return EXIT_USAGE
622
+ }
623
+ let parsed
624
+ try {
625
+ parsed = parseArguments(argv.slice(1), VALUE_OPTIONS[command])
626
+ } catch (error) {
627
+ if (error instanceof UsageError) {
628
+ err(`factoidal ${command}: ${error.message}`)
629
+ return EXIT_USAGE
630
+ }
631
+ throw error
632
+ }
633
+ if (parsed.options.help) {
634
+ out(COMMAND_USAGE[command])
635
+ return EXIT_OK
636
+ }
637
+ try {
638
+ return await COMMANDS[command](parsed.positional, parsed.options)
639
+ } catch (error) {
640
+ if (error instanceof UsageError) {
641
+ err(`factoidal ${command}: ${error.message}`)
642
+ err('')
643
+ err(COMMAND_USAGE[command])
644
+ return EXIT_USAGE
645
+ }
646
+ if (error instanceof StoreHostError) {
647
+ err(`factoidal ${command}: ${error.code}: ${error.message}`)
648
+ return EXIT_FAILURE
649
+ }
650
+ if (error instanceof StoreOperationError) {
651
+ err(`factoidal ${command}: ${error.message}`)
652
+ return EXIT_FAILURE
653
+ }
654
+ throw error
655
+ }
656
+ }
657
+
658
+ exit(await main())