@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
package/src/index.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from './file-storage.js';
2
+ export * from './file-batch-backend.js';
3
+ export * from './file-write-log.js';
4
+ export * from './store-lock.js';
2
5
  export * from './file-kv-store.js';
@@ -0,0 +1,123 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('store-lock');
7
+
8
+ export const STORE_LOCK_FILENAME = 'store.lock';
9
+
10
+ /** What the lock file records about the process that took it. */
11
+ export interface StoreLockHolder {
12
+ readonly pid: number;
13
+ readonly hostname: string;
14
+ }
15
+
16
+ /**
17
+ * Thrown by every operation of a store whose directory another live process on this machine has
18
+ * open. Invariant 5 in `packages/db-p2p/docs/storage.md`: a store is owned by exactly one process,
19
+ * and the write-ahead log makes two writers in one file a corruption rather than a stale cache.
20
+ */
21
+ export class StoreLockedError extends Error {
22
+ constructor(readonly lockPath: string, readonly holder: StoreLockHolder) {
23
+ super(`store at ${path.dirname(lockPath)} is open in process ${holder.pid} on ${holder.hostname}; a store is owned by one process at a time`);
24
+ this.name = 'StoreLockedError';
25
+ }
26
+ }
27
+
28
+ export interface StoreLock {
29
+ readonly path: string;
30
+ /** Remove the lock file. Idempotent. */
31
+ release(): Promise<void>;
32
+ }
33
+
34
+ /** How many stale locks one open will replace before giving up on a directory whose lock keeps reappearing. */
35
+ const REPLACE_ATTEMPTS = 3;
36
+
37
+ /**
38
+ * Take `<basePath>/store.lock` for this process. The file is created exclusively (`wx`) and holds
39
+ * `{ pid, hostname }`. An existing lock naming this machine and a live pid refuses the open with
40
+ * {@link StoreLockedError}; any other existing lock — another machine's, a dead pid's, or one whose
41
+ * content cannot be read — is stale and replaced. The liveness test is `process.kill(pid, 0)`, so
42
+ * only one machine is protected, which is the one case ever supported (two machines over one
43
+ * network share never were; Invariant 5).
44
+ *
45
+ * NOTE: the content is written in the same call that creates the file, but a second process that
46
+ * opens the directory in the microseconds between the create and the write reads an empty file,
47
+ * judges the lock stale and replaces it. Two processes starting over one directory at the same
48
+ * instant is already the violation the lock exists to refuse; if that race is ever seen, judge an
49
+ * unreadable lock by its age instead of replacing it at once.
50
+ * NOTE: the holder is identified by pid and hostname alone. A dead holder's pid reused by an
51
+ * unrelated process reads as live, and the open is refused until the lock file is removed by hand;
52
+ * a machine whose hostname changed between two starts reads its own live lock as another
53
+ * machine's and replaces it. Both need a start-time stamp in the lock to tell apart; add one if
54
+ * either is seen.
55
+ */
56
+ export async function acquireStoreLock(basePath: string): Promise<StoreLock> {
57
+ const lockPath = path.join(basePath, STORE_LOCK_FILENAME);
58
+ const self: StoreLockHolder = { pid: process.pid, hostname: os.hostname() };
59
+ for (let attempt = 0; attempt < REPLACE_ATTEMPTS; attempt++) {
60
+ if (await tryCreate(lockPath, self)) {
61
+ return { path: lockPath, release: () => removeLock(lockPath) };
62
+ }
63
+ const holder = await readHolder(lockPath);
64
+ if (holder && holder.hostname === self.hostname && processExists(holder.pid)) {
65
+ throw new StoreLockedError(lockPath, holder);
66
+ }
67
+ log('replacing stale lock %s held by %o', lockPath, holder);
68
+ await removeLock(lockPath);
69
+ }
70
+ throw new Error(`could not take ${lockPath}: it was replaced ${REPLACE_ATTEMPTS} times by another opener`);
71
+ }
72
+
73
+ async function tryCreate(lockPath: string, holder: StoreLockHolder): Promise<boolean> {
74
+ try {
75
+ await fs.writeFile(lockPath, JSON.stringify(holder), { flag: 'wx' });
76
+ return true;
77
+ } catch (err) {
78
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ /** The holder a lock file names, or `undefined` for a missing, unreadable or malformed one. */
84
+ async function readHolder(lockPath: string): Promise<StoreLockHolder | undefined> {
85
+ let text: string;
86
+ try {
87
+ text = await fs.readFile(lockPath, 'utf8');
88
+ } catch (err) {
89
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
90
+ throw err;
91
+ }
92
+ try {
93
+ const parsed: unknown = JSON.parse(text);
94
+ return isHolder(parsed) ? parsed : undefined;
95
+ } catch (err) {
96
+ if (err instanceof SyntaxError) return undefined;
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ function isHolder(value: unknown): value is StoreLockHolder {
102
+ if (typeof value !== 'object' || value === null) return false;
103
+ const { pid, hostname } = value as Record<string, unknown>;
104
+ return Number.isInteger(pid) && (pid as number) > 0 && typeof hostname === 'string';
105
+ }
106
+
107
+ /** Signal 0 tests for existence without sending anything; EPERM means the process exists but is not ours. */
108
+ function processExists(pid: number): boolean {
109
+ try {
110
+ process.kill(pid, 0);
111
+ return true;
112
+ } catch (err) {
113
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
114
+ }
115
+ }
116
+
117
+ async function removeLock(lockPath: string): Promise<void> {
118
+ try {
119
+ await fs.unlink(lockPath);
120
+ } catch (err) {
121
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
122
+ }
123
+ }
@@ -1,22 +0,0 @@
1
- /**
2
- * Atomically write `content` to `filePath`.
3
- *
4
- * Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
5
- * place. `rename` over an existing file is atomic on POSIX and NTFS, so a
6
- * concurrent reader only ever sees the complete old file or the complete new
7
- * file — never a torn/half-written one. After the rename we best-effort fsync
8
- * the containing directory so the rename itself survives power loss on POSIX;
9
- * that directory fsync is unsupported on win32 and is skipped there.
10
- *
11
- * On any failure the temp file is removed (best-effort) so a crashed write does
12
- * not leave the canonical path damaged. A crash *between* the temp write and the
13
- * rename leaves only an inert `*.tmp` sibling — never read (reads target
14
- * canonical paths) and skipped by `.json` directory scans.
15
- *
16
- * `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
17
- * `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
18
- * `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
19
- * byte-for-byte, so non-ASCII JSON round-trips exactly.
20
- */
21
- export declare function atomicWriteFile(filePath: string, content: string | Uint8Array): Promise<void>;
22
- //# sourceMappingURL=atomic-write.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"atomic-write.d.ts","sourceRoot":"","sources":["../../src/atomic-write.ts"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAkCnG"}
@@ -1,90 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import * as path from 'path';
3
- // Per-process monotonic counter for unique temp names. Combined with the pid it
4
- // makes each temp path unique across concurrent writers to the same logical
5
- // target — even two FileRawStorage/FileKVStore instances in one process, which
6
- // do not lock (see the proper-lockfile TODO in file-storage.ts). Deterministic
7
- // and collision-safe, unlike Math.random()/Date.now().
8
- let tempCounter = 0;
9
- /**
10
- * Atomically write `content` to `filePath`.
11
- *
12
- * Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
13
- * place. `rename` over an existing file is atomic on POSIX and NTFS, so a
14
- * concurrent reader only ever sees the complete old file or the complete new
15
- * file — never a torn/half-written one. After the rename we best-effort fsync
16
- * the containing directory so the rename itself survives power loss on POSIX;
17
- * that directory fsync is unsupported on win32 and is skipped there.
18
- *
19
- * On any failure the temp file is removed (best-effort) so a crashed write does
20
- * not leave the canonical path damaged. A crash *between* the temp write and the
21
- * rename leaves only an inert `*.tmp` sibling — never read (reads target
22
- * canonical paths) and skipped by `.json` directory scans.
23
- *
24
- * `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
25
- * `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
26
- * `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
27
- * byte-for-byte, so non-ASCII JSON round-trips exactly.
28
- */
29
- export async function atomicWriteFile(filePath, content) {
30
- const dir = path.dirname(filePath);
31
- await fs.mkdir(dir, { recursive: true });
32
- // Suffix ends in `.tmp` (not `.json`) so listPendingTransactions / FileKVStore.list,
33
- // which filter on `.json`, never surface an in-flight temp file.
34
- // NOTE: a crash between open and rename leaves an inert `*.tmp` orphan (never read,
35
- // skipped by `.json` scans). No cleanup sweep exists; if a crash-looping writer ever
36
- // accumulates many, add a startup sweep of stale `*.tmp` siblings.
37
- const tmpPath = path.join(dir, `${path.basename(filePath)}.${process.pid}.${tempCounter++}.tmp`);
38
- let handle;
39
- try {
40
- handle = await fs.open(tmpPath, 'w');
41
- await handle.writeFile(content);
42
- await handle.sync();
43
- await handle.close();
44
- handle = undefined;
45
- // NOTE: on win32, rename-over-existing can throw EPERM/EACCES/EBUSY if a
46
- // concurrent reader holds the target open without FILE_SHARE_DELETE (Node
47
- // readIfExists/get open→read→close in a tiny window, so it's rare). Modern
48
- // libuv retries some cases; if this ever surfaces as spurious write failures
49
- // under concurrent read+write on Windows, add a bounded retry loop here (the
50
- // write-file-atomic package does exactly this). Conditional — the adapter is
51
- // already last-writer-wins with no cross-process lock (proper-lockfile TODO
52
- // in file-storage.ts), so it is not reachable under current single-writer use.
53
- await fs.rename(tmpPath, filePath);
54
- }
55
- catch (err) {
56
- if (handle)
57
- await handle.close().catch(() => { });
58
- await fs.unlink(tmpPath).catch(() => { });
59
- throw err;
60
- }
61
- await fsyncDir(dir);
62
- }
63
- /**
64
- * Best-effort fsync of a directory so a completed rename is durable. POSIX needs
65
- * this; platforms that reject opening a directory for fsync throw, and we ignore
66
- * that rather than failing the write.
67
- *
68
- * Skipped outright on win32: there `fs.open(dir, 'r')` SUCCEEDS and only the
69
- * following `handle.sync()` fails with EPERM (swallowed), so the open/close pair
70
- * was guaranteed wasted work — exactly half of all `fs.open` calls a write
71
- * workload makes. Durability is unchanged on win32 (the sync never succeeded
72
- * there); the on-disk layout is untouched.
73
- */
74
- async function fsyncDir(dir) {
75
- if (process.platform === 'win32')
76
- return;
77
- let handle;
78
- try {
79
- handle = await fs.open(dir, 'r');
80
- await handle.sync();
81
- }
82
- catch {
83
- // Directory fsync unsupported here — nothing to do.
84
- }
85
- finally {
86
- if (handle)
87
- await handle.close().catch(() => { });
88
- }
89
- }
90
- //# sourceMappingURL=atomic-write.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"atomic-write.js","sourceRoot":"","sources":["../../src/atomic-write.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAE7B,gFAAgF;AAChF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,uDAAuD;AACvD,IAAI,WAAW,GAAG,CAAC,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB,EAAE,OAA4B;IACnF,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAEzC,qFAAqF;IACrF,iEAAiE;IACjE,oFAAoF;IACpF,qFAAqF;IACrF,mEAAmE;IACnE,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,GAAG,IAAI,WAAW,EAAE,MAAM,CAAC,CAAC;IAEjG,IAAI,MAAiC,CAAC;IACtC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QACrC,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QAChC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,GAAG,SAAS,CAAC;QACnB,yEAAyE;QACzE,0EAA0E;QAC1E,2EAA2E;QAC3E,6EAA6E;QAC7E,6EAA6E;QAC7E,6EAA6E;QAC7E,4EAA4E;QAC5E,+EAA+E;QAC/E,MAAM,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACd,IAAI,MAAM;YAAE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAqB,CAAC,CAAC,CAAC;QACpE,MAAM,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAyC,CAAC,CAAC,CAAC;QAChF,MAAM,GAAG,CAAC;IACX,CAAC;IAED,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;;;;;GAUG;AACH,KAAK,UAAU,QAAQ,CAAC,GAAW;IAClC,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO;IACzC,IAAI,MAAiC,CAAC;IACtC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACjC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACR,oDAAoD;IACrD,CAAC;YAAS,CAAC;QACV,IAAI,MAAM;YAAE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAqB,CAAC,CAAC,CAAC;IACrE,CAAC;AACF,CAAC"}
@@ -1,89 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import * as path from 'path';
3
-
4
- // Per-process monotonic counter for unique temp names. Combined with the pid it
5
- // makes each temp path unique across concurrent writers to the same logical
6
- // target — even two FileRawStorage/FileKVStore instances in one process, which
7
- // do not lock (see the proper-lockfile TODO in file-storage.ts). Deterministic
8
- // and collision-safe, unlike Math.random()/Date.now().
9
- let tempCounter = 0;
10
-
11
- /**
12
- * Atomically write `content` to `filePath`.
13
- *
14
- * Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
15
- * place. `rename` over an existing file is atomic on POSIX and NTFS, so a
16
- * concurrent reader only ever sees the complete old file or the complete new
17
- * file — never a torn/half-written one. After the rename we best-effort fsync
18
- * the containing directory so the rename itself survives power loss on POSIX;
19
- * that directory fsync is unsupported on win32 and is skipped there.
20
- *
21
- * On any failure the temp file is removed (best-effort) so a crashed write does
22
- * not leave the canonical path damaged. A crash *between* the temp write and the
23
- * rename leaves only an inert `*.tmp` sibling — never read (reads target
24
- * canonical paths) and skipped by `.json` directory scans.
25
- *
26
- * `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
27
- * `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
28
- * `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
29
- * byte-for-byte, so non-ASCII JSON round-trips exactly.
30
- */
31
- export async function atomicWriteFile(filePath: string, content: string | Uint8Array): Promise<void> {
32
- const dir = path.dirname(filePath);
33
- await fs.mkdir(dir, { recursive: true });
34
-
35
- // Suffix ends in `.tmp` (not `.json`) so listPendingTransactions / FileKVStore.list,
36
- // which filter on `.json`, never surface an in-flight temp file.
37
- // NOTE: a crash between open and rename leaves an inert `*.tmp` orphan (never read,
38
- // skipped by `.json` scans). No cleanup sweep exists; if a crash-looping writer ever
39
- // accumulates many, add a startup sweep of stale `*.tmp` siblings.
40
- const tmpPath = path.join(dir, `${path.basename(filePath)}.${process.pid}.${tempCounter++}.tmp`);
41
-
42
- let handle: fs.FileHandle | undefined;
43
- try {
44
- handle = await fs.open(tmpPath, 'w');
45
- await handle.writeFile(content);
46
- await handle.sync();
47
- await handle.close();
48
- handle = undefined;
49
- // NOTE: on win32, rename-over-existing can throw EPERM/EACCES/EBUSY if a
50
- // concurrent reader holds the target open without FILE_SHARE_DELETE (Node
51
- // readIfExists/get open→read→close in a tiny window, so it's rare). Modern
52
- // libuv retries some cases; if this ever surfaces as spurious write failures
53
- // under concurrent read+write on Windows, add a bounded retry loop here (the
54
- // write-file-atomic package does exactly this). Conditional — the adapter is
55
- // already last-writer-wins with no cross-process lock (proper-lockfile TODO
56
- // in file-storage.ts), so it is not reachable under current single-writer use.
57
- await fs.rename(tmpPath, filePath);
58
- } catch (err) {
59
- if (handle) await handle.close().catch(() => { /* best-effort */ });
60
- await fs.unlink(tmpPath).catch(() => { /* best-effort: temp may not exist */ });
61
- throw err;
62
- }
63
-
64
- await fsyncDir(dir);
65
- }
66
-
67
- /**
68
- * Best-effort fsync of a directory so a completed rename is durable. POSIX needs
69
- * this; platforms that reject opening a directory for fsync throw, and we ignore
70
- * that rather than failing the write.
71
- *
72
- * Skipped outright on win32: there `fs.open(dir, 'r')` SUCCEEDS and only the
73
- * following `handle.sync()` fails with EPERM (swallowed), so the open/close pair
74
- * was guaranteed wasted work — exactly half of all `fs.open` calls a write
75
- * workload makes. Durability is unchanged on win32 (the sync never succeeded
76
- * there); the on-disk layout is untouched.
77
- */
78
- async function fsyncDir(dir: string): Promise<void> {
79
- if (process.platform === 'win32') return;
80
- let handle: fs.FileHandle | undefined;
81
- try {
82
- handle = await fs.open(dir, 'r');
83
- await handle.sync();
84
- } catch {
85
- // Directory fsync unsupported here — nothing to do.
86
- } finally {
87
- if (handle) await handle.close().catch(() => { /* best-effort */ });
88
- }
89
- }