@optimystic/db-p2p-storage-fs 1.15.0 → 1.17.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 (37) hide show
  1. package/README.md +142 -33
  2. package/dist/src/file-batch-backend.d.ts +69 -0
  3. package/dist/src/file-batch-backend.d.ts.map +1 -0
  4. package/dist/src/file-batch-backend.js +400 -0
  5. package/dist/src/file-batch-backend.js.map +1 -0
  6. package/dist/src/file-kv-store.d.ts +37 -3
  7. package/dist/src/file-kv-store.d.ts.map +1 -1
  8. package/dist/src/file-kv-store.js +100 -34
  9. package/dist/src/file-kv-store.js.map +1 -1
  10. package/dist/src/file-storage.d.ts +20 -58
  11. package/dist/src/file-storage.d.ts.map +1 -1
  12. package/dist/src/file-storage.js +16 -430
  13. package/dist/src/file-storage.js.map +1 -1
  14. package/dist/src/file-write-log.d.ts +361 -0
  15. package/dist/src/file-write-log.d.ts.map +1 -0
  16. package/dist/src/file-write-log.js +848 -0
  17. package/dist/src/file-write-log.js.map +1 -0
  18. package/dist/src/index.d.ts +3 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +3 -0
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/store-lock.d.ts +42 -0
  23. package/dist/src/store-lock.d.ts.map +1 -0
  24. package/dist/src/store-lock.js +116 -0
  25. package/dist/src/store-lock.js.map +1 -0
  26. package/package.json +3 -3
  27. package/src/file-batch-backend.ts +430 -0
  28. package/src/file-kv-store.ts +112 -32
  29. package/src/file-storage.ts +22 -468
  30. package/src/file-write-log.ts +906 -0
  31. package/src/index.ts +3 -0
  32. package/src/store-lock.ts +123 -0
  33. package/dist/src/atomic-write.d.ts +0 -22
  34. package/dist/src/atomic-write.d.ts.map +0 -1
  35. package/dist/src/atomic-write.js +0 -90
  36. package/dist/src/atomic-write.js.map +0 -1
  37. package/src/atomic-write.ts +0 -89
@@ -0,0 +1,430 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ import type { BlockId, ActionId } from '@optimystic/db-core';
4
+ import { NODE_KV_NAMESPACES, type BatchBackend, type BatchEntry, type CommittedBatch, type SinkCommitOptions, type StoreDurability, type StoreIdentity, type StoreKey } from '@optimystic/db-p2p';
5
+ import { createLogger } from './logger.js';
6
+ import { FileWriteLog, assertFileWriteLogOptions, fileStoreIdentity, readFileIfExists, type FileWriteLogOptions, type WriteLogEntry } from './file-write-log.js';
7
+
8
+ const log = createLogger('storage:file');
9
+
10
+ // Built on first use rather than at module load, like every other `TextDecoder` in library source
11
+ // (see NO_MODULE_SCOPE_TEXT_DECODER in eslint.config.js). Harmless on Node; kept uniform so the lint
12
+ // rule needs no per-package exemption.
13
+ let decoderInstance: TextDecoder | undefined;
14
+ const decoder = (): TextDecoder => decoderInstance ??= new TextDecoder();
15
+
16
+ // Colons are illegal in Windows filenames; encode them so action ids like
17
+ // `tx:<hash>` and `stamp:<hash>` round-trip safely on all platforms.
18
+ function encodeActionIdForFilename(actionId: ActionId): string {
19
+ return actionId.replace(/:/g, '%3A');
20
+ }
21
+
22
+ function decodeFilenameToActionId(filename: string): ActionId {
23
+ return filename.replace(/%3A/g, ':') as ActionId;
24
+ }
25
+
26
+ // A torn write leaves valid-prefix JSON cut off mid-token — JSON.parse throws
27
+ // SyntaxError. Used as the "corrupt content → treat as missing" guard so a
28
+ // crash-truncated file reads as absent (letting recover() make progress) instead
29
+ // of surfacing a parse error forever. Only the JSON-valued stores use this; the
30
+ // revisions store holds a bare ActionId string (not JSON) and is never guarded.
31
+ // NOTE: every guarded read parses the JSON here (validate, discard) and the kernel's
32
+ // decodeJson parses the same bytes AGAIN to build the value — 2× parse per get. The
33
+ // guard is intrinsic to the backend's "corrupt→missing" contract (the kernel can't
34
+ // express it), so it can't simply be dropped. Fine at current read volumes; if a read
35
+ // path ever shows up hot, parse once and thread the parsed value through the backend.
36
+ function isParseableJson(bytes: Uint8Array): boolean {
37
+ try {
38
+ JSON.parse(decoder().decode(bytes));
39
+ return true;
40
+ } catch (err) {
41
+ if (err instanceof SyntaxError) return false;
42
+ throw err;
43
+ }
44
+ }
45
+
46
+ type ActionScopedStore = 'pending' | 'transactions' | 'materialized';
47
+
48
+ const ACTION_SCOPED_DIRS: Record<ActionScopedStore, string> = {
49
+ pending: 'pend',
50
+ transactions: 'actions',
51
+ materialized: 'blocks',
52
+ };
53
+
54
+ const METADATA_FILE = 'meta.json';
55
+ const REVISIONS_DIR = 'revs';
56
+ const PROOFS_DIR = 'proofs';
57
+
58
+ /**
59
+ * The directories a block keeps its own records in, skipped when the block listing walks below a
60
+ * block directory (a block's directory can hold another block's: `.../Ledger/index/LedgerLatest`
61
+ * sits inside `.../Ledger/`).
62
+ * NOTE: a block id that extends another block's id with one of these segments (`x/revs`,
63
+ * `x/blocks/y`) lands inside block `x`'s own store directory — on write `x/revs` shares it outright,
64
+ * and the listing never yields either id. A limit of the raw-id layout; if such ids ever occur,
65
+ * encode block-id segments in the layout.
66
+ */
67
+ const BLOCK_STORE_DIRS: ReadonlySet<string> = new Set([...Object.values(ACTION_SCOPED_DIRS), REVISIONS_DIR, PROOFS_DIR]);
68
+
69
+ /**
70
+ * The node's KV namespaces, which `FileKVStore` writes at the root when it shares this base path.
71
+ * Skipped by the listing so a KV key whose last segment is `meta` (stored as `meta.json`) is not
72
+ * read as a block, and so a startup scan does not walk `executed/`. A block id starting with one of
73
+ * these words already collides with the KV store on write (see `FileKVStore.keyToPath`).
74
+ */
75
+ const ROOT_KV_NAMESPACES: ReadonlySet<string> = new Set(NODE_KV_NAMESPACES);
76
+
77
+ /**
78
+ * Filesystem {@link BatchBackend}: the six logical block-storage stores mapped to six paths under
79
+ * `basePath/<blockId>/` (`{meta.json,revs/,pend/,actions/,proofs/,blocks/}`), every batch committed
80
+ * through the directory's {@link FileWriteLog}. The directory tree is a deliberate,
81
+ * human-inspectable layout — it is NOT flattened into encoded-filename KV keys.
82
+ *
83
+ * `KvRawStorage` owns all JSON serialization, so this backend reads raw `Uint8Array` bytes and
84
+ * never does `JSON.stringify/parse` on values. What is fs-specific lives here: the path per
85
+ * {@link StoreKey}, the corrupt-content-as-missing read guard, colon-encoded action-id filenames
86
+ * with the legacy raw-colon read fallback and win32 guards, and the directory listings.
87
+ *
88
+ * Every operation opens the shared log first — on the first one: take the lock, replay, then
89
+ * serve — so no read ever sees a tree the log has not been replayed into. The log is acquired on
90
+ * that first operation rather than in the constructor, so an instance that is constructed and
91
+ * never used (one `withReadCache` dedupes away) holds no handle and no lock.
92
+ */
93
+ export class FileBatchBackend implements BatchBackend {
94
+ /** A committed batch's record is synced to the log by the deadline its commit allowed, or before the commit resolves; replay rebuilds the tree from it. */
95
+ readonly durability: StoreDurability = 'power-loss';
96
+
97
+ readonly basePath: string;
98
+ private readonly identity: StoreIdentity;
99
+ private log: FileWriteLog | undefined;
100
+
101
+ /** `options` are the directory log's checkpoint settings, applied when this is the first store to open it. */
102
+ constructor(basePath: string, private readonly options: FileWriteLogOptions = {}) {
103
+ assertFileWriteLogOptions(options);
104
+ this.basePath = path.resolve(basePath);
105
+ this.identity = fileStoreIdentity(basePath);
106
+ }
107
+
108
+ storeIdentity(): StoreIdentity {
109
+ return this.identity;
110
+ }
111
+
112
+ // --- reads ---
113
+
114
+ async get(key: StoreKey): Promise<Uint8Array | undefined> {
115
+ await this.open();
116
+ switch (key.store) {
117
+ case 'metadata':
118
+ return this.readBytesIfExists(this.metadataPath(key.blockId), true);
119
+ // The revisions store value is a bare ActionId string (kernel `encodeActionId`, NOT JSON),
120
+ // so it is read WITHOUT the JSON guard — any bytes are a valid string and `decodeActionId`
121
+ // never throws.
122
+ // NOTE: because there is no guard here, a *torn* revision file reads back as a wrong
123
+ // (truncated) ActionId rather than as missing — unlike the JSON stores, which torn-read as
124
+ // undefined. A tree file can only be torn by a crash during its write, and that write's log
125
+ // record is durable before the write starts, so the next open's replay rewrites it whole.
126
+ // Only a file written by a build before the log can stay torn, and recover() re-derives
127
+ // revisions from the actions store anyway. If revisions ever move outside the log, add a
128
+ // checksum/length guard here.
129
+ case 'revisions':
130
+ return this.readBytesIfExists(this.revisionPath(key.blockId, key.rev), false);
131
+ // Keyed by (blockId, rev) like the revisions store, but JSON-valued like the action stores —
132
+ // so it takes the corrupt-content-as-missing guard the revisions store does not, and the
133
+ // filename carries no colon to encode. Deliberately no delete: a proof lives and dies with
134
+ // its revision record (see RawStoreDriver.getProof).
135
+ case 'proofs':
136
+ return this.readBytesIfExists(this.proofPath(key.blockId, key.rev), true);
137
+ case 'pending':
138
+ case 'transactions':
139
+ case 'materialized':
140
+ return this.readActionScopedBytes(
141
+ this.actionScopedPath(key.store, key.blockId, key.actionId),
142
+ this.actionScopedPath(key.store, key.blockId, key.actionId, false),
143
+ );
144
+ }
145
+ }
146
+
147
+ async rangeRevisions(blockId: BlockId, lo: number, hi: number): Promise<[number, Uint8Array][]> {
148
+ await this.open();
149
+ // The fs backend has no cursor: walk the bounded [lo, hi] range rev-by-rev, reading each
150
+ // present rev. The range is caller-bounded, so this avoids listing an unbounded revs/ directory.
151
+ const results: [number, Uint8Array][] = [];
152
+ for (let rev = lo; rev <= hi; rev++) {
153
+ const value = await this.readBytesIfExists(this.revisionPath(blockId, rev), false);
154
+ if (value !== undefined) {
155
+ results.push([rev, value]);
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+
161
+ async listPendingActionIds(blockId: BlockId): Promise<ActionId[]> {
162
+ await this.open();
163
+ const pendingPath = path.join(this.blockPath(blockId), ACTION_SCOPED_DIRS.pending);
164
+
165
+ // Only a genuinely-absent directory (ENOENT) maps to "no pendings". Any other error
166
+ // (EACCES, EIO, ENOTDIR, ...) must surface — swallowing it here would make listPendingActionIds
167
+ // silently report an empty directory, so pend's conflict detection would be skipped. Mirrors
168
+ // directoryByteSize's ENOENT-vs-other discrimination.
169
+ const files = await fs.readdir(pendingPath).catch((err) => {
170
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return [] as string[];
171
+ log('listPendingActionIds readdir failed for %s - %o', blockId, err);
172
+ throw err;
173
+ });
174
+ const ids: ActionId[] = [];
175
+ for (const file of files) {
176
+ if (!file.endsWith('.json')) continue;
177
+ const actionId = decodeFilenameToActionId(file.slice(0, -5));
178
+ // Accept every realistic action id: legacy UUIDs (`[0-9a-f-]`), consensus
179
+ // tx:/stamp: ids (base64url-encoded SHA-256, alphabet `[A-Za-z0-9_-]` — see
180
+ // db-core hashString, NOT lowercase hex), AND the bare-alphanumeric ids the
181
+ // cross-backend conformance suite uses (`a1`, `b1`, ...). This is deliberately
182
+ // broad-but-not-total: an id is any `[A-Za-z0-9_-]` string, optionally prefixed
183
+ // with `tx:`/`stamp:`. It is NOT a total accept — a file whose decoded name
184
+ // carries other punctuation (a dot, a space) is genuine junk in pend/ and is
185
+ // logged-and-skipped rather than surfaced as a phantom pending. The memory/db
186
+ // reference drivers key on the raw id and never see a filesystem name, so this
187
+ // filter is fs-only; it must not drop an id those backends would list, hence the
188
+ // widened class (an earlier hex-only class silently dropped real consensus ids —
189
+ // see `optimystic-filestorage-colon-actionid-windows`).
190
+ if (!/^(?:tx:|stamp:)?[A-Za-z0-9_-]+$/.test(actionId)) {
191
+ // Leave a breadcrumb rather than silently dropping: the .json + decode guard
192
+ // already excludes the `*.tmp` orphans an older build's atomic writer could leave, so
193
+ // anything reaching here is an unexpected filename a maintainer should see.
194
+ log('listPendingActionIds skipping unrecognized action-id file %s for %s', file, blockId);
195
+ continue;
196
+ }
197
+ ids.push(actionId);
198
+ }
199
+ return ids;
200
+ }
201
+
202
+ async *listBlockIds(): AsyncIterable<BlockId> {
203
+ await this.open();
204
+ // A block id is used raw as a relative path (see blockPath), so an id containing `/` lives in
205
+ // nested directories: `default/app/Ledger` is `basePath/default/app/Ledger/meta.json`. So the
206
+ // listing walks the whole tree and yields every directory holding a meta.json, its path
207
+ // relative to basePath rebuilt with `/` (never path.sep, so a win32 host yields the id it was
208
+ // given). Intermediate directories (`default/`, `default/app/`) hold no meta.json: descended,
209
+ // not listed. Files are never descended, so `write-ahead.log` and `store.lock` at the root
210
+ // are skipped by that alone.
211
+ //
212
+ // Gating on meta.json — existence (fs.access), not parse — enumerates exactly the population
213
+ // the metadata-keyed backends (sqlite/leveldb/indexeddb) get for free: every block with ANY
214
+ // durable metadata. That INCLUDES a pend-only block on the node path, because
215
+ // `BlockStorage.savePendingTransaction` seeds metadata first; a pend straight through this
216
+ // backend creates `<blockId>/pend/` without meta.json and is not listed. Do not "improve" this
217
+ // into a committed-revision filter: parsing every meta.json would turn a cheap sweep into a
218
+ // per-block read at startup (see the `listBlockIds` contract in i-raw-storage.ts). A
219
+ // torn/corrupt meta.json still counts as a key, exactly as a corrupt value would elsewhere.
220
+ //
221
+ // ENOENT (basePath not created yet, or a directory without meta.json) maps to "not there".
222
+ // Any OTHER readdir/access error surfaces: swallowing it would make the startup seed falsely
223
+ // report an empty store and under-protect data already on disk.
224
+ // NOTE: one readdir + one meta.json access per directory on the walk, yielded as it goes; if a
225
+ // store ever grows to millions of block directories and this becomes a startup-latency
226
+ // problem, page each listing (e.g. opendir cursor) — fine at current scale.
227
+ yield* this.walkBlockDirectories([]);
228
+ }
229
+
230
+ private async *walkBlockDirectories(segments: readonly string[]): AsyncIterable<BlockId> {
231
+ const dir = path.join(this.basePath, ...segments);
232
+ const isBlock = segments.length > 0 && await this.hasMetadataFile(dir);
233
+ if (isBlock) yield segments.join('/') as BlockId;
234
+ for (const name of await this.childDirectoryNames(dir)) {
235
+ if (isBlock && BLOCK_STORE_DIRS.has(name)) continue;
236
+ if (segments.length === 0 && ROOT_KV_NAMESPACES.has(name)) continue;
237
+ yield* this.walkBlockDirectories([...segments, name]);
238
+ }
239
+ }
240
+
241
+ private async childDirectoryNames(dir: string): Promise<string[]> {
242
+ const entries = await fs.readdir(dir, { withFileTypes: true })
243
+ .catch((err) => {
244
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
245
+ log('listBlockIds readdir failed for %s - %o', dir, err);
246
+ throw err;
247
+ });
248
+ return entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
249
+ }
250
+
251
+ private hasMetadataFile(dir: string): Promise<boolean> {
252
+ return fs.access(path.join(dir, METADATA_FILE))
253
+ .then(() => true)
254
+ .catch((err) => {
255
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return false;
256
+ log('listBlockIds access failed for %s - %o', dir, err);
257
+ throw err;
258
+ });
259
+ }
260
+
261
+ /** The tree's bytes, the write-ahead log and the lock included: all of it is what the store occupies. */
262
+ async approximateBytesUsed(): Promise<number> {
263
+ await this.open();
264
+ return this.directoryByteSize(this.basePath);
265
+ }
266
+
267
+ // --- the one write ---
268
+
269
+ /** One log record, synced within `syncWithinMs` (see `FileWriteLog.commit`); the `durable` returned is the log's. */
270
+ commit(entries: readonly BatchEntry[], options: SinkCommitOptions): Promise<CommittedBatch> {
271
+ return this.shared().commit(entries.flatMap(entry => this.toLogEntries(entry)), options);
272
+ }
273
+
274
+ /** Hasten the log's sync. A backend that never committed holds no log, and so has nothing to hasten. */
275
+ sync(): Promise<void> {
276
+ return this.log?.sync() ?? Promise.resolve();
277
+ }
278
+
279
+ async close(): Promise<void> {
280
+ const shared = this.log;
281
+ this.log = undefined;
282
+ await shared?.release();
283
+ }
284
+
285
+ // --- the log ---
286
+
287
+ private shared(): FileWriteLog {
288
+ return this.log ??= FileWriteLog.acquire(this.basePath, this.options);
289
+ }
290
+
291
+ private open(): Promise<void> {
292
+ return this.shared().open();
293
+ }
294
+
295
+ /**
296
+ * A put is one entry at the key's canonical path. A delete of an action-scoped record is that
297
+ * path's entry plus, off win32 and when the id carries a colon, the pre-encode raw-colon sibling's,
298
+ * so a deleted item cannot resurface through the read fallback in readActionScopedBytes — in the
299
+ * log, so a replay removes the sibling too.
300
+ *
301
+ * NOTE: that sibling entry names a path with a colon, which only a POSIX build writes; a log
302
+ * carrying one and moved unreplayed to a win32 machine replays it as an unlink of an NTFS
303
+ * stream path, where anything but ENOENT fails the open. A store is not moved across platforms
304
+ * mid-log today; if it ever is, have the replay skip a delete it cannot spell on this platform.
305
+ */
306
+ private toLogEntries(entry: BatchEntry): WriteLogEntry[] {
307
+ const relative = this.relativePath(entry.key);
308
+ if (entry.value !== undefined) return [{ kind: 'put', path: relative, value: entry.value }];
309
+ const entries: WriteLogEntry[] = [{ kind: 'delete', path: relative }];
310
+ const { key } = entry;
311
+ if (key.store === 'pending' || key.store === 'materialized') {
312
+ const raw = this.relativeActionScopedPath(key.store, key.blockId, key.actionId, false);
313
+ if (process.platform !== 'win32' && raw !== relative) entries.push({ kind: 'delete', path: raw });
314
+ }
315
+ return entries;
316
+ }
317
+
318
+ // --- paths: relative with forward slashes for the log, absolute for reads ---
319
+
320
+ private relativePath(key: StoreKey): string {
321
+ switch (key.store) {
322
+ case 'metadata':
323
+ return `${key.blockId}/${METADATA_FILE}`;
324
+ case 'revisions':
325
+ return `${key.blockId}/${REVISIONS_DIR}/${key.rev}.json`;
326
+ case 'proofs':
327
+ return `${key.blockId}/${PROOFS_DIR}/${key.rev}.json`;
328
+ case 'pending':
329
+ case 'transactions':
330
+ case 'materialized':
331
+ return this.relativeActionScopedPath(key.store, key.blockId, key.actionId);
332
+ }
333
+ }
334
+
335
+ // `encoded` controls colon handling: writes and canonical reads use the percent-encoded filename
336
+ // (encoded = true); the legacy raw-colon fallback (see readActionScopedBytes) passes
337
+ // encoded = false to reach pre-encode POSIX files like `actions/tx:<hash>.json`.
338
+ private relativeActionScopedPath(store: ActionScopedStore, blockId: BlockId, actionId: ActionId, encoded = true): string {
339
+ const filename = encoded ? encodeActionIdForFilename(actionId) : actionId;
340
+ return `${blockId}/${ACTION_SCOPED_DIRS[store]}/${filename}.json`;
341
+ }
342
+
343
+ private blockPath(blockId: BlockId): string {
344
+ return path.join(this.basePath, blockId);
345
+ }
346
+
347
+ private metadataPath(blockId: BlockId): string {
348
+ return path.join(this.blockPath(blockId), METADATA_FILE);
349
+ }
350
+
351
+ private revisionPath(blockId: BlockId, rev: number): string {
352
+ return path.join(this.blockPath(blockId), REVISIONS_DIR, `${rev}.json`);
353
+ }
354
+
355
+ private proofPath(blockId: BlockId, rev: number): string {
356
+ return path.join(this.blockPath(blockId), PROOFS_DIR, `${rev}.json`);
357
+ }
358
+
359
+ private actionScopedPath(store: ActionScopedStore, blockId: BlockId, actionId: ActionId, encoded = true): string {
360
+ return path.join(this.basePath, this.relativeActionScopedPath(store, blockId, actionId, encoded));
361
+ }
362
+
363
+ // --- reads with the fs guards ---
364
+
365
+ // Reads an action-id-keyed file (JSON-valued) by its canonical (percent-encoded)
366
+ // path, falling back on a miss to the legacy raw-colon path written by pre-encode
367
+ // nodes (e.g. POSIX files literally named `actions/tx:<hash>.json`).
368
+ //
369
+ // Tradeoff: this reads legacy files in place and never renames them, so a
370
+ // store upgraded from a pre-encode node keeps mixed naming on disk. That is
371
+ // acceptable pre-1.0; a future migration sweep can normalize if desired. We
372
+ // deliberately do NOT migrate-on-read here — reads stay side-effect-free.
373
+ //
374
+ // win32 guard: a raw-colon path is not a benign miss on Windows — the colon
375
+ // is parsed as an NTFS alternate-data-stream separator and a read there can
376
+ // throw a non-ENOENT error rather than cleanly missing. Raw-colon files
377
+ // cannot have been written on win32 anyway, so we skip the fallback there
378
+ // (losing nothing) and swallow ALL fallback errors elsewhere, guaranteeing
379
+ // the fallback never surfaces a new throw to callers.
380
+ private async readActionScopedBytes(encodedPath: string, rawPath: string): Promise<Uint8Array | undefined> {
381
+ const hit = await this.readBytesIfExists(encodedPath, true);
382
+ if (hit !== undefined) return hit;
383
+ if (process.platform === 'win32' || rawPath === encodedPath) return undefined;
384
+ return fs.readFile(rawPath)
385
+ .then(bytes => (isParseableJson(bytes) ? bytes : undefined))
386
+ .catch(() => undefined);
387
+ }
388
+
389
+ // Reads a file's raw bytes. ENOENT → undefined. When `jsonGuard` is set, a
390
+ // present-but-corrupt file (JSON.parse fails — a torn write of a build before the log, or a
391
+ // crash mid-write that the next open's replay has not yet rewritten) is treated as "missing" so
392
+ // recover() and normal reads make progress instead of rethrowing forever. A real I/O error
393
+ // (permissions, EIO, EISDIR, ...) still throws — it must not be masked.
394
+ private async readBytesIfExists(filePath: string, jsonGuard: boolean): Promise<Uint8Array | undefined> {
395
+ const bytes = await readFileIfExists(filePath);
396
+ if (bytes === undefined) return undefined;
397
+ if (jsonGuard && !isParseableJson(bytes)) {
398
+ log('readBytesIfExists: corrupt JSON at %s, treating as missing', filePath);
399
+ return undefined;
400
+ }
401
+ return bytes;
402
+ }
403
+
404
+ private async directoryByteSize(dir: string): Promise<number> {
405
+ const entries = await fs.readdir(dir, { withFileTypes: true })
406
+ .catch((err) => {
407
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
408
+ log('directoryByteSize readdir failed for %s - %o', dir, err);
409
+ return [];
410
+ });
411
+
412
+ let total = 0;
413
+ for (const entry of entries) {
414
+ const entryPath = path.join(dir, entry.name);
415
+ if (entry.isDirectory()) {
416
+ total += await this.directoryByteSize(entryPath);
417
+ } else if (entry.isFile()) {
418
+ const size = await fs.stat(entryPath)
419
+ .then(st => st.size)
420
+ .catch((err) => {
421
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return 0;
422
+ log('directoryByteSize stat failed for %s - %o', entryPath, err);
423
+ return 0;
424
+ });
425
+ total += size;
426
+ }
427
+ }
428
+ return total;
429
+ }
430
+ }
@@ -1,43 +1,118 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import * as path from 'path';
3
- import type { IKVStore } from '@optimystic/db-p2p';
4
- import { atomicWriteFile } from './atomic-write.js';
3
+ import { WriteGroup, type CommittedBatch, type FlushOptions, type IKVStore, type SinkCommitOptions, type WriteGroupEntry, type WriteGroupOptions } from '@optimystic/db-p2p';
4
+ import { FileWriteLog, assertFileWriteLogOptions, readFileIfExists, type FileWriteLogOptions, type WriteLogEntry } from './file-write-log.js';
5
5
 
6
- /** Filesystem-backed IKVStore. Keys may contain `/` separators which become subdirectories. */
6
+ /** The write group's `idleFlushMs` and the log's checkpoint settings; `setTimer` (test-only) arms both. */
7
+ export type FileKVStoreOptions = WriteGroupOptions & FileWriteLogOptions;
8
+
9
+ const encoder = new TextEncoder();
10
+ // Built on first use rather than at module load, like every other `TextDecoder` in library source
11
+ // (see NO_MODULE_SCOPE_TEXT_DECODER in eslint.config.js).
12
+ let decoderInstance: TextDecoder | undefined;
13
+ const decoder = (): TextDecoder => decoderInstance ??= new TextDecoder();
14
+
15
+ /**
16
+ * Filesystem-backed {@link IKVStore}: each key is the file `<basePath>/<key>.json`, a `/` in the key
17
+ * a subdirectory. Writes go through a {@link WriteGroup} over the directory's {@link FileWriteLog} —
18
+ * the same log a `FileRawStorage` over the same directory holds, so the two share one log, one
19
+ * lock and one checkpoint. A `set` or `delete` resolves into memory, served from there to `get` and
20
+ * `list`; `flush()` appends everything written since the last flush to the log as one record, synced
21
+ * before the flush resolves or within the window the flush allows (`FileWriteLog.commit`). A write
22
+ * no flush reaches is flushed by the group's idle timer.
23
+ *
24
+ * The constructor is synchronous and holds nothing: the log is acquired, the lock taken and the log
25
+ * replayed on the first operation. `close()` flushes what is still held and releases this store's
26
+ * reference to the log.
27
+ */
7
28
  export class FileKVStore implements IKVStore {
8
- constructor(private readonly basePath: string) {}
29
+ private readonly basePath: string;
30
+ private readonly options: FileWriteLogOptions;
31
+ private readonly group: WriteGroup<string>;
32
+ private log: FileWriteLog | undefined;
33
+
34
+ constructor(basePath: string, options: FileKVStoreOptions = {}) {
35
+ assertFileWriteLogOptions(options);
36
+ this.basePath = path.resolve(basePath);
37
+ this.options = options;
38
+ this.group = new WriteGroup<string>(key => key, {
39
+ commit: (entries, commitOptions) => this.commit(entries, commitOptions),
40
+ // A store that never committed holds no log, and so has nothing to hasten.
41
+ sync: () => this.log?.sync() ?? Promise.resolve(),
42
+ }, options);
43
+ }
9
44
 
10
45
  async get(key: string): Promise<string | undefined> {
11
- try {
12
- return await fs.readFile(this.keyToPath(key), 'utf-8');
13
- } catch (err) {
14
- if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
15
- throw err;
16
- }
46
+ const held = this.group.lookup(key);
47
+ if (held !== undefined) return held === null ? undefined : decoder().decode(held);
48
+ await this.open();
49
+ const bytes = await readFileIfExists(this.keyToPath(key));
50
+ return bytes === undefined ? undefined : decoder().decode(bytes);
17
51
  }
18
52
 
19
53
  async set(key: string, value: string): Promise<void> {
20
- await atomicWriteFile(this.keyToPath(key), value);
54
+ this.group.put(key, encoder.encode(value));
21
55
  }
22
56
 
23
57
  async delete(key: string): Promise<void> {
58
+ this.group.delete(key);
59
+ }
60
+
61
+ /**
62
+ * Every key under `prefix`, which names a directory: it ends in `/`, as every prefix the node
63
+ * lists by does. The tree's keys, plus those written and not yet flushed, less those deleted and
64
+ * not yet flushed.
65
+ */
66
+ async list(prefix: string): Promise<string[]> {
67
+ // Taken before awaiting the tree: a commit resolving meanwhile drops entries from the overlay
68
+ // that the listing below may have been taken without.
69
+ const held = [...this.group.entries()].filter(([key]) => key.startsWith(prefix));
70
+ await this.open();
71
+ const keys = new Set<string>();
72
+ await this.listRecursive(path.join(this.basePath, ...prefix.split('/').filter(Boolean)), prefix, keys);
73
+ for (const [key, value] of held) {
74
+ if (value === null) keys.delete(key);
75
+ else keys.add(key);
76
+ }
77
+ return [...keys];
78
+ }
79
+
80
+ /** Make every write resolved before this call durable, at once or within `options.withinMs`: one log record, synced by then. The contract is `RawStoreDriver.flush`'s. */
81
+ flush(options?: FlushOptions): Promise<void> {
82
+ return this.group.flush(options);
83
+ }
84
+
85
+ /** Resolves once every write covered by a flush that resolved before this call is durable; `RawStoreDriver.whenDurable`'s contract. */
86
+ whenDurable(): Promise<void> {
87
+ return this.group.whenDurable();
88
+ }
89
+
90
+ /** Flush what is still held, then release this store's reference to the directory's log. */
91
+ async close(): Promise<void> {
24
92
  try {
25
- await fs.unlink(this.keyToPath(key));
26
- } catch (err) {
27
- if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
93
+ await this.group.close();
94
+ } finally {
95
+ const shared = this.log;
96
+ this.log = undefined;
97
+ await shared?.release();
28
98
  }
29
99
  }
30
100
 
31
- async list(prefix: string): Promise<string[]> {
32
- // prefix like "coordinator/" → scan basePath/coordinator/ directory
33
- const parts = prefix.split('/').filter(Boolean);
34
- const dirPath = path.join(this.basePath, ...parts);
35
- const results: string[] = [];
36
- await this.listRecursive(dirPath, prefix, results);
37
- return results;
101
+ private commit(entries: readonly WriteGroupEntry<string>[], options: SinkCommitOptions): Promise<CommittedBatch> {
102
+ return this.shared().commit(entries.map(({ key, value }): WriteLogEntry => value === undefined
103
+ ? { kind: 'delete', path: this.keyToRelative(key) }
104
+ : { kind: 'put', path: this.keyToRelative(key), value }), options);
38
105
  }
39
106
 
40
- private async listRecursive(dirPath: string, prefix: string, results: string[]): Promise<void> {
107
+ private shared(): FileWriteLog {
108
+ return this.log ??= FileWriteLog.acquire(this.basePath, this.options);
109
+ }
110
+
111
+ private open(): Promise<void> {
112
+ return this.shared().open();
113
+ }
114
+
115
+ private async listRecursive(dirPath: string, prefix: string, results: Set<string>): Promise<void> {
41
116
  let entries;
42
117
  try {
43
118
  entries = await fs.readdir(dirPath, { withFileTypes: true });
@@ -47,22 +122,27 @@ export class FileKVStore implements IKVStore {
47
122
  }
48
123
  for (const entry of entries) {
49
124
  if (entry.isDirectory()) {
50
- const subPrefix = prefix + entry.name + '/';
51
- await this.listRecursive(path.join(dirPath, entry.name), subPrefix, results);
125
+ await this.listRecursive(path.join(dirPath, entry.name), prefix + entry.name + '/', results);
52
126
  } else if (entry.isFile() && entry.name.endsWith('.json')) {
53
- results.push(prefix + entry.name.slice(0, -5));
127
+ results.add(prefix + entry.name.slice(0, -5));
54
128
  }
55
129
  }
56
130
  }
57
131
 
132
+ /** The key's file relative to the store's directory, forward slashes, as the log records it. */
133
+ private keyToRelative(key: string): string {
134
+ return `${key}.json`;
135
+ }
136
+
58
137
  private keyToPath(key: string): string {
59
- // NOTE: `/`-separated keys become nested dirs, so a key's first segment shares
60
- // the top-level namespace with FileRawStorage's <blockId>/ dirs. Safe today
61
- // because block ids are content hashes; if a KV key's first segment could ever
62
- // equal a block id, give the two stores separate basePaths (see README Usage).
63
- // FileRawStorage.listBlockIds also yields only root directories holding a meta.json, so a
64
- // KV directory such as the under-replication ledger's `under-replicated/` is never
65
- // enumerated as a block. Its bytes do count toward FileRawStorage.getApproximateBytesUsed.
138
+ // NOTE: `/`-separated keys become nested dirs, so a key's first segment shares the top-level
139
+ // namespace with FileRawStorage's <blockId>/ dirs. Safe while no block id's first segment
140
+ // (a block id containing `/` nests too) equals a key's first segment — the node's keys all start with a fixed word (`under-replicated`,
141
+ // `coordinator`, `participant`, `executed`) — and if one ever could, give the two stores
142
+ // separate basePaths (see README Usage). FileRawStorage.listBlockIds walks the tree but skips
143
+ // these root namespaces (db-p2p's NODE_KV_NAMESPACES — a new namespace must be added there),
144
+ // so a KV key whose last segment is `meta` is never enumerated as a block. Their bytes do
145
+ // count toward FileRawStorage.getApproximateBytesUsed.
66
146
  return path.join(this.basePath, ...key.split('/')) + '.json';
67
147
  }
68
148
  }