@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.
- package/CHANGELOG.md +122 -0
- package/NOTICE +31 -0
- package/README.md +131 -1
- package/bin/engine.mjs +124 -0
- package/bin/factoidal.mjs +894 -0
- package/bin/pack-host.mjs +341 -0
- package/bin/pack-worker.mjs +31 -0
- package/bin/pack.mjs +174 -0
- package/bin/store.mjs +238 -0
- package/l4-assets/l4factoidal.js +125 -5
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/package.json +4 -0
- package/l4-assets/version.json +5 -5
- package/l4.d.ts +14 -0
- package/l4.js +15 -1
- package/package.json +17 -2
- package/sample-store/CURRENT +1 -0
- package/sample-store/gen-1/manifest.sbm2 +0 -0
- package/sample-store/gen-1/manifest.tsv +14 -0
- package/sample-store/gen-1/predicate-0.ibk3 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-10.ibk3 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-11.ibk3 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-12.ibk3 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-2.ibk3 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-3.ibk3 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-4.ibk3 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +2 -0
- package/sample-store/gen-1/predicate-5.ibk3 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-6.ibk3 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
- package/sample-store/gen-1/predicate-7.ibk3 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +2 -0
- package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-8.ibk3 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +1 -0
- package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +1 -0
- package/sample-store/gen-1/predicate-9.ibk3 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.merkle +1 -0
- package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +1 -0
- package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +3 -0
- package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
- package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +2 -0
- package/sample-store.d.ts +16 -0
- package/sample-store.mjs +37 -0
- package/store-host/deno.mjs +262 -0
- package/store-host/errors.mjs +55 -0
- package/store-host/index.mjs +247 -0
- package/store-host/node.mjs +273 -0
- package/store-host/paths.mjs +77 -0
- 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
|
+
}
|