@factoidal/core 0.5.1 → 0.7.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/factoidal.mjs CHANGED
@@ -28,6 +28,9 @@ import { loadEngine } from './engine.mjs'
28
28
  import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
29
29
  import { PackError, packSupported, verifyGeneration } from './pack.mjs'
30
30
  import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
31
+ import {
32
+ planQueryOnWorker, queryStoreOnWorker, workerRouteAvailable
33
+ } from './store-worker-host.mjs'
31
34
  import {
32
35
  STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
33
36
  queryStore, stackLimitAdvice, turtleOfNQuads
@@ -42,7 +45,7 @@ const EXIT_NOT_WIRED = 3
42
45
  // a time, so this is the window that catches one feed per report.
43
46
  const FEED_PROGRESS = 65536
44
47
 
45
- const PACK_LAYOUTS = ['ibk3', 'ibk4']
48
+ const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
46
49
  const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
47
50
  const PACK_SUFFIXES = [
48
51
  ['.ttl', 'turtle'], ['.turtle', 'turtle'],
@@ -179,6 +182,21 @@ options:
179
182
  --generation NAME query this generation instead of the activated one
180
183
  --json shorthand for --format json
181
184
  --quiet print the result only, no plan line on stderr
185
+ --no-worker never retry on a worker thread (see below)
186
+
187
+ Several engine paths recurse once per manifest entry or once per row, and
188
+ against a large collection that exceeds the runtime's default call stack.
189
+ A one-shot query builds no store handle, so it runs IN PROCESS and pays
190
+ nothing for a worker it would drop; only when the runtime runs out of
191
+ frames does it run again on a worker thread with a raised stack, or under
192
+ Deno re-execute itself once with a raised V8 stack, which needs
193
+ --allow-run and --allow-env (https://github.com/danbri/factoidal/issues/653).
194
+ --no-worker turns the retry off, and the frame budget is then reported.
195
+
196
+ A process that asks MANY questions about one store should hold a store
197
+ handle instead: openStoreHandleOnWorker in bin/store-worker-host.mjs
198
+ verifies and decodes once, on a thread with the raised stack, and answers
199
+ every later query from what it retained.
182
200
 
183
201
  formats:
184
202
  table a human display of the engine's SPARQL Query Results JSON;
@@ -212,9 +230,15 @@ INPUT is an RDF file. OUTPUT is the generation directory to create; it is
212
230
  normally STORE/gen-N. Packing does not activate: run activate next.
213
231
 
214
232
  options:
215
- --layout LAYOUT ibk3 (triples, default) or ibk4 (quads)
233
+ --layout LAYOUT ibk3 (triples, default), ibk4 (quads, wire version 9)
234
+ or ibk5 (quads, wire version 10: RDF 1.2 terms,
235
+ out-of-line literals, zone maps)
216
236
  --syntax SYNTAX turtle, trig or nquads; default from the file extension
217
237
  --chunk-bytes N Merkle chunk size; default is the engine's
238
+ --batch-bytes N ibk4 only: source bytes between publication batches;
239
+ default is the engine's. Blocks are published during
240
+ the ingest pass, so this bounds the packer's memory.
241
+ Match l4block-shard-pack --batch-bytes for its blocks.
218
242
  --json emit one JSON object
219
243
  --no-worker pack in this process instead of on a worker thread
220
244
 
@@ -452,10 +476,27 @@ async function commandInspect (positional, options) {
452
476
  out(`term registry ${envelope.termRegistryVersion === '' ? '(none recorded)' : envelope.termRegistryVersion}`)
453
477
  out(`fixed-chunk Merkle commitment ${envelope.rangeCommitted ? 'yes' : 'no'}`)
454
478
  out(`${envelope.entries.length} ${envelope.entries.length === 1 ? 'entry' : 'entries'}, ${envelope.totalBytes} bytes, ${plural(envelope.totalRows, 'row')}`)
479
+ // Wire version 10: the manifest blob table. Each `blob-<hex>.lit` holds the
480
+ // lexical form of one literal above the 65,536-byte inline ceiling, and is
481
+ // shared by every block whose dictionary names it.
482
+ const blobs = envelope.blobs ?? []
483
+ if (blobs.length > 0) {
484
+ let blobBytes = 0
485
+ for (const blob of blobs) blobBytes += blob.bytes
486
+ out(`${plural(blobs.length, 'out-of-line literal')}, ${blobBytes} bytes`)
487
+ }
455
488
  out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
456
489
  out('')
490
+ // The zone maps are per entry, so the columns appear only for a manifest
491
+ // that carries them. A bound is the first 64 bytes of the smallest (or
492
+ // largest) term key of the block; the first eight are enough to see that
493
+ // two blocks hold different ranges.
494
+ const zoned = envelope.entries.some((entry) => entry.subjectZone !== undefined)
495
+ const zoneCell = (zone) =>
496
+ zone === undefined ? '-' : `${zone.min.slice(0, 16)}..${zone.max.slice(0, 16)}`
457
497
  printTable(
458
- ['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate'],
498
+ ['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate']
499
+ .concat(zoned ? ['blobs', 'subject zone'] : []),
459
500
  envelope.entries.map((entry) => [
460
501
  String(entry.ordinal),
461
502
  String(entry.rows),
@@ -463,7 +504,9 @@ async function commandInspect (positional, options) {
463
504
  entry.blockKind,
464
505
  entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
465
506
  entry.predicate
466
- ]))
507
+ ].concat(zoned
508
+ ? [String((entry.blobRefs ?? []).length), zoneCell(entry.subjectZone)]
509
+ : [])))
467
510
  return EXIT_OK
468
511
  }
469
512
 
@@ -532,6 +575,36 @@ function reportStoreFailure (error) {
532
575
  return EXIT_FAILURE
533
576
  }
534
577
 
578
+ /**
579
+ * Run one store call again on a bigger call stack, after the in-process
580
+ * attempt ran out of frames.
581
+ *
582
+ * A one-shot query builds no handle and drops what it loads, so it runs
583
+ * IN PROCESS first and pays nothing for a worker it would throw away.
584
+ * This is the retry, and it is reached only by the overflow.
585
+ *
586
+ * Node takes the worker route; Deno has no `worker_threads` route with a
587
+ * stack size, so it re-executes this command once with a raised V8 stack
588
+ * (`denoReexec`), which needs --allow-run and --allow-env.
589
+ *
590
+ * @returns the retried answer, or a number to exit with (the Deno child's
591
+ * code), or null when no bigger stack is available here
592
+ */
593
+ async function onBiggerStack (options, quiet, retry) {
594
+ const host = { worker: options['no-worker'] !== true }
595
+ if (host.worker === false) return null
596
+ if (isDeno) {
597
+ const code = await denoReexec(host)
598
+ return typeof code === 'number' ? code : null
599
+ }
600
+ if (!workerRouteAvailable()) return null
601
+ if (!quiet) {
602
+ err('The runtime ran out of call stack; running again on a worker thread ' +
603
+ 'with a bigger one.')
604
+ }
605
+ return await retry()
606
+ }
607
+
535
608
  async function commandQuery (positional, options) {
536
609
  if (positional.length < 1) throw new UsageError('query needs a STORE')
537
610
  if (typeof options.base === 'string') {
@@ -554,8 +627,13 @@ async function commandQuery (positional, options) {
554
627
  try {
555
628
  plan = planQuery(engine, store, sparql)
556
629
  } catch (error) {
557
- if (error instanceof StoreOperationError) return reportStoreFailure(error)
558
- throw error
630
+ if (!(error instanceof StoreOperationError)) throw error
631
+ if (!error.stackLimit) return reportStoreFailure(error)
632
+ const retried = await onBiggerStack(options, quiet,
633
+ () => planQueryOnWorker(root, named, sparql))
634
+ if (retried === null) return reportStoreFailure(error)
635
+ if (typeof retried === 'number') return retried
636
+ plan = retried
559
637
  }
560
638
  if (format === 'json' || options.json === true) {
561
639
  out(JSON.stringify(plan, null, 2))
@@ -572,8 +650,13 @@ async function commandQuery (positional, options) {
572
650
  try {
573
651
  answer = queryStore(engine, store, sparql)
574
652
  } catch (error) {
575
- if (error instanceof StoreOperationError) return reportStoreFailure(error)
576
- throw error
653
+ if (!(error instanceof StoreOperationError)) throw error
654
+ if (!error.stackLimit) return reportStoreFailure(error)
655
+ const retried = await onBiggerStack(options, quiet,
656
+ () => queryStoreOnWorker(root, named, sparql))
657
+ if (retried === null) return reportStoreFailure(error)
658
+ if (typeof retried === 'number') return retried
659
+ answer = retried
577
660
  }
578
661
  const { plan, result, blobBytes } = answer
579
662
  if (!quiet) {
@@ -706,7 +789,8 @@ async function commandPack (positional, options) {
706
789
  let answer
707
790
  try {
708
791
  answer = await runPack(
709
- { kind: 'pack', input, output, syntax, layout, base: packBase(input, options) },
792
+ { kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
793
+ batchBytes: options['batch-bytes'] },
710
794
  quiet
711
795
  ? undefined
712
796
  : (progress) => {
package/bin/pack-host.mjs CHANGED
@@ -46,7 +46,7 @@ import { StoreHostError, listGeneration, readWhole } from '../store-host/index.m
46
46
  import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
47
47
  import { loadEngine } from './engine.mjs'
48
48
  import { PackError, packFile, packSupported, verifyGeneration } from './pack.mjs'
49
- import { openStore } from './store.mjs'
49
+ import { StoreOperationError, openStore } from './store.mjs'
50
50
 
51
51
  const isDeno = typeof globalThis.Deno !== 'undefined'
52
52
 
@@ -144,6 +144,16 @@ export function describeError (error) {
144
144
  path: error.path === undefined ? null : error.path
145
145
  }
146
146
  }
147
+ if (error instanceof StoreOperationError) {
148
+ return {
149
+ type: 'StoreOperationError',
150
+ message: error.message,
151
+ capValue: error.capValue,
152
+ capLimit: error.capLimit,
153
+ digestKey: error.digestKey,
154
+ stackLimit: error.stackLimit
155
+ }
156
+ }
147
157
  return {
148
158
  type: 'Error',
149
159
  message: error && error.message ? String(error.message) : String(error)
@@ -160,6 +170,14 @@ export function reviveError (description) {
160
170
  const detail = description.path === null ? {} : { path: description.path }
161
171
  return new StoreHostError(description.code, description.message, detail)
162
172
  }
173
+ if (description.type === 'StoreOperationError') {
174
+ return new StoreOperationError(description.message, {
175
+ capValue: description.capValue === null ? undefined : description.capValue,
176
+ capLimit: description.capLimit === null ? undefined : description.capLimit,
177
+ digestKey: description.digestKey === null ? undefined : description.digestKey,
178
+ stackLimit: description.stackLimit
179
+ })
180
+ }
163
181
  return new Error(description.message)
164
182
  }
165
183
 
@@ -197,6 +215,7 @@ export async function packHere (task, onProgress) {
197
215
  syntax: task.syntax,
198
216
  layout: task.layout,
199
217
  base: task.base,
218
+ batchBytes: task.batchBytes,
200
219
  onProgress
201
220
  })
202
221
  return { report }
@@ -280,7 +299,7 @@ async function packInWorker (task, onProgress) {
280
299
  * already re-executed (see `denoReexec`), so this runs in process.
281
300
  *
282
301
  * @param {object} task {kind, ...}: a pack takes
283
- * {input, output, syntax, layout, base}, an activate takes
302
+ * {input, output, syntax, layout, base, batchBytes}, an activate takes
284
303
  * {root, generation}
285
304
  * @param {(progress: object) => void} [onProgress]
286
305
  * @param {object} [options] {worker: false} forces the in-process path
package/bin/pack.mjs CHANGED
@@ -93,16 +93,22 @@ function drain (engine, handle, output) {
93
93
  * @param {string} input the RDF file to read
94
94
  * @param {string} output the generation directory to fill; it must exist
95
95
  * @param {object} options
96
- * @param {string} options.layout `ibk3` or `ibk4`
96
+ * @param {string} options.layout `ibk3`, `ibk4` or `ibk5`
97
97
  * @param {string} options.syntax `turtle`, `trig` or `nquads`
98
98
  * @param {string} [options.base] the base IRI relative IRIs resolve against;
99
99
  * the empty string means no base
100
+ * @param {number|string} [options.batchBytes] source bytes per publication
101
+ * batch for the IBK4 streamed grammars; omitted means the engine's own
102
+ * default. Match the native packer's `--batch-bytes` to get its block set.
100
103
  * @param {(progress: object) => void} [options.onProgress]
101
104
  * @returns {object} the engine's own finish envelope, plus what was written
102
105
  */
103
106
  export function packFile (engine, input, output, options) {
104
107
  const begun = engine.call('packBegin',
105
- [options.syntax, options.layout, typeof options.base === 'string' ? options.base : ''])
108
+ [options.syntax, options.layout,
109
+ typeof options.base === 'string' ? options.base : '',
110
+ options.batchBytes === undefined || options.batchBytes === null
111
+ ? '' : String(options.batchBytes)])
106
112
  const handle = begun.handle
107
113
  if (typeof handle !== 'string') {
108
114
  throw new PackError('packBegin answered no handle')
@@ -0,0 +1,397 @@
1
+ // Giving the store operations a call stack big enough to finish, without
2
+ // asking the reader for a runtime flag.
3
+ // https://github.com/danbri/factoidal/issues/653
4
+ //
5
+ // THE DEFECT THIS FILE EXISTS FOR
6
+ // Several engine paths recurse once per manifest entry or once per row.
7
+ // On a large generation that exceeds the default call stack of Node and
8
+ // of Deno, and the failure is `Maximum call stack size exceeded` rather
9
+ // than an engine refusal. MEASURED 2026-09-05 against the committed
10
+ // module, on a 7,315,251-quad collection of 3,286 blocks in 204 graphs
11
+ // (`/Users/danbri/working/factoidal-skosfull`): `storeQueryPlan` alone
12
+ // overflows on plain `node`, before a single artifact byte is read, and
13
+ // `node --stack-size=60000` clears the plan, the open and every query.
14
+ // Earlier the same limit was narrowed to any query that materialises
15
+ // about 14,576 rows or more, whatever it returns.
16
+ //
17
+ // `factoidal pack` had the same defect and it was solved host-side, on a
18
+ // worker thread with a raised stack (`bin/pack-host.mjs`,
19
+ // https://github.com/danbri/factoidal/issues/649). This file carries that
20
+ // route to the store operations.
21
+ //
22
+ // WHY THE HANDLE ITSELF LIVES IN THE WORKER
23
+ // A handle is STATE INSIDE THE WASM INSTANCE -- the verified, decoded,
24
+ // indexed blocks that `storeOpen` retained. A WebAssembly instance does
25
+ // not cross a thread boundary, so a handle opened on the main thread
26
+ // cannot be queried from a worker, and the reverse. Only two designs are
27
+ // available:
28
+ //
29
+ // (a) the handle lives in the worker, and `query()` and `close()` are
30
+ // messages to it;
31
+ // (b) each query spawns a worker, which re-reads and re-decodes every
32
+ // artifact.
33
+ //
34
+ // (b) destroys the reason a handle exists: `storeOpen` is the expensive
35
+ // call (5.4 s against the collection above, where a query is 0.6 s), and
36
+ // paying it per query is worse than the stateless `queryStore` path. So
37
+ // (a) is what this file implements.
38
+ //
39
+ // THE COST OF (a), STATED
40
+ // - One worker thread per SESSION, not per handle. A session holds one
41
+ // engine and as many handles as the engine's own handle cap allows,
42
+ // so a caller that opens several stores pays for one thread and one
43
+ // copy of the module. `openStoreHandleOnWorker` uses a shared session
44
+ // by default; `{ownWorker: true}` gives a handle its own thread, and
45
+ // its own copy of the 5.6 MB module, when a caller wants failure
46
+ // isolation between stores.
47
+ // - Every call is ASYNCHRONOUS, where the in-process handle is
48
+ // synchronous. A message round trip is the price of the bigger stack.
49
+ // - An extension function registered on the MAIN thread's engine
50
+ // (`bin/ext.mjs`) is not visible to the worker's engine. Register it
51
+ // inside the worker, or use the in-process handle.
52
+ // - The worker keeps the process alive until `close()` or
53
+ // `terminate()`. `unref()` is not used: a server that dropped its
54
+ // last reference would otherwise lose its store silently.
55
+ //
56
+ // WHAT A ONE-SHOT QUERY DOES INSTEAD
57
+ // A single `factoidal query` builds no handle and drops whatever it
58
+ // loads, so a worker is measured overhead it cannot amortise. It runs
59
+ // IN PROCESS, and only when the runtime runs out of frames does it retry
60
+ // once on a worker (`queryStoreOnWorker`). The normal case pays nothing.
61
+
62
+ import { loadEngine } from './engine.mjs'
63
+ import {
64
+ WORKER_STACK_MB, isStackOverflow, reviveError, workerRefused, workerStackMb
65
+ } from './pack-host.mjs'
66
+ import {
67
+ StoreOperationError, openStore, openStoreHandle, planQuery, queryStore
68
+ } from './store.mjs'
69
+
70
+ const isDeno = typeof globalThis.Deno !== 'undefined'
71
+
72
+ export { WORKER_STACK_MB, isStackOverflow, workerRefused, workerStackMb }
73
+
74
+ /**
75
+ * Whether this runtime has the `worker_threads` route at all.
76
+ *
77
+ * Deno has a `node:worker_threads` shim, and it accepts
78
+ * `resourceLimits.stackSizeMb` and does almost nothing with it. MEASURED
79
+ * 2026-09-05, deno 2.9.4 / V8 15.0.245.2 against Node 22.22.2, by
80
+ * counting frames to the overflow inside the worker:
81
+ *
82
+ * runtime default stackSizeMb 64
83
+ * node 41,195 696,555
84
+ * deno 10,835 13,837
85
+ *
86
+ * A 1.28-times raise does not carry a store open that needs sixteen
87
+ * times the default, so Deno takes the re-exec route in
88
+ * `bin/pack-host.mjs` instead: the command runs itself again once with
89
+ * `--v8-flags=--stack-size=...`, which needs --allow-run and --allow-env.
90
+ * A Deno LIBRARY caller cannot re-execute its own host, so
91
+ * `openStoreHandleOnWorker` gives it an in-process handle behind the same
92
+ * asynchronous interface, and the process must be started with
93
+ * deno run --allow-read --v8-flags=--stack-size=65536
94
+ */
95
+ export function workerRouteAvailable () {
96
+ return !isDeno
97
+ }
98
+
99
+ // ------------------------------------------------------------- a session
100
+ //
101
+ // One worker thread, one engine, many handles.
102
+
103
+ let nextRequestId = 1
104
+
105
+ /**
106
+ * A worker thread holding one engine and the handles opened through it.
107
+ *
108
+ * Hold one of these for as long as a process answers questions about a
109
+ * store. `terminate()` drops the thread and everything in it.
110
+ */
111
+ export class StoreWorkerSession {
112
+ constructor (worker) {
113
+ this.worker = worker
114
+ this.pending = new Map()
115
+ this.dead = null
116
+ this.handles = new Set()
117
+ worker.on('message', (message) => {
118
+ const waiting = this.pending.get(message.id)
119
+ if (waiting === undefined) return
120
+ this.pending.delete(message.id)
121
+ if (message.kind === 'error') waiting.reject(reviveError(message.error))
122
+ else waiting.resolve(message.answer)
123
+ })
124
+ worker.on('error', (error) => { this.fail(error) })
125
+ worker.on('exit', (code) => {
126
+ this.fail(new StoreOperationError(
127
+ `the store worker exited with code ${code}`))
128
+ })
129
+ }
130
+
131
+ /** Fail every outstanding request and refuse every later one. */
132
+ fail (error) {
133
+ if (this.dead === null) this.dead = error
134
+ for (const waiting of this.pending.values()) waiting.reject(error)
135
+ this.pending.clear()
136
+ }
137
+
138
+ /** Send one request and await its answer. */
139
+ send (request) {
140
+ if (this.dead !== null) return Promise.reject(this.dead)
141
+ const id = nextRequestId
142
+ nextRequestId += 1
143
+ return new Promise((resolve, reject) => {
144
+ this.pending.set(id, { resolve, reject })
145
+ this.worker.postMessage({ ...request, id })
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Open one store handle inside this session's worker.
151
+ *
152
+ * @param {string} root the collection root that holds CURRENT
153
+ * @param {{generation?: string, keys?: string[], sparql?: string}} options
154
+ * the same options `openStoreHandle` takes, plus `generation` to open
155
+ * a generation that has not been activated
156
+ * @returns {Promise<WorkerStoreHandle>}
157
+ */
158
+ async open (root, options = {}) {
159
+ const envelope = await this.send({
160
+ kind: 'open',
161
+ root,
162
+ generation: typeof options.generation === 'string' ? options.generation : null,
163
+ keys: Array.isArray(options.keys) ? options.keys : null,
164
+ sparql: typeof options.sparql === 'string' ? options.sparql : null
165
+ })
166
+ const handle = new WorkerStoreHandle(this, envelope)
167
+ this.handles.add(handle)
168
+ return handle
169
+ }
170
+
171
+ /** `storeHandleList` from inside the worker: what this session holds. */
172
+ async list () {
173
+ return (await this.send({ kind: 'list' })).list
174
+ }
175
+
176
+ /** Stop the thread. Every handle it held is gone with it. */
177
+ async terminate () {
178
+ if (this.dead === null) this.dead = new StoreOperationError(
179
+ 'this store worker session is terminated')
180
+ for (const handle of this.handles) handle.closed = true
181
+ this.handles.clear()
182
+ await this.worker.terminate()
183
+ }
184
+ }
185
+
186
+ /**
187
+ * A store handle held inside a worker thread.
188
+ *
189
+ * The same envelope fields as the in-process `StoreHandle`, and the same
190
+ * two methods, except that both are asynchronous. The wasm module is
191
+ * single-threaded, so two `query()` calls on one session are served in
192
+ * the order they were sent.
193
+ */
194
+ export class WorkerStoreHandle {
195
+ constructor (session, envelope) {
196
+ this.session = session
197
+ this.handle = envelope.handle
198
+ this.identity = envelope.identity
199
+ this.layout = envelope.layout
200
+ this.wireVersion = envelope.wireVersion
201
+ this.artifacts = envelope.artifacts
202
+ this.bytes = envelope.bytes
203
+ this.rows = envelope.rows
204
+ this.closed = false
205
+ // Set when this handle owns its session, so `close()` stops the thread.
206
+ this.ownSession = false
207
+ }
208
+
209
+ /** One SPARQL query against the retained blocks. The envelope is the
210
+ * one the in-process handle answers with. */
211
+ async query (sparql) {
212
+ if (this.closed) {
213
+ throw new StoreOperationError(`store handle ${this.handle} is closed`)
214
+ }
215
+ return (await this.session.send({
216
+ kind: 'query', handle: this.handle, sparql
217
+ })).result
218
+ }
219
+
220
+ /** Drop the handle and everything it retained. Idempotent. */
221
+ async close () {
222
+ if (this.closed) return
223
+ this.closed = true
224
+ this.session.handles.delete(this)
225
+ if (this.ownSession) {
226
+ await this.session.terminate()
227
+ return
228
+ }
229
+ if (this.session.dead !== null) return
230
+ await this.session.send({ kind: 'close', handle: this.handle })
231
+ }
232
+ }
233
+
234
+ // ------------------------------------------------------- starting one
235
+
236
+ /** Start a worker thread with a stack big enough for the store paths. */
237
+ async function startWorker () {
238
+ if (!workerRouteAvailable()) {
239
+ throw new StoreOperationError(
240
+ 'this runtime has no worker_threads route for the store operations; ' +
241
+ 'raise the call stack itself instead')
242
+ }
243
+ const { Worker } = await import('node:worker_threads')
244
+ const entry = new URL('./store-worker.mjs', import.meta.url)
245
+ const worker = new Worker(entry, {
246
+ resourceLimits: { stackSizeMb: workerStackMb() }
247
+ })
248
+ const session = new StoreWorkerSession(worker)
249
+ // Load the engine now, so the first query measures the query.
250
+ await session.send({ kind: 'warm' })
251
+ return session
252
+ }
253
+
254
+ /** Start a session of this session's own. */
255
+ export async function openStoreWorkerSession () {
256
+ return await startWorker()
257
+ }
258
+
259
+ let sharedSession = null
260
+
261
+ /** The session `openStoreHandleOnWorker` uses when the caller names none:
262
+ * one thread, one engine, shared by every handle in the process. */
263
+ export async function sharedStoreWorkerSession () {
264
+ if (sharedSession === null) sharedSession = startWorker()
265
+ try {
266
+ return await sharedSession
267
+ } catch (error) {
268
+ sharedSession = null
269
+ throw error
270
+ }
271
+ }
272
+
273
+ /** Stop the shared session, if one was started. */
274
+ export async function closeSharedStoreWorkerSession () {
275
+ if (sharedSession === null) return
276
+ const session = sharedSession
277
+ sharedSession = null
278
+ await (await session).terminate()
279
+ }
280
+
281
+ /**
282
+ * Open one store handle on a worker thread with a raised call stack.
283
+ *
284
+ * This is the handle a long-lived process wants against a large store: an
285
+ * MCP server, a chat tool loop, a SPARQL endpoint. It is asynchronous
286
+ * where `openStoreHandle` is synchronous, and it needs no runtime flag.
287
+ *
288
+ * @param {string} root the collection root that holds CURRENT
289
+ * @param {{generation?: string, keys?: string[], sparql?: string,
290
+ * session?: StoreWorkerSession, ownWorker?: boolean}} options
291
+ * @returns {Promise<WorkerStoreHandle>}
292
+ */
293
+ export async function openStoreHandleOnWorker (root, options = {}) {
294
+ if (!workerRouteAvailable() || workerRefused(options)) {
295
+ return await openHere(root, options)
296
+ }
297
+ if (options.session instanceof StoreWorkerSession) {
298
+ return await options.session.open(root, options)
299
+ }
300
+ if (options.ownWorker === true) {
301
+ const session = await startWorker()
302
+ const handle = await session.open(root, options)
303
+ handle.ownSession = true
304
+ return handle
305
+ }
306
+ const session = await sharedStoreWorkerSession()
307
+ return await session.open(root, options)
308
+ }
309
+
310
+ // -------------------------------- the in-process facade, for Deno
311
+ //
312
+ // Same two methods, same envelopes, no thread. It exists so one caller
313
+ // works on both runtimes; on Deno the stack must come from the host
314
+ // (`--v8-flags=--stack-size=65536`), because Deno's worker_threads shim
315
+ // does not raise it (see `workerRouteAvailable`).
316
+
317
+ /** A handle held in this process, behind the asynchronous interface. */
318
+ export class LocalStoreHandle {
319
+ constructor (handle) {
320
+ this.local = handle
321
+ this.handle = handle.handle
322
+ this.identity = handle.identity
323
+ this.layout = handle.layout
324
+ this.wireVersion = handle.wireVersion
325
+ this.artifacts = handle.artifacts
326
+ this.bytes = handle.bytes
327
+ this.rows = handle.rows
328
+ }
329
+
330
+ get closed () { return this.local.closed }
331
+
332
+ async query (sparql) { return this.local.query(sparql) }
333
+
334
+ async close () { this.local.close() }
335
+ }
336
+
337
+ /** Open a handle in this process, behind the asynchronous interface. */
338
+ async function openHere (root, options) {
339
+ const engine = await loadEngine()
340
+ const store = openStore(root,
341
+ typeof options.generation === 'string' ? options.generation : null)
342
+ const pick = {}
343
+ if (Array.isArray(options.keys)) pick.keys = options.keys
344
+ else if (typeof options.sparql === 'string') pick.sparql = options.sparql
345
+ return new LocalStoreHandle(openStoreHandle(engine, store, pick))
346
+ }
347
+
348
+ // ------------------------------------------------- the one-shot retry
349
+
350
+ /**
351
+ * Run one stateless query on a worker thread, then stop the thread.
352
+ *
353
+ * What `factoidal query` retries with after the in-process attempt ran
354
+ * out of frames. It answers exactly what `queryStore` answers.
355
+ *
356
+ * @returns {Promise<{plan: object, result: object, blobBytes: number}>}
357
+ */
358
+ export async function queryStoreOnWorker (root, generation, sparql) {
359
+ if (!workerRouteAvailable()) {
360
+ const engine = await loadEngine()
361
+ const store = openStore(root, generation ?? null)
362
+ const answer = queryStore(engine, store, sparql)
363
+ return { plan: answer.plan, result: answer.result, blobBytes: answer.blobBytes }
364
+ }
365
+ const session = await startWorker()
366
+ try {
367
+ return await session.send({
368
+ kind: 'query-once', root, generation: generation ?? null, sparql
369
+ })
370
+ } finally {
371
+ await session.terminate()
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Plan one query on a worker thread, then stop the thread.
377
+ *
378
+ * `storeQueryPlan` walks the manifest, and on a collection of a few
379
+ * thousand blocks that walk alone exceeds the default stack. What
380
+ * `factoidal query --explain` retries with.
381
+ *
382
+ * @returns {Promise<object>} the plan envelope
383
+ */
384
+ export async function planQueryOnWorker (root, generation, sparql) {
385
+ if (!workerRouteAvailable()) {
386
+ const engine = await loadEngine()
387
+ return planQuery(engine, openStore(root, generation ?? null), sparql)
388
+ }
389
+ const session = await startWorker()
390
+ try {
391
+ return (await session.send({
392
+ kind: 'plan', root, generation: generation ?? null, sparql
393
+ })).plan
394
+ } finally {
395
+ await session.terminate()
396
+ }
397
+ }