@factoidal/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ // Deno implementation of the same four host primitives. Uses only Deno
2
+ // globals, so it never loads Deno's Node compatibility layer.
3
+ //
4
+ // Deno 2 removed the resource-id fsync entry points (`Deno.fsyncSync`);
5
+ // the file-handle method `FsFile.syncSync()` is the one that exists.
6
+
7
+ import { StoreHostError } from './errors.mjs'
8
+ import { baseName, dirName, joinPath } from './paths.mjs'
9
+
10
+ export const runtime = 'deno'
11
+
12
+ function wrap (code, message, path, cause) {
13
+ return new StoreHostError(code, `${message}: ${String(cause && cause.message ? cause.message : cause)}`, { path, cause })
14
+ }
15
+
16
+ function openRead (path) {
17
+ try {
18
+ return Deno.openSync(path, { read: true })
19
+ } catch (cause) {
20
+ throw wrap('OPEN_FAILED', `cannot open ${path} for reading`, path, cause)
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Deno has no positional read. Each call here opens its own handle, so the
26
+ * seek belongs to that handle alone and no other reader can move it. That
27
+ * is what `pread` buys the C extern; it is not the same instruction.
28
+ */
29
+ function readInto (file, out, length, offset, path) {
30
+ if (offset > 0) file.seekSync(offset, Deno.SeekMode.Start)
31
+ let done = 0
32
+ while (done < length) {
33
+ let read
34
+ try {
35
+ read = file.readSync(out.subarray(done, length))
36
+ } catch (cause) {
37
+ throw wrap('READ_FAILED', `read failed on ${path}`, path, cause)
38
+ }
39
+ if (read === null || read === 0) break
40
+ done += read
41
+ }
42
+ return done
43
+ }
44
+
45
+ export function readWhole (path) {
46
+ const file = openRead(path)
47
+ try {
48
+ const size = file.statSync().size
49
+ if (!Number.isSafeInteger(size)) {
50
+ throw new StoreHostError('FILE_TOO_LARGE', `${path} is larger than 2^53 - 1 bytes`, { path })
51
+ }
52
+ const out = new Uint8Array(size)
53
+ const done = readInto(file, out, size, 0, path)
54
+ if (done !== size) {
55
+ throw new StoreHostError('SHORT_READ', `${path} shrank during the read (${done} of ${size} bytes)`, { path })
56
+ }
57
+ return out
58
+ } finally {
59
+ file.close()
60
+ }
61
+ }
62
+
63
+ export function readRange (path, offset, length) {
64
+ if (length === 0) return new Uint8Array(0)
65
+ const file = openRead(path)
66
+ try {
67
+ const out = new Uint8Array(length)
68
+ const done = readInto(file, out, length, offset, path)
69
+ if (done !== length) {
70
+ throw new StoreHostError(
71
+ 'SHORT_READ',
72
+ `${path} returned ${done} of ${length} bytes at offset ${offset}`,
73
+ { path }
74
+ )
75
+ }
76
+ return out
77
+ } finally {
78
+ file.close()
79
+ }
80
+ }
81
+
82
+ export function appendSyncAtSize (path, bytes, expectedSize) {
83
+ let file
84
+ try {
85
+ file = Deno.openSync(path, { write: true, create: true, append: true })
86
+ } catch (cause) {
87
+ throw wrap('OPEN_FAILED', `cannot open ${path} for append`, path, cause)
88
+ }
89
+ try {
90
+ const size = file.statSync().size
91
+ if (size !== expectedSize) return false
92
+ let done = 0
93
+ while (done < bytes.length) {
94
+ let written
95
+ try {
96
+ written = file.writeSync(bytes.subarray(done))
97
+ } catch (cause) {
98
+ throw wrap('WRITE_FAILED', `append failed on ${path}`, path, cause)
99
+ }
100
+ done += written
101
+ }
102
+ try {
103
+ file.syncSync()
104
+ } catch (cause) {
105
+ throw wrap('FSYNC_FAILED', `fsync failed on ${path}`, path, cause)
106
+ }
107
+ return true
108
+ } finally {
109
+ file.close()
110
+ }
111
+ }
112
+
113
+ function temporaryName (path) {
114
+ const suffix = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')
115
+ return joinPath(dirName(path), baseName(path) + '.tmp.' + suffix)
116
+ }
117
+
118
+ function fsyncDirectory (directory) {
119
+ let handle
120
+ try {
121
+ handle = Deno.openSync(directory, { read: true })
122
+ } catch (cause) {
123
+ throw wrap('DIR_OPEN_FAILED', `cannot open ${directory} to sync it`, directory, cause)
124
+ }
125
+ try {
126
+ handle.syncSync()
127
+ } finally {
128
+ handle.close()
129
+ }
130
+ }
131
+
132
+ export function atomicReplace (path, bytes) {
133
+ const directory = dirName(path)
134
+ let temporary = null
135
+ let file = null
136
+ try {
137
+ for (let attempt = 0; attempt < 8 && file === null; attempt += 1) {
138
+ temporary = temporaryName(path)
139
+ try {
140
+ file = Deno.openSync(temporary, { write: true, createNew: true })
141
+ } catch (cause) {
142
+ if (cause instanceof Deno.errors.AlreadyExists) continue
143
+ throw wrap('OPEN_FAILED', `cannot create ${temporary}`, temporary, cause)
144
+ }
145
+ }
146
+ if (file === null) {
147
+ throw new StoreHostError('TEMP_NAME_EXHAUSTED', `no free temporary name beside ${path}`, { path })
148
+ }
149
+ let done = 0
150
+ while (done < bytes.length) {
151
+ let written
152
+ try {
153
+ written = file.writeSync(bytes.subarray(done))
154
+ } catch (cause) {
155
+ throw wrap('WRITE_FAILED', `write failed on ${temporary}`, temporary, cause)
156
+ }
157
+ done += written
158
+ }
159
+ file.syncSync()
160
+ file.close()
161
+ file = null
162
+ Deno.renameSync(temporary, path)
163
+ temporary = null
164
+ try {
165
+ fsyncDirectory(directory)
166
+ } catch (_error) {
167
+ return false
168
+ }
169
+ return true
170
+ } catch (error) {
171
+ if (file !== null) file.close()
172
+ if (temporary !== null) {
173
+ try { Deno.removeSync(temporary) } catch (_ignored) { /* the temporary may not exist */ }
174
+ }
175
+ throw error
176
+ }
177
+ }
178
+
179
+ export function listGeneration (directory) {
180
+ const out = []
181
+ let entries
182
+ try {
183
+ entries = [...Deno.readDirSync(directory)]
184
+ } catch (cause) {
185
+ throw wrap('DIR_READ_FAILED', `cannot list ${directory}`, directory, cause)
186
+ }
187
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
188
+ for (const entry of entries) {
189
+ if (!entry.isFile) continue
190
+ let info
191
+ try {
192
+ info = Deno.statSync(joinPath(directory, entry.name))
193
+ } catch (_cause) {
194
+ continue
195
+ }
196
+ out.push({ name: entry.name, size: info.size })
197
+ }
198
+ return out
199
+ }
@@ -0,0 +1,55 @@
1
+ // Error type shared by the Node and Deno host-I/O implementations.
2
+ //
3
+ // The store host moves bytes. It never reports a FORMAT decision, so no
4
+ // error code here names a manifest, a digest, a block or a generation
5
+ // layout: those decisions belong to the Lean source
6
+ // (https://github.com/danbri/factoidal/issues/641, iron rule 7).
7
+
8
+ export class StoreHostError extends Error {
9
+ /**
10
+ * @param {string} code stable machine-readable code
11
+ * @param {string} message
12
+ * @param {{cause?: unknown, path?: string}} [detail]
13
+ */
14
+ constructor (code, message, detail = {}) {
15
+ super(message, detail.cause === undefined ? undefined : { cause: detail.cause })
16
+ this.name = 'StoreHostError'
17
+ this.code = code
18
+ if (detail.path !== undefined) this.path = detail.path
19
+ }
20
+ }
21
+
22
+ /** Reject an argument that is not a usable filesystem path. */
23
+ export function requirePath (value, label) {
24
+ if (typeof value !== 'string' || value.length === 0) {
25
+ throw new StoreHostError('BAD_ARGUMENT', `${label} must be a non-empty string`)
26
+ }
27
+ if (value.indexOf('\u0000') >= 0) {
28
+ throw new StoreHostError('BAD_ARGUMENT', `${label} must not contain a NUL byte`)
29
+ }
30
+ return value
31
+ }
32
+
33
+ /**
34
+ * Reject an offset or length that a 64-bit host accepts and JavaScript
35
+ * cannot represent exactly. The C extern refuses `offset > INT64_MAX`;
36
+ * a JavaScript number stops being exact at 2^53 - 1, so that is the
37
+ * limit this module enforces.
38
+ */
39
+ export function requireCount (value, label) {
40
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
41
+ throw new StoreHostError(
42
+ 'BAD_ARGUMENT',
43
+ `${label} must be a non-negative integer below 2^53`
44
+ )
45
+ }
46
+ return value
47
+ }
48
+
49
+ /** Reject a payload that is not raw bytes. */
50
+ export function requireBytes (value, label) {
51
+ if (!(value instanceof Uint8Array)) {
52
+ throw new StoreHostError('BAD_ARGUMENT', `${label} must be a Uint8Array`)
53
+ }
54
+ return value
55
+ }
@@ -0,0 +1,208 @@
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
+ * Append `bytes` only if the file currently has exactly `expectedSize`
110
+ * bytes, then fsync it. The counterpart of
111
+ * `l4_delta_log_append_sync_at_size`, minus its advisory lock
112
+ * (divergence 1 above). Creates the file when absent, in which case the
113
+ * only size that matches is 0.
114
+ * @param {string} path
115
+ * @param {Uint8Array} bytes
116
+ * @param {number} expectedSize
117
+ * @returns {boolean} true when the append happened and was synced;
118
+ * false when the file's size was not `expectedSize`
119
+ */
120
+ export function appendSyncAtSize (path, bytes, expectedSize) {
121
+ requirePath(path, 'path')
122
+ requireBytes(bytes, 'bytes')
123
+ requireCount(expectedSize, 'expectedSize')
124
+ return impl.appendSyncAtSize(path, bytes, expectedSize)
125
+ }
126
+
127
+ /**
128
+ * Replace a file's whole contents so that a reader sees either the old
129
+ * bytes or the new bytes and never a mixture: write a temporary file in
130
+ * the same directory, fsync it, rename it over the target, fsync the
131
+ * directory. The counterpart of `l4_atomic_replace_file_sync`.
132
+ * @param {string} path
133
+ * @param {Uint8Array} bytes
134
+ * @returns {boolean} true when the replacement is durable; false when the
135
+ * rename succeeded but the directory fsync did not (divergence 4)
136
+ */
137
+ export function atomicReplace (path, bytes) {
138
+ requirePath(path, 'path')
139
+ requireBytes(bytes, 'bytes')
140
+ return impl.atomicReplace(path, bytes)
141
+ }
142
+
143
+ /**
144
+ * List the regular files of one directory with their sizes, sorted by
145
+ * name. Subdirectories and symbolic links to directories are left out.
146
+ * @param {string} directory
147
+ * @returns {{name: string, size: number}[]}
148
+ */
149
+ export function listGeneration (directory) {
150
+ requirePath(directory, 'directory')
151
+ return impl.listGeneration(directory)
152
+ }
153
+
154
+ // The manifest file names a generation directory can carry, in the order
155
+ // `Harness.ShardMerklePread.readManifest` tries them. This module reads
156
+ // whichever exists and returns its bytes untouched; it does not look
157
+ // inside either one.
158
+ const MANIFEST_NAMES = ['manifest.sbm2', 'manifest.sbm1']
159
+
160
+ /**
161
+ * Open an activated collection: read `CURRENT`, and return the generation
162
+ * name it holds together with the raw manifest bytes of that generation.
163
+ *
164
+ * The returned `manifest` is bytes. Deciding what those bytes mean —
165
+ * which wire version, which artifacts, which digests — is the engine's
166
+ * job, not this module's.
167
+ *
168
+ * @param {string} root the collection root that holds CURRENT
169
+ * @returns {{root: string, generation: string, generationDir: string,
170
+ * manifestName: string, manifest: Uint8Array}}
171
+ * @throws {StoreHostError} code NO_CURRENT when the root has no CURRENT,
172
+ * NO_MANIFEST when the generation carries none of the manifest names
173
+ */
174
+ export function openCollection (root) {
175
+ requirePath(root, 'root')
176
+ const pointerPath = joinPath(root, 'CURRENT')
177
+ let pointerBytes
178
+ try {
179
+ pointerBytes = impl.readWhole(pointerPath)
180
+ } catch (cause) {
181
+ throw new StoreHostError(
182
+ 'NO_CURRENT',
183
+ `${root} has no readable CURRENT pointer`,
184
+ { path: pointerPath, cause }
185
+ )
186
+ }
187
+ // CURRENT holds a UTF-8 child-generation name (spec section 6.4). Trailing
188
+ // ASCII whitespace is tolerated so a pointer written by hand still opens.
189
+ const generation = requireChildName(
190
+ new TextDecoder('utf-8', { fatal: true }).decode(pointerBytes).replace(/[\r\n\t ]+$/, ''),
191
+ 'CURRENT'
192
+ )
193
+ const generationDir = joinPath(root, generation)
194
+ for (const manifestName of MANIFEST_NAMES) {
195
+ try {
196
+ const manifest = impl.readWhole(joinPath(generationDir, manifestName))
197
+ return { root, generation, generationDir, manifestName, manifest }
198
+ } catch (error) {
199
+ if (error instanceof StoreHostError && error.code === 'OPEN_FAILED') continue
200
+ throw error
201
+ }
202
+ }
203
+ throw new StoreHostError(
204
+ 'NO_MANIFEST',
205
+ `${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`,
206
+ { path: generationDir }
207
+ )
208
+ }
@@ -0,0 +1,212 @@
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, 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
+ export function appendSyncAtSize (path, bytes, expectedSize) {
89
+ let fd
90
+ try {
91
+ // 'a' is O_WRONLY | O_CREAT | O_APPEND, matching the C extern's open.
92
+ fd = openSync(path, 'a')
93
+ } catch (cause) {
94
+ throw wrap('OPEN_FAILED', `cannot open ${path} for append`, path, cause)
95
+ }
96
+ try {
97
+ const size = fstatSync(fd).size
98
+ if (size !== expectedSize) return false
99
+ let done = 0
100
+ while (done < bytes.length) {
101
+ let written
102
+ try {
103
+ // position null keeps the O_APPEND placement the C extern relies on.
104
+ written = writeSync(fd, bytes, done, bytes.length - done, null)
105
+ } catch (cause) {
106
+ if (isInterrupt(cause)) continue
107
+ throw wrap('WRITE_FAILED', `append failed on ${path}`, path, cause)
108
+ }
109
+ done += written
110
+ }
111
+ try {
112
+ fsyncSync(fd)
113
+ } catch (cause) {
114
+ throw wrap('FSYNC_FAILED', `fsync failed on ${path}`, path, cause)
115
+ }
116
+ return true
117
+ } finally {
118
+ closeSync(fd)
119
+ }
120
+ }
121
+
122
+ function temporaryName (path) {
123
+ const suffix = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')
124
+ return joinPath(dirName(path), baseName(path) + '.tmp.' + suffix)
125
+ }
126
+
127
+ function fsyncDirectory (directory) {
128
+ let fd
129
+ try {
130
+ fd = openSync(directory, 'r')
131
+ } catch (cause) {
132
+ throw wrap('DIR_OPEN_FAILED', `cannot open ${directory} to sync it`, directory, cause)
133
+ }
134
+ try {
135
+ fsyncSync(fd)
136
+ } finally {
137
+ closeSync(fd)
138
+ }
139
+ }
140
+
141
+ export function atomicReplace (path, bytes) {
142
+ const directory = dirName(path)
143
+ let temporary = null
144
+ let fd = null
145
+ try {
146
+ for (let attempt = 0; attempt < 8 && fd === null; attempt += 1) {
147
+ temporary = temporaryName(path)
148
+ try {
149
+ // 'wx' is O_WRONLY | O_CREAT | O_EXCL, the exclusive create that
150
+ // makes the name ours the way mkstemp does in the C extern.
151
+ fd = openSync(temporary, 'wx')
152
+ } catch (cause) {
153
+ if (cause && cause.code === 'EEXIST') continue
154
+ throw wrap('OPEN_FAILED', `cannot create ${temporary}`, temporary, cause)
155
+ }
156
+ }
157
+ if (fd === null) {
158
+ throw new StoreHostError('TEMP_NAME_EXHAUSTED', `no free temporary name beside ${path}`, { path })
159
+ }
160
+ let done = 0
161
+ while (done < bytes.length) {
162
+ let written
163
+ try {
164
+ written = writeSync(fd, bytes, done, bytes.length - done, null)
165
+ } catch (cause) {
166
+ if (isInterrupt(cause)) continue
167
+ throw wrap('WRITE_FAILED', `write failed on ${temporary}`, temporary, cause)
168
+ }
169
+ done += written
170
+ }
171
+ fsyncSync(fd)
172
+ closeSync(fd)
173
+ fd = null
174
+ renameSync(temporary, path)
175
+ temporary = null
176
+ try {
177
+ fsyncDirectory(directory)
178
+ } catch (_error) {
179
+ // The C extern also returns false here, with the replacement already
180
+ // done. Reported the same way; see ./index.mjs for what false means.
181
+ return false
182
+ }
183
+ return true
184
+ } catch (error) {
185
+ if (fd !== null) closeSync(fd)
186
+ if (temporary !== null) {
187
+ try { unlinkSync(temporary) } catch (_ignored) { /* the temporary may not exist */ }
188
+ }
189
+ throw error
190
+ }
191
+ }
192
+
193
+ export function listGeneration (directory) {
194
+ let names
195
+ try {
196
+ names = readdirSync(directory)
197
+ } catch (cause) {
198
+ throw wrap('DIR_READ_FAILED', `cannot list ${directory}`, directory, cause)
199
+ }
200
+ const out = []
201
+ for (const name of names.sort()) {
202
+ let info
203
+ try {
204
+ info = statSync(joinPath(directory, name))
205
+ } catch (_cause) {
206
+ continue // a name that vanished between readdir and stat
207
+ }
208
+ if (!info.isFile()) continue
209
+ out.push({ name, size: info.size })
210
+ }
211
+ return out
212
+ }