@optimystic/db-p2p-storage-fs 1.14.0 → 1.16.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
package/README.md CHANGED
@@ -11,11 +11,13 @@ Node.js filesystem storage backend for Optimystic peers. Provides:
11
11
  acknowledged before every cohort member held them), and the persistent
12
12
  transaction state used to recover crashed two-phase commits.
13
13
 
14
+ Both write through one write-ahead log per directory (below).
15
+
14
16
  This package targets plain Node.js. Use the sibling adapter for other
15
17
  environments:
16
18
 
17
19
  - `@optimystic/db-p2p-storage-ns` — NativeScript (iOS/Android, SQLite)
18
- - `@optimystic/db-p2p-storage-rn` — React Native (MMKV)
20
+ - `@optimystic/db-p2p-storage-rn` — React Native (LevelDB)
19
21
  - `@optimystic/db-p2p-storage-web` — Browser (IndexedDB)
20
22
 
21
23
  ## Install
@@ -39,21 +41,46 @@ const libp2p = await createLibp2pNode({
39
41
  storage: rawStorage,
40
42
  kvStore,
41
43
  });
44
+
45
+ // … and on shutdown, after the node has stopped:
46
+ await libp2p.stop();
47
+ await kvStore.close();
48
+ await rawStorage.close();
42
49
  ```
43
50
 
44
- Both constructors take a single `basePath` — the directory under which all
45
- data is stored. They can share the same `basePath` in practice because their
51
+ The node never closes a store it was handed, so close both after `stop()`.
52
+ Closing flushes what the store still holds and, for the last store over the
53
+ directory, checkpoints the write-ahead log and removes the lock. A store that is
54
+ never closed loses nothing it flushed, but leaves a log for the next open to
55
+ replay and a lock the next open replaces as stale.
56
+
57
+ Both constructors take a `basePath` — the directory under which all data is
58
+ stored — and an optional options object:
59
+
60
+ | Option | Default | What it does |
61
+ | --- | --- | --- |
62
+ | `idleFlushMs` | 1000 | How long a write may wait for a `flush()` before the store flushes it itself |
63
+ | `checkpointBytes` | 16 MiB | Checkpoint the log once its records reach this size |
64
+ | `checkpointIdleMs` | 5000 | Checkpoint the log this long after its last append |
65
+
66
+ The two checkpoint settings belong to the directory's one log, so they are the
67
+ first-opened store's; a store opened beside it with different values logs them
68
+ on the `optimystic:db-p2p-storage-fs:write-log` channel and shares the open
69
+ log's.
70
+
71
+ The two stores can share the same `basePath` in practice because their
46
72
  top-level names do not collide: block data lives under `<blockId>/`
47
- subdirectories (block ids are content-address hashes), while KV data lives
48
- under directories named by the first segment of each key (a `/`-separated key
49
- becomes nested subdirectories — e.g. `coordinator/key1` → `coordinator/key1.json`).
50
- Sharing is safe only as long as no block id equals a KV key's first segment;
51
- give them separate `basePath`s if you cannot guarantee that. The KV keys the
52
- node itself writes all start with a fixed literal segment (`under-replicated/`
53
- for the ledger), and `FileRawStorage.listBlockIds` yields only directories that
54
- contain a `meta.json`, so a KV directory is never mistaken for a block. KV
55
- bytes do count toward `FileRawStorage.getApproximateBytesUsed` when the path is
56
- shared.
73
+ subdirectories, while KV data lives under directories named by the first
74
+ segment of each key (a `/`-separated key becomes nested subdirectories — e.g.
75
+ `coordinator/key1` → `coordinator/key1.json`). Sharing is safe only as long as
76
+ no block id's first segment (a block id containing `/` nests the same way)
77
+ equals a KV key's first segment; give them separate `basePath`s if
78
+ you cannot guarantee that. The KV keys the node itself writes all start with a
79
+ fixed literal segment (`under-replicated/`, `coordinator/`, `participant/`,
80
+ `executed/` — db-p2p's `NODE_KV_NAMESPACES`), and `FileRawStorage.listBlockIds`
81
+ skips those root directories, so a KV key is never mistaken for a block. KV bytes do count
82
+ toward `FileRawStorage.getApproximateBytesUsed` when the path is shared. Over
83
+ one `basePath` the two share one log, one lock and one checkpoint.
57
84
 
58
85
  Without a `kvStore`, the node falls back to an in-memory store and logs a
59
86
  `node-wiring` warning: what the ledger records is then lost on restart.
@@ -69,8 +96,14 @@ Without a `kvStore`, the node falls back to an in-memory store and logs a
69
96
  actions/<id>.json — committed Transform
70
97
  proofs/<rev>.json — BlockCommitProof retained for that revision
71
98
  blocks/<id>.json — materialized IBlock snapshot
99
+ <seg>/<seg>/ — a block id containing "/" (e.g. `default/app/Ledger`) nests one directory per segment
100
+ meta.json — and holds the same files as above; it can contain another block's directory
101
+ index/<seg>/meta.json — (e.g. `default/app/Ledger/index/LedgerLatest`)
72
102
  <key-segment>/
73
103
  <key-segment>.json — FileKVStore value; key "/" separators become subdirectories
104
+ write-ahead.log — groups written since the last checkpoint
105
+ write-ahead.log.sealed — the log a checkpoint is syncing the tree under; present only during one, or after a crash in one
106
+ store.lock — { pid, hostname } of the process holding the store
74
107
  ```
75
108
 
76
109
  Action ids that contain a colon (e.g. `tx:abcd1234`) are percent-encoded in
@@ -78,12 +111,83 @@ filenames (`tx%3Aabcd1234.json`) for Windows compatibility; the storage layer
78
111
  encodes and decodes transparently so callers always work with the canonical
79
112
  id form.
80
113
 
81
- ## Atomic writes
82
-
83
- Every write goes through `atomic-write.ts`: the new content is written to a
84
- `.tmp` sibling, then renamed over the canonical path. A crash mid-write
85
- therefore leaves either the complete old file or the complete new file — never
86
- a partial/torn one.
114
+ ## Write-ahead log
115
+
116
+ Each store holds its writes in memory and, at a `flush()`, appends everything
117
+ written since the last flush to `<basePath>/write-ahead.log` as one record — a
118
+ length, a CRC-32, then each put or delete with its path. A flush given no
119
+ window (`withinMs` absent or 0) then waits for the disk once, and that sync
120
+ covers every record appended before it. A flush given a window resolves as soon
121
+ as the record is appended; the log syncs by the earliest deadline any waiting
122
+ record was given, so many groups share one sync. A group that arrives to find
123
+ an earlier deadline already passed makes the sync itself before it resolves,
124
+ whatever its own window, so a disk that falls behind slows the writers rather
125
+ than widening the window. Only after the sync are the files written to their
126
+ canonical paths in the tree, without waiting. A tree file therefore never holds
127
+ content whose log record is not durable, and the next open replays the log in
128
+ order before serving anything.
129
+
130
+ **What a crash loses.** A crash of the process loses nothing a flush resolved:
131
+ every record is in the operating system's hands before its flush resolves, and
132
+ the next open replays it. A power cut or operating-system crash loses at most
133
+ the groups still inside their window — whole groups only, never half of one,
134
+ and always the last ones: the log is one sequence, and a record cut short, or
135
+ one whose checksum fails because a later page of the log was written while an
136
+ earlier one was not, ends the replay there, so what survives is a prefix of the
137
+ groups in flush order.
138
+
139
+ **Checkpoint.** The log does not grow for ever. A checkpoint seals the log —
140
+ renames it to `write-ahead.log.sealed` and starts an empty one, which new
141
+ writes go to at once — then syncs every file written since the last checkpoint
142
+ and every directory whose entries changed, then deletes the sealed segment. It
143
+ runs when the log reaches `checkpointBytes`, `checkpointIdleMs` after the last
144
+ append, and when the last store over the directory closes. Only the seal waits
145
+ in line with writes; the syncs run beside them. A crash during a checkpoint
146
+ leaves the sealed segment behind, and the next open replays it before the
147
+ current log. A checkpoint that fails latches the store failed, like a failed
148
+ flush, because a half-synced tree under a discarded log could not be recovered.
149
+
150
+ ## Durability
151
+
152
+ `FileRawStorage` reports `durability: 'power-loss'`, and a resolved
153
+ `FileKVStore.flush()` promises the same: a flushed group survives a power cut
154
+ once its window has passed, or at once for a flush given none, and the tree is
155
+ rebuilt from the log on the next open. `whenDurable()` resolves once every group
156
+ flushed so far is synced. The log is synced once per strict group and once per
157
+ window for the relaxed groups appended inside it; a strict flush also covers
158
+ every relaxed group waiting, which is how a strict flush of one store makes the
159
+ other store's earlier groups durable too. The write path syncs nothing else.
160
+ Directories are synced at the checkpoint (and at open, by the replay), not per
161
+ write; on Windows, where a directory cannot be synced, only files are. See
162
+ [storage.md §6](../db-p2p/docs/storage.md#6-a-write-survives-a-crash-once-a-flush-called-after-it-has-resolved).
163
+
164
+ The idle checkpoint, `checkpointIdleMs` after the last append, syncs whatever is
165
+ waiting before it seals the log, so a record waits at most the shorter of its
166
+ window and that setting; the default 5 s equals the longest window the node
167
+ computes. A sync that started after a deadline it covered — the disk, or the
168
+ log's queue, fell behind — is logged once per such episode as `window-overrun`
169
+ on the `optimystic:db-p2p-storage-fs:write-log` channel, with how late it
170
+ started and how many groups it covered.
171
+
172
+ A solo node's write therefore costs three syncs: its pend, its commit, and the
173
+ under-replication entry it records, since the ledger flushes after every call
174
+ that wrote, and every one of those flushes is strict. A write a cohort agreed to
175
+ is flushed with the window its cohort allows, so a member applying many such
176
+ writes pays one sync per window instead. GitHub issue #37 measured more than
177
+ seventy syncs for one solo write before the log.
178
+
179
+ Opening a store takes `<basePath>/store.lock`, which records the holder's
180
+ `{ pid, hostname }`. A lock naming a live process on this machine refuses the
181
+ open with `StoreLockedError` from every operation; a lock left by a process that
182
+ has exited, or by another machine, is stale and replaced. Every store over one
183
+ directory in one process shares one log and one lock.
184
+
185
+ **Downgrading.** A build older than the log reads the tree and ignores
186
+ `write-ahead.log`, so it sees only what the tree held: after an unclean stop it
187
+ loses the groups the log held and the tree did not, and a later upgrade back
188
+ would replay that stale log over what the older build wrote meanwhile. A clean
189
+ `close()` checkpoints first, leaving an empty log. To downgrade after an
190
+ unclean stop, open and close the store once with this build first.
87
191
 
88
192
  ## Identity
89
193
 
@@ -105,24 +209,29 @@ cleanup:
105
209
 
106
210
  - **Shared conformance suite** — `runRawStorageConformance('FileSystem', …)` from
107
211
  `@optimystic/db-p2p/testing/conformance`, the one cross-backend parity target. It proves the
108
- fs backend (now `KvRawStorage` over a `FileStoreDriver`) behaves identically to
109
- every other backend: round-trips, `listRevisions` ordering, promote atomicity +
110
- the exact missing-pend error, clone-on-store/read (structural via the byte
111
- boundary), drain-before-yield iteration, and a `BlockStorage` parity slice.
212
+ fs backend (`KvRawStorage` over `WriteGroupDriver` over a `FileBatchBackend`)
213
+ behaves identically to every other backend: round-trips, `listRevisions`
214
+ ordering, promote atomicity + the exact missing-pend error, clone-on-store/read
215
+ (structural via the byte boundary), drain-before-yield iteration, a
216
+ `BlockStorage` parity slice, and the flush-then-reopen case.
112
217
  - **fs-only tests** (`node:assert`) — the behaviors the shared suite can't cover:
113
- atomic writes + torn-file corruption tolerance, `readdir` error discrimination,
114
- colon-encoded filenames + the POSIX legacy raw-colon fallback, the directory-based
115
- `listBlockIds` meta-gate, and `FileKVStore.list`/`delete`.
218
+ what the log leaves in the tree and what a reopen replays
219
+ (`test/file-write-log.spec.ts` for a damaged log, a failed apply, a failed sync,
220
+ the sync a relaxed group defers and what hastens it, the checkpoint and its
221
+ crash cases, and the lock; `test/fsync-count.spec.ts`
222
+ for one sync per `StorageRepo` group), torn-file corruption tolerance,
223
+ `readdir` error discrimination, colon-encoded filenames + the POSIX legacy
224
+ raw-colon fallback, the directory-based `listBlockIds` meta-gate, `FileKVStore`
225
+ listings over unflushed writes and the log it shares with `FileRawStorage`,
226
+ and the under-replication ledger over `FileKVStore`.
116
227
 
117
228
  ## Known limitations
118
229
 
119
- **No cross-process lock.** Two separate Node processes pointing at the same
120
- `basePath` can interleave writes with no coordination. The constructor carries
121
- a TODO — the "proper-lockfile" comment in
122
- `packages/db-p2p-storage-fs/src/file-storage.ts` — to integrate
123
- [`proper-lockfile`](https://www.npmjs.com/package/proper-lockfile) along with
124
- an explicit `dispose()` pattern. Until that lands, `FileRawStorage` is
125
- single-process only.
230
+ **The lock protects one machine.** `store.lock` refuses a second live process
231
+ on the same host; it cannot tell a live process on another machine from a dead
232
+ one, so two machines over one network share can both open the store. That was
233
+ never supported (Invariant 5 in
234
+ [storage.md](../db-p2p/docs/storage.md#5-a-store-is-owned-by-exactly-one-process)).
126
235
 
127
236
  **Ephemeral peer identity.** There is no `loadOrCreateFSPeerKey` equivalent.
128
237
  A Node peer backed by `FileRawStorage` gets a new libp2p peer id each restart.
@@ -0,0 +1,69 @@
1
+ import type { BlockId, ActionId } from '@optimystic/db-core';
2
+ import { type BatchBackend, type BatchEntry, type CommittedBatch, type SinkCommitOptions, type StoreDurability, type StoreIdentity, type StoreKey } from '@optimystic/db-p2p';
3
+ import { type FileWriteLogOptions } from './file-write-log.js';
4
+ /**
5
+ * Filesystem {@link BatchBackend}: the six logical block-storage stores mapped to six paths under
6
+ * `basePath/<blockId>/` (`{meta.json,revs/,pend/,actions/,proofs/,blocks/}`), every batch committed
7
+ * through the directory's {@link FileWriteLog}. The directory tree is a deliberate,
8
+ * human-inspectable layout — it is NOT flattened into encoded-filename KV keys.
9
+ *
10
+ * `KvRawStorage` owns all JSON serialization, so this backend reads raw `Uint8Array` bytes and
11
+ * never does `JSON.stringify/parse` on values. What is fs-specific lives here: the path per
12
+ * {@link StoreKey}, the corrupt-content-as-missing read guard, colon-encoded action-id filenames
13
+ * with the legacy raw-colon read fallback and win32 guards, and the directory listings.
14
+ *
15
+ * Every operation opens the shared log first — on the first one: take the lock, replay, then
16
+ * serve — so no read ever sees a tree the log has not been replayed into. The log is acquired on
17
+ * that first operation rather than in the constructor, so an instance that is constructed and
18
+ * never used (one `withReadCache` dedupes away) holds no handle and no lock.
19
+ */
20
+ export declare class FileBatchBackend implements BatchBackend {
21
+ private readonly options;
22
+ /** 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. */
23
+ readonly durability: StoreDurability;
24
+ readonly basePath: string;
25
+ private readonly identity;
26
+ private log;
27
+ /** `options` are the directory log's checkpoint settings, applied when this is the first store to open it. */
28
+ constructor(basePath: string, options?: FileWriteLogOptions);
29
+ storeIdentity(): StoreIdentity;
30
+ get(key: StoreKey): Promise<Uint8Array | undefined>;
31
+ rangeRevisions(blockId: BlockId, lo: number, hi: number): Promise<[number, Uint8Array][]>;
32
+ listPendingActionIds(blockId: BlockId): Promise<ActionId[]>;
33
+ listBlockIds(): AsyncIterable<BlockId>;
34
+ private walkBlockDirectories;
35
+ private childDirectoryNames;
36
+ private hasMetadataFile;
37
+ /** The tree's bytes, the write-ahead log and the lock included: all of it is what the store occupies. */
38
+ approximateBytesUsed(): Promise<number>;
39
+ /** One log record, synced within `syncWithinMs` (see `FileWriteLog.commit`); the `durable` returned is the log's. */
40
+ commit(entries: readonly BatchEntry[], options: SinkCommitOptions): Promise<CommittedBatch>;
41
+ /** Hasten the log's sync. A backend that never committed holds no log, and so has nothing to hasten. */
42
+ sync(): Promise<void>;
43
+ close(): Promise<void>;
44
+ private shared;
45
+ private open;
46
+ /**
47
+ * A put is one entry at the key's canonical path. A delete of an action-scoped record is that
48
+ * path's entry plus, off win32 and when the id carries a colon, the pre-encode raw-colon sibling's,
49
+ * so a deleted item cannot resurface through the read fallback in readActionScopedBytes — in the
50
+ * log, so a replay removes the sibling too.
51
+ *
52
+ * NOTE: that sibling entry names a path with a colon, which only a POSIX build writes; a log
53
+ * carrying one and moved unreplayed to a win32 machine replays it as an unlink of an NTFS
54
+ * stream path, where anything but ENOENT fails the open. A store is not moved across platforms
55
+ * mid-log today; if it ever is, have the replay skip a delete it cannot spell on this platform.
56
+ */
57
+ private toLogEntries;
58
+ private relativePath;
59
+ private relativeActionScopedPath;
60
+ private blockPath;
61
+ private metadataPath;
62
+ private revisionPath;
63
+ private proofPath;
64
+ private actionScopedPath;
65
+ private readActionScopedBytes;
66
+ private readBytesIfExists;
67
+ private directoryByteSize;
68
+ }
69
+ //# sourceMappingURL=file-batch-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-batch-backend.d.ts","sourceRoot":"","sources":["../../src/file-batch-backend.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAsB,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,KAAK,iBAAiB,EAAE,KAAK,eAAe,EAAE,KAAK,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAElM,OAAO,EAAgF,KAAK,mBAAmB,EAAsB,MAAM,qBAAqB,CAAC;AAuEjK;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,gBAAiB,YAAW,YAAY;IAStB,OAAO,CAAC,QAAQ,CAAC,OAAO;IARtD,2JAA2J;IAC3J,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAgB;IAEpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,GAAG,CAA2B;IAEtC,8GAA8G;gBAClG,QAAQ,EAAE,MAAM,EAAmB,OAAO,GAAE,mBAAwB;IAMhF,aAAa,IAAI,aAAa;IAMxB,GAAG,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IAiCnD,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,CAAC;IAczF,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAyC1D,YAAY,IAAI,aAAa,CAAC,OAAO,CAAC;YA4B9B,oBAAoB;YAWrB,mBAAmB;IAUjC,OAAO,CAAC,eAAe;IAUvB,yGAAyG;IACnG,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC;IAO7C,qHAAqH;IACrH,MAAM,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC;IAI3F,wGAAwG;IACxG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAIf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,IAAI;IAIZ;;;;;;;;;;OAUG;IACH,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,YAAY;IAkBpB,OAAO,CAAC,wBAAwB;IAKhC,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,gBAAgB;YAqBV,qBAAqB;YAcrB,iBAAiB;YAUjB,iBAAiB;CA0B/B"}