@factoidal/core 0.2.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 (132) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/NOTICE +31 -0
  3. package/README.md +131 -1
  4. package/bin/engine.mjs +124 -0
  5. package/bin/factoidal.mjs +894 -0
  6. package/bin/pack-host.mjs +341 -0
  7. package/bin/pack-worker.mjs +31 -0
  8. package/bin/pack.mjs +174 -0
  9. package/bin/store.mjs +238 -0
  10. package/l4-assets/l4factoidal.js +125 -5
  11. package/l4-assets/l4factoidal.mjs +1 -1
  12. package/l4-assets/l4factoidal.wasm +0 -0
  13. package/l4-assets/package.json +4 -0
  14. package/l4-assets/version.json +5 -5
  15. package/l4.d.ts +14 -0
  16. package/l4.js +15 -1
  17. package/package.json +17 -2
  18. package/sample-store/CURRENT +1 -0
  19. package/sample-store/gen-1/manifest.sbm2 +0 -0
  20. package/sample-store/gen-1/manifest.tsv +14 -0
  21. package/sample-store/gen-1/predicate-0.ibk3 +0 -0
  22. package/sample-store/gen-1/predicate-0.ibk3.merkle +1 -0
  23. package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
  24. package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +1 -0
  25. package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
  26. package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +1 -0
  27. package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
  28. package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
  29. package/sample-store/gen-1/predicate-1.ibk3 +0 -0
  30. package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
  31. package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
  32. package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
  33. package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
  34. package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +1 -0
  35. package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
  36. package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +1 -0
  37. package/sample-store/gen-1/predicate-10.ibk3 +0 -0
  38. package/sample-store/gen-1/predicate-10.ibk3.merkle +1 -0
  39. package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
  40. package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
  41. package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
  42. package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +1 -0
  43. package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
  44. package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +1 -0
  45. package/sample-store/gen-1/predicate-11.ibk3 +0 -0
  46. package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
  47. package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
  48. package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +1 -0
  49. package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
  50. package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +1 -0
  51. package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
  52. package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +1 -0
  53. package/sample-store/gen-1/predicate-12.ibk3 +0 -0
  54. package/sample-store/gen-1/predicate-12.ibk3.merkle +1 -0
  55. package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
  56. package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +1 -0
  57. package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
  58. package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +1 -0
  59. package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
  60. package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +1 -0
  61. package/sample-store/gen-1/predicate-2.ibk3 +0 -0
  62. package/sample-store/gen-1/predicate-2.ibk3.merkle +1 -0
  63. package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
  64. package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +1 -0
  65. package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
  66. package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
  67. package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
  68. package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
  69. package/sample-store/gen-1/predicate-3.ibk3 +0 -0
  70. package/sample-store/gen-1/predicate-3.ibk3.merkle +1 -0
  71. package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
  72. package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +1 -0
  73. package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
  74. package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +1 -0
  75. package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
  76. package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +1 -0
  77. package/sample-store/gen-1/predicate-4.ibk3 +0 -0
  78. package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
  79. package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
  80. package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +1 -0
  81. package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
  82. package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +1 -0
  83. package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
  84. package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +2 -0
  85. package/sample-store/gen-1/predicate-5.ibk3 +0 -0
  86. package/sample-store/gen-1/predicate-5.ibk3.merkle +1 -0
  87. package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
  88. package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +1 -0
  89. package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
  90. package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +1 -0
  91. package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
  92. package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +1 -0
  93. package/sample-store/gen-1/predicate-6.ibk3 +0 -0
  94. package/sample-store/gen-1/predicate-6.ibk3.merkle +1 -0
  95. package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
  96. package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +1 -0
  97. package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
  98. package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +1 -0
  99. package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
  100. package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
  101. package/sample-store/gen-1/predicate-7.ibk3 +0 -0
  102. package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
  103. package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
  104. package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +2 -0
  105. package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
  106. package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +1 -0
  107. package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
  108. package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +1 -0
  109. package/sample-store/gen-1/predicate-8.ibk3 +0 -0
  110. package/sample-store/gen-1/predicate-8.ibk3.merkle +1 -0
  111. package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
  112. package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +1 -0
  113. package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
  114. package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +1 -0
  115. package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
  116. package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +1 -0
  117. package/sample-store/gen-1/predicate-9.ibk3 +0 -0
  118. package/sample-store/gen-1/predicate-9.ibk3.merkle +1 -0
  119. package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
  120. package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +1 -0
  121. package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
  122. package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +3 -0
  123. package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
  124. package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +2 -0
  125. package/sample-store.d.ts +16 -0
  126. package/sample-store.mjs +37 -0
  127. package/store-host/deno.mjs +262 -0
  128. package/store-host/errors.mjs +55 -0
  129. package/store-host/index.mjs +247 -0
  130. package/store-host/node.mjs +273 -0
  131. package/store-host/paths.mjs +77 -0
  132. package/version.json +22 -21
@@ -0,0 +1,247 @@
1
+ // The store host: the byte-moving half of the persisted Shardborough store
2
+ // when the engine runs as WebAssembly instead of a native binary.
3
+ // https://github.com/danbri/factoidal/issues/641
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Open a file by name, move its bytes, sync, rename, list a directory.
7
+ // Nothing else. It never parses a manifest, checks a digest, decodes a
8
+ // block, or decides which artifact answers a query. Those are format
9
+ // decisions and they live in the Lean source (iron rule 7). A reviewer
10
+ // who finds a magic number, a field offset or a hash in this directory
11
+ // has found a rule violation.
12
+ //
13
+ // WHAT IT CORRESPONDS TO
14
+ // `formal/lean4/Harness/PosixRangeIO.lean` declares three `@[extern]`
15
+ // primitives, realised in `formal/lean4/ffi/block_pread.c`:
16
+ //
17
+ // l4_block_pread -> readRange
18
+ // l4_delta_log_append_sync_at_size -> appendSyncAtSize
19
+ // l4_atomic_replace_file_sync -> atomicReplace
20
+ //
21
+ // `readWhole`, `openCollection` and `listGeneration` have no extern of
22
+ // their own; the native tools use Lean's own `IO.FS` for them.
23
+ //
24
+ // WHERE THE HOST DIFFERS FROM THE C EXTERNS
25
+ //
26
+ // 1. No advisory lock on the append. `l4_delta_log_append_sync_at_size`
27
+ // holds `flock(fd, LOCK_EX)` across the size check, the write loop and
28
+ // the fsync, so exactly one of two concurrent writers appends and the
29
+ // loser gets `false` and retries. Neither Node nor Deno has `flock` in
30
+ // its standard library, and this module takes no dependency, so the
31
+ // sequence here is fstat, then append, then fsync, with no lock. Two
32
+ // writers that observe the same size therefore both append, and the
33
+ // delta log gets two batches where the protocol expects one. Use this
34
+ // function only where a single writer is guaranteed. This is the one
35
+ // divergence that can corrupt a store; see issue 641 stage 4.
36
+ //
37
+ // 2. Failures raise instead of returning false. Every C extern collapses
38
+ // an open, write or fsync failure into an empty result or `false`. A
39
+ // `false` there is therefore ambiguous: it can mean the size did not
40
+ // match, or that the disk is full. Here `false` from
41
+ // `appendSyncAtSize` means only that the file size was not the
42
+ // expected one; an I/O failure throws a `StoreHostError`. A caller
43
+ // that wants the extern's exact shape catches and maps to false.
44
+ //
45
+ // 3. `readRange` raises on a short read. The extern returns an empty
46
+ // array on any failure and `Harness.PosixRangeIO.readRange?` turns a
47
+ // wrong size into `none`. Here a short read throws with code
48
+ // SHORT_READ, which carries the same refusal and says why.
49
+ //
50
+ // 4. `atomicReplace` returning false does not mean nothing changed. The C
51
+ // version returns false when the parent-directory fsync fails, after
52
+ // the rename has already succeeded. This module reports it the same
53
+ // way: false means the new bytes may be in place but their directory
54
+ // entry is not known to be durable.
55
+ //
56
+ // 5. Deno has no positional read. `readRange` opens its own handle, seeks
57
+ // and reads. The handle is private to the call, so there is no cursor
58
+ // to race, but it is a seek plus a read and not one `pread`.
59
+ //
60
+ // 6. Offsets and lengths are JavaScript numbers. The externs take
61
+ // `UInt64`. Anything at or above 2^53 is rejected with BAD_ARGUMENT
62
+ // rather than silently rounded.
63
+ //
64
+ // 7. Directory fsync is not portable. `atomicReplace` opens the parent
65
+ // directory and fsyncs it, which works on Linux and macOS under both
66
+ // runtimes. Windows refuses to open a directory as a file; the call
67
+ // returns false there and the rename still happened (point 4).
68
+
69
+ import { StoreHostError, requireBytes, requireCount, requirePath } from './errors.mjs'
70
+ import { joinPath, requireChildName } from './paths.mjs'
71
+
72
+ const isDeno = typeof globalThis.Deno !== 'undefined' &&
73
+ typeof globalThis.Deno.openSync === 'function'
74
+
75
+ const impl = isDeno ? await import('./deno.mjs') : await import('./node.mjs')
76
+
77
+ /** 'node' or 'deno' — which implementation load-time selection chose. */
78
+ export const runtime = impl.runtime
79
+
80
+ export { StoreHostError }
81
+
82
+ /**
83
+ * Read a whole file.
84
+ * @param {string} path
85
+ * @returns {Uint8Array} a fresh array of exactly the file's bytes
86
+ */
87
+ export function readWhole (path) {
88
+ requirePath(path, 'path')
89
+ return impl.readWhole(path)
90
+ }
91
+
92
+ /**
93
+ * Read exactly `length` bytes starting at `offset`, without touching any
94
+ * shared file cursor. The counterpart of `l4_block_pread`.
95
+ * @param {string} path
96
+ * @param {number} offset
97
+ * @param {number} length
98
+ * @returns {Uint8Array} exactly `length` bytes
99
+ * @throws {StoreHostError} code SHORT_READ when the file has fewer bytes
100
+ */
101
+ export function readRange (path, offset, length) {
102
+ requirePath(path, 'path')
103
+ requireCount(offset, 'offset')
104
+ requireCount(length, 'length')
105
+ return impl.readRange(path, offset, length)
106
+ }
107
+
108
+ /**
109
+ * Read at most `length` bytes starting at `offset`. A short read is not an
110
+ * error: fewer bytes than asked for means the file ended there, and zero
111
+ * bytes means the offset is at or past the end. This is the shape a
112
+ * streaming reader needs; `readRange` is the exact-length shape the store
113
+ * uses when the manifest already committed a length.
114
+ * @param {string} path
115
+ * @param {number} offset
116
+ * @param {number} length
117
+ * @returns {Uint8Array} between 0 and `length` bytes
118
+ */
119
+ export function readChunk (path, offset, length) {
120
+ requirePath(path, 'path')
121
+ requireCount(offset, 'offset')
122
+ requireCount(length, 'length')
123
+ return impl.readChunk(path, offset, length)
124
+ }
125
+
126
+ /**
127
+ * Create `path`, write `bytes` and fsync. Refuses an existing file with
128
+ * code FILE_EXISTS: a generation directory is immutable once written, so
129
+ * a silent overwrite would hide a name collision rather than report it.
130
+ * @param {string} path
131
+ * @param {Uint8Array} bytes
132
+ */
133
+ export function writeNew (path, bytes) {
134
+ requirePath(path, 'path')
135
+ impl.writeNew(path, bytes)
136
+ }
137
+
138
+ /**
139
+ * Create a directory and every missing parent.
140
+ * @param {string} path
141
+ */
142
+ export function makeDirectory (path) {
143
+ requirePath(path, 'path')
144
+ impl.makeDirectory(path)
145
+ }
146
+
147
+ /**
148
+ * Append `bytes` only if the file currently has exactly `expectedSize`
149
+ * bytes, then fsync it. The counterpart of
150
+ * `l4_delta_log_append_sync_at_size`, minus its advisory lock
151
+ * (divergence 1 above). Creates the file when absent, in which case the
152
+ * only size that matches is 0.
153
+ * @param {string} path
154
+ * @param {Uint8Array} bytes
155
+ * @param {number} expectedSize
156
+ * @returns {boolean} true when the append happened and was synced;
157
+ * false when the file's size was not `expectedSize`
158
+ */
159
+ export function appendSyncAtSize (path, bytes, expectedSize) {
160
+ requirePath(path, 'path')
161
+ requireBytes(bytes, 'bytes')
162
+ requireCount(expectedSize, 'expectedSize')
163
+ return impl.appendSyncAtSize(path, bytes, expectedSize)
164
+ }
165
+
166
+ /**
167
+ * Replace a file's whole contents so that a reader sees either the old
168
+ * bytes or the new bytes and never a mixture: write a temporary file in
169
+ * the same directory, fsync it, rename it over the target, fsync the
170
+ * directory. The counterpart of `l4_atomic_replace_file_sync`.
171
+ * @param {string} path
172
+ * @param {Uint8Array} bytes
173
+ * @returns {boolean} true when the replacement is durable; false when the
174
+ * rename succeeded but the directory fsync did not (divergence 4)
175
+ */
176
+ export function atomicReplace (path, bytes) {
177
+ requirePath(path, 'path')
178
+ requireBytes(bytes, 'bytes')
179
+ return impl.atomicReplace(path, bytes)
180
+ }
181
+
182
+ /**
183
+ * List the regular files of one directory with their sizes, sorted by
184
+ * name. Subdirectories and symbolic links to directories are left out.
185
+ * @param {string} directory
186
+ * @returns {{name: string, size: number}[]}
187
+ */
188
+ export function listGeneration (directory) {
189
+ requirePath(directory, 'directory')
190
+ return impl.listGeneration(directory)
191
+ }
192
+
193
+ // The manifest file names a generation directory can carry, in the order
194
+ // `Harness.ShardMerklePread.readManifest` tries them. This module reads
195
+ // whichever exists and returns its bytes untouched; it does not look
196
+ // inside either one.
197
+ const MANIFEST_NAMES = ['manifest.sbm2', 'manifest.sbm1']
198
+
199
+ /**
200
+ * Open an activated collection: read `CURRENT`, and return the generation
201
+ * name it holds together with the raw manifest bytes of that generation.
202
+ *
203
+ * The returned `manifest` is bytes. Deciding what those bytes mean —
204
+ * which wire version, which artifacts, which digests — is the engine's
205
+ * job, not this module's.
206
+ *
207
+ * @param {string} root the collection root that holds CURRENT
208
+ * @returns {{root: string, generation: string, generationDir: string,
209
+ * manifestName: string, manifest: Uint8Array}}
210
+ * @throws {StoreHostError} code NO_CURRENT when the root has no CURRENT,
211
+ * NO_MANIFEST when the generation carries none of the manifest names
212
+ */
213
+ export function openCollection (root) {
214
+ requirePath(root, 'root')
215
+ const pointerPath = joinPath(root, 'CURRENT')
216
+ let pointerBytes
217
+ try {
218
+ pointerBytes = impl.readWhole(pointerPath)
219
+ } catch (cause) {
220
+ throw new StoreHostError(
221
+ 'NO_CURRENT',
222
+ `${root} has no readable CURRENT pointer`,
223
+ { path: pointerPath, cause }
224
+ )
225
+ }
226
+ // CURRENT holds a UTF-8 child-generation name (spec section 6.4). Trailing
227
+ // ASCII whitespace is tolerated so a pointer written by hand still opens.
228
+ const generation = requireChildName(
229
+ new TextDecoder('utf-8', { fatal: true }).decode(pointerBytes).replace(/[\r\n\t ]+$/, ''),
230
+ 'CURRENT'
231
+ )
232
+ const generationDir = joinPath(root, generation)
233
+ for (const manifestName of MANIFEST_NAMES) {
234
+ try {
235
+ const manifest = impl.readWhole(joinPath(generationDir, manifestName))
236
+ return { root, generation, generationDir, manifestName, manifest }
237
+ } catch (error) {
238
+ if (error instanceof StoreHostError && error.code === 'OPEN_FAILED') continue
239
+ throw error
240
+ }
241
+ }
242
+ throw new StoreHostError(
243
+ 'NO_MANIFEST',
244
+ `${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`,
245
+ { path: generationDir }
246
+ )
247
+ }
@@ -0,0 +1,273 @@
1
+ // Node implementation of the four host primitives the Lean persisted store
2
+ // needs (Harness/PosixRangeIO.lean). See ./index.mjs for the contract and
3
+ // for the places where Node's semantics differ from the C externs.
4
+ //
5
+ // Nothing here parses, verifies or interprets a byte. It opens files,
6
+ // moves bytes, and syncs.
7
+
8
+ import {
9
+ closeSync, fstatSync, fsyncSync, mkdirSync, openSync, readSync, readdirSync,
10
+ renameSync, statSync, unlinkSync, writeSync
11
+ } from 'node:fs'
12
+
13
+ import { StoreHostError } from './errors.mjs'
14
+ import { baseName, dirName, joinPath } from './paths.mjs'
15
+
16
+ export const runtime = 'node'
17
+
18
+ function wrap (code, message, path, cause) {
19
+ return new StoreHostError(code, `${message}: ${String(cause && cause.message ? cause.message : cause)}`, { path, cause })
20
+ }
21
+
22
+ function isInterrupt (error) {
23
+ return error && (error.code === 'EINTR' || error.code === 'EAGAIN')
24
+ }
25
+
26
+ function openRead (path) {
27
+ try {
28
+ return openSync(path, 'r')
29
+ } catch (cause) {
30
+ throw wrap('OPEN_FAILED', `cannot open ${path} for reading`, path, cause)
31
+ }
32
+ }
33
+
34
+ /** Read `length` bytes at `offset` into `out` at `outOffset`. Returns how many. */
35
+ function preadInto (fd, out, outOffset, length, offset, path) {
36
+ let done = 0
37
+ while (done < length) {
38
+ let read
39
+ try {
40
+ read = readSync(fd, out, outOffset + done, length - done, offset + done)
41
+ } catch (cause) {
42
+ if (isInterrupt(cause)) continue
43
+ throw wrap('READ_FAILED', `read failed on ${path}`, path, cause)
44
+ }
45
+ if (read === 0) break
46
+ done += read
47
+ }
48
+ return done
49
+ }
50
+
51
+ export function readWhole (path) {
52
+ const fd = openRead(path)
53
+ try {
54
+ const size = fstatSync(fd).size
55
+ if (!Number.isSafeInteger(size)) {
56
+ throw new StoreHostError('FILE_TOO_LARGE', `${path} is larger than 2^53 - 1 bytes`, { path })
57
+ }
58
+ const out = new Uint8Array(size)
59
+ const done = preadInto(fd, out, 0, size, 0, path)
60
+ if (done !== size) {
61
+ throw new StoreHostError('SHORT_READ', `${path} shrank during the read (${done} of ${size} bytes)`, { path })
62
+ }
63
+ return out
64
+ } finally {
65
+ closeSync(fd)
66
+ }
67
+ }
68
+
69
+ export function readRange (path, offset, length) {
70
+ if (length === 0) return new Uint8Array(0)
71
+ const fd = openRead(path)
72
+ try {
73
+ const out = new Uint8Array(length)
74
+ const done = preadInto(fd, out, 0, length, offset, path)
75
+ if (done !== length) {
76
+ throw new StoreHostError(
77
+ 'SHORT_READ',
78
+ `${path} returned ${done} of ${length} bytes at offset ${offset}`,
79
+ { path }
80
+ )
81
+ }
82
+ return out
83
+ } finally {
84
+ closeSync(fd)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read at most `length` bytes at `offset`. Unlike `readRange` a short read
90
+ * is NOT an error: it is how the reader learns it reached the end of the
91
+ * file. The packer streams a file it did not stat first, so it needs this
92
+ * shape rather than the exact-length one.
93
+ */
94
+ export function readChunk (path, offset, length) {
95
+ if (length === 0) return new Uint8Array(0)
96
+ const fd = openRead(path)
97
+ try {
98
+ const out = new Uint8Array(length)
99
+ const done = preadInto(fd, out, 0, length, offset, path)
100
+ return done === length ? out : out.subarray(0, done)
101
+ } finally {
102
+ closeSync(fd)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Create `path` and write `bytes`. Refuses to replace an existing file:
108
+ * a generation directory is immutable once written, and a packer that
109
+ * silently overwrote an artifact would hide a name collision.
110
+ */
111
+ export function writeNew (path, bytes) {
112
+ let fd
113
+ try {
114
+ // 'wx' is O_WRONLY | O_CREAT | O_EXCL.
115
+ fd = openSync(path, 'wx')
116
+ } catch (cause) {
117
+ if (cause && cause.code === 'EEXIST') {
118
+ throw new StoreHostError('FILE_EXISTS', `${path} already exists`, { path })
119
+ }
120
+ throw wrap('OPEN_FAILED', `cannot create ${path}`, path, cause)
121
+ }
122
+ try {
123
+ let done = 0
124
+ while (done < bytes.length) {
125
+ let written
126
+ try {
127
+ written = writeSync(fd, bytes, done, bytes.length - done, null)
128
+ } catch (cause) {
129
+ if (isInterrupt(cause)) continue
130
+ throw wrap('WRITE_FAILED', `write failed on ${path}`, path, cause)
131
+ }
132
+ done += written
133
+ }
134
+ fsyncSync(fd)
135
+ } finally {
136
+ closeSync(fd)
137
+ }
138
+ }
139
+
140
+ /** Create a directory and every missing parent. */
141
+ export function makeDirectory (path) {
142
+ try {
143
+ mkdirSync(path, { recursive: true })
144
+ } catch (cause) {
145
+ throw wrap('MKDIR_FAILED', `cannot create ${path}`, path, cause)
146
+ }
147
+ }
148
+
149
+ export function appendSyncAtSize (path, bytes, expectedSize) {
150
+ let fd
151
+ try {
152
+ // 'a' is O_WRONLY | O_CREAT | O_APPEND, matching the C extern's open.
153
+ fd = openSync(path, 'a')
154
+ } catch (cause) {
155
+ throw wrap('OPEN_FAILED', `cannot open ${path} for append`, path, cause)
156
+ }
157
+ try {
158
+ const size = fstatSync(fd).size
159
+ if (size !== expectedSize) return false
160
+ let done = 0
161
+ while (done < bytes.length) {
162
+ let written
163
+ try {
164
+ // position null keeps the O_APPEND placement the C extern relies on.
165
+ written = writeSync(fd, bytes, done, bytes.length - done, null)
166
+ } catch (cause) {
167
+ if (isInterrupt(cause)) continue
168
+ throw wrap('WRITE_FAILED', `append failed on ${path}`, path, cause)
169
+ }
170
+ done += written
171
+ }
172
+ try {
173
+ fsyncSync(fd)
174
+ } catch (cause) {
175
+ throw wrap('FSYNC_FAILED', `fsync failed on ${path}`, path, cause)
176
+ }
177
+ return true
178
+ } finally {
179
+ closeSync(fd)
180
+ }
181
+ }
182
+
183
+ function temporaryName (path) {
184
+ const suffix = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')
185
+ return joinPath(dirName(path), baseName(path) + '.tmp.' + suffix)
186
+ }
187
+
188
+ function fsyncDirectory (directory) {
189
+ let fd
190
+ try {
191
+ fd = openSync(directory, 'r')
192
+ } catch (cause) {
193
+ throw wrap('DIR_OPEN_FAILED', `cannot open ${directory} to sync it`, directory, cause)
194
+ }
195
+ try {
196
+ fsyncSync(fd)
197
+ } finally {
198
+ closeSync(fd)
199
+ }
200
+ }
201
+
202
+ export function atomicReplace (path, bytes) {
203
+ const directory = dirName(path)
204
+ let temporary = null
205
+ let fd = null
206
+ try {
207
+ for (let attempt = 0; attempt < 8 && fd === null; attempt += 1) {
208
+ temporary = temporaryName(path)
209
+ try {
210
+ // 'wx' is O_WRONLY | O_CREAT | O_EXCL, the exclusive create that
211
+ // makes the name ours the way mkstemp does in the C extern.
212
+ fd = openSync(temporary, 'wx')
213
+ } catch (cause) {
214
+ if (cause && cause.code === 'EEXIST') continue
215
+ throw wrap('OPEN_FAILED', `cannot create ${temporary}`, temporary, cause)
216
+ }
217
+ }
218
+ if (fd === null) {
219
+ throw new StoreHostError('TEMP_NAME_EXHAUSTED', `no free temporary name beside ${path}`, { path })
220
+ }
221
+ let done = 0
222
+ while (done < bytes.length) {
223
+ let written
224
+ try {
225
+ written = writeSync(fd, bytes, done, bytes.length - done, null)
226
+ } catch (cause) {
227
+ if (isInterrupt(cause)) continue
228
+ throw wrap('WRITE_FAILED', `write failed on ${temporary}`, temporary, cause)
229
+ }
230
+ done += written
231
+ }
232
+ fsyncSync(fd)
233
+ closeSync(fd)
234
+ fd = null
235
+ renameSync(temporary, path)
236
+ temporary = null
237
+ try {
238
+ fsyncDirectory(directory)
239
+ } catch (_error) {
240
+ // The C extern also returns false here, with the replacement already
241
+ // done. Reported the same way; see ./index.mjs for what false means.
242
+ return false
243
+ }
244
+ return true
245
+ } catch (error) {
246
+ if (fd !== null) closeSync(fd)
247
+ if (temporary !== null) {
248
+ try { unlinkSync(temporary) } catch (_ignored) { /* the temporary may not exist */ }
249
+ }
250
+ throw error
251
+ }
252
+ }
253
+
254
+ export function listGeneration (directory) {
255
+ let names
256
+ try {
257
+ names = readdirSync(directory)
258
+ } catch (cause) {
259
+ throw wrap('DIR_READ_FAILED', `cannot list ${directory}`, directory, cause)
260
+ }
261
+ const out = []
262
+ for (const name of names.sort()) {
263
+ let info
264
+ try {
265
+ info = statSync(joinPath(directory, name))
266
+ } catch (_cause) {
267
+ continue // a name that vanished between readdir and stat
268
+ }
269
+ if (!info.isFile()) continue
270
+ out.push({ name, size: info.size })
271
+ }
272
+ return out
273
+ }
@@ -0,0 +1,77 @@
1
+ // Path plumbing for the store host. No dependency on `node:path`, so the
2
+ // same file loads under Deno without the Node compatibility layer.
3
+ //
4
+ // These functions join and split path strings. They make no decision about
5
+ // what a Shardborough generation contains.
6
+
7
+ import { StoreHostError } from './errors.mjs'
8
+
9
+ const SEPARATORS = ['/', '\\']
10
+
11
+ function isSeparator (character) {
12
+ return SEPARATORS.indexOf(character) >= 0
13
+ }
14
+
15
+ /** Join with '/'. Every runtime this module targets accepts '/'. */
16
+ export function joinPath (base, child) {
17
+ if (base.length === 0) return child
18
+ const last = base[base.length - 1]
19
+ return isSeparator(last) ? base + child : base + '/' + child
20
+ }
21
+
22
+ /** The directory part of a path, or '.' when the path has no separator. */
23
+ export function dirName (path) {
24
+ let end = path.length
25
+ while (end > 1 && isSeparator(path[end - 1])) end -= 1
26
+ let index = end - 1
27
+ while (index >= 0 && !isSeparator(path[index])) index -= 1
28
+ if (index < 0) return '.'
29
+ if (index === 0) return path[0]
30
+ return path.slice(0, index)
31
+ }
32
+
33
+ /** The final component of a path. */
34
+ export function baseName (path) {
35
+ let end = path.length
36
+ while (end > 1 && isSeparator(path[end - 1])) end -= 1
37
+ let index = end - 1
38
+ while (index >= 0 && !isSeparator(path[index])) index -= 1
39
+ return path.slice(index + 1, end)
40
+ }
41
+
42
+ /**
43
+ * A single child name that may be appended to a directory path.
44
+ *
45
+ * `CURRENT` holds a generation name written by the Lean activation path;
46
+ * this module still refuses a value that would leave the collection root
47
+ * when joined. That is a filesystem-safety guard on a string the host is
48
+ * about to turn into a path, not a check of the pointer's format.
49
+ */
50
+ export function requireChildName (name, label) {
51
+ if (typeof name !== 'string' || name.length === 0) {
52
+ throw new StoreHostError('BAD_CHILD_NAME', `${label} is empty`)
53
+ }
54
+ if (name === '.' || name === '..') {
55
+ throw new StoreHostError('BAD_CHILD_NAME', `${label} is "${name}"`)
56
+ }
57
+ for (const character of name) {
58
+ if (isSeparator(character) || character === '\u0000') {
59
+ throw new StoreHostError(
60
+ 'BAD_CHILD_NAME',
61
+ `${label} contains a path separator or NUL byte`
62
+ )
63
+ }
64
+ }
65
+ return name
66
+ }
67
+
68
+ /** Convert a `file:` URL string to a filesystem path. */
69
+ export function fileUrlToPath (url) {
70
+ const text = typeof url === 'string' ? url : String(url)
71
+ if (!text.startsWith('file://')) return text
72
+ let path = decodeURIComponent(text.slice('file://'.length))
73
+ const host = path.indexOf('/')
74
+ if (host > 0) path = path.slice(host)
75
+ if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1)
76
+ return path
77
+ }