@oxidezap/baileyrs 0.2.11 → 0.2.13

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.
@@ -1,11 +1,13 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
2
+ import { mkdir, open, readdir, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { dirname, join } from 'node:path';
4
5
  /**
5
6
  * Stores whose loss on SIGKILL produces undecryptable messages or
6
7
  * permanent app-state divergence. Each entry below carries a reason —
7
- * promoting a store to "critical" doubles disk I/O, so don't add
8
- * anything here that can be re-derived from the network.
8
+ * promoting a store to "critical" multiplies disk I/O (write + fsync +
9
+ * atomic rename per key), so don't add anything here that can be
10
+ * re-derived from the network.
9
11
  *
10
12
  * `session` / `identity` — Signal session ratchet steps. Lose
11
13
  * one step → next inbound message from peer undecryptable.
@@ -37,113 +39,562 @@ const CRITICAL_STORES = new Set([
37
39
  'mutation_mac'
38
40
  ]);
39
41
  /**
40
- * `ENOENT` is the only error tolerated on a write/readdir: it means the auth
41
- * folder was removed (e.g. during shutdown/cleanup). Any other error (ENOSPC,
42
- * EACCES, EIO, ENOTDIR) is a real persistence failure that MUST propagate
43
- * swallowing it would silently lose Signal session state or message secrets
44
- * while the caller believes the write succeeded.
42
+ * `ENOENT` is legitimate only where absence is a valid answer: reading a
43
+ * key that was never written (or was deleted), deleting a key that is
44
+ * already gone, and enumerating a folder that was removed. Everywhere
45
+ * else writes, flushes, non-ENOENT read/delete failures (ENOTDIR,
46
+ * EACCES, EIO, ENOSPC) the error MUST propagate. A write that resolves
47
+ * without durable bytes while the caller believes it persisted silently
48
+ * loses Signal session state or message secrets.
45
49
  */
46
50
  const isEnoent = (e) => e?.code === 'ENOENT';
51
+ const errnoOf = (e) => e?.code;
52
+ const bytesEqual = (a, b) => a.length === b.length && Buffer.compare(a, b) === 0;
53
+ // Real copy for both Uint8Array and Buffer. `Buffer.prototype.slice`
54
+ // deliberately returns a view over the same memory, so `.slice()` is NOT
55
+ // a valid snapshot for caller-owned buffers.
56
+ const copyBytes = (value) => Uint8Array.from(value);
57
+ /**
58
+ * Pure classifier for directory-barrier failures: does this error mean
59
+ * "this platform cannot sync directory handles" (degrade to process-crash
60
+ * atomicity) or "the durability barrier did not hold" (propagate)?
61
+ *
62
+ * - `ENOSYS` / `ENOTSUP` on any platform: the operation is not
63
+ * implemented — genuinely unsupported, safe to degrade.
64
+ * - `EINVAL` / `EPERM` / `EISDIR` on `win32` only: Windows directory
65
+ * handles reject open-for-read and FlushFileBuffers with these codes,
66
+ * so they are the documented platform fallback there. On Linux/macOS
67
+ * the same codes from a freshly opened directory handle mean something
68
+ * is genuinely wrong and they propagate.
69
+ * - Everything else propagates everywhere: `EIO`, `ENOSPC`, `EROFS`,
70
+ * `EACCES`, `ENOENT`, and notably `EBADF` (a bad handle is a real bug,
71
+ * never evidence of an unsupported platform).
72
+ *
73
+ * `platform` defaults to the running platform; tests pass explicit values
74
+ * to cover the matrix deterministically on any OS.
75
+ */
76
+ export const isUnsupportedDirSync = (e, platform = process.platform) => {
77
+ const code = errnoOf(e);
78
+ if (code === 'ENOSYS' || code === 'ENOTSUP')
79
+ return true;
80
+ if (platform === 'win32' && (code === 'EINVAL' || code === 'EPERM' || code === 'EISDIR'))
81
+ return true;
82
+ return false;
83
+ };
84
+ // Thrown by `durableWrite` when the temp file was published (rename
85
+ // attempted) but the post-rename barrier did not certify. At that point
86
+ // the target may or may not hold the new bytes, so prior durable
87
+ // knowledge for the key is invalid. Carries the original failure.
88
+ class BarrierUncertainty extends Error {
89
+ constructor(detail) {
90
+ super('use-bridge-store: post-rename barrier uncertified; prior durable knowledge invalidated');
91
+ this.name = 'BarrierUncertainty';
92
+ this.detail = detail;
93
+ }
94
+ }
95
+ const defaultSyncDir = async (dir) => {
96
+ let handle;
97
+ try {
98
+ handle = await open(dir, 'r');
99
+ }
100
+ catch (e) {
101
+ // A store folder that cannot even be opened is not a platform
102
+ // quirk, except where the platform cannot open directory handles
103
+ // at all (classified above).
104
+ if (!isUnsupportedDirSync(e))
105
+ throw e;
106
+ return;
107
+ }
108
+ try {
109
+ await handle.sync();
110
+ }
111
+ catch (e) {
112
+ if (!isUnsupportedDirSync(e))
113
+ throw e;
114
+ }
115
+ finally {
116
+ await handle.close().catch(() => { });
117
+ }
118
+ };
119
+ // Exclusive temp creation with a restrictive mode for sensitive auth
120
+ // bytes. `0o600` regardless of umask: replacement renames this inode over
121
+ // the target, so an existing `0600` file can never be widened (a previously
122
+ // wider file narrows to `0600`, the safe direction for session secrets;
123
+ // POSIX only — Windows ignores mode bits).
124
+ const realOpenTmp = async (tmpPath) => open(tmpPath, 'wx', 0o600);
125
+ const defaultWriteTmp = async (tmpPath, value, openTmp) => {
126
+ // Exclusive create: never truncate a temp file this operation did
127
+ // not create. Temp names are unique per attempt (see durableWrite),
128
+ // so EEXIST is not expected — but if it ever happens the caller
129
+ // retries with a fresh name instead of touching foreign bytes.
130
+ const handle = await openTmp(tmpPath);
131
+ try {
132
+ await handle.writeFile(value);
133
+ await handle.sync();
134
+ await handle.close();
135
+ }
136
+ catch (e) {
137
+ // Owned (we just created it exclusively): close best-effort and
138
+ // remove our own temp, never anyone else's. Cleanup failures
139
+ // cannot mask the original error — it always propagates.
140
+ await handle.close().catch(() => { });
141
+ await unlink(tmpPath).catch(() => { });
142
+ throw e;
143
+ }
144
+ };
145
+ const defaultPublishTmp = async (tmpPath, finalPath) => {
146
+ await rename(tmpPath, finalPath);
147
+ };
148
+ let tmpSeq = 0;
47
149
  /**
48
150
  * Creates a file-based store for the WASM bridge.
49
151
  *
50
152
  * Each (store, key) pair maps to a file: `<folder>/<store>-<key>.bin`
51
153
  *
52
- * Uses a write-through in-memory cache to avoid redundant disk reads.
53
- * Writes go to both cache and disk. Reads hit cache first, disk on miss.
154
+ * Durability model:
155
+ * - Caller buffers are copied synchronously at admission (`set`/`setMany`
156
+ * copy before queueing), so mutating a buffer after the call — even
157
+ * before awaiting it — can never change what gets persisted.
158
+ * - Critical stores write through `durableWrite` before `set`/`setMany`
159
+ * resolve. The in-memory map only records bytes AFTER the full barrier
160
+ * (write + fsync + atomic rename + directory sync) succeeds, so an
161
+ * identical retry following a failure is never skipped and always
162
+ * re-attempts the write.
163
+ * - If the post-rename barrier fails, the key is marked uncertain: prior
164
+ * durable knowledge is discarded, reads serve best-available bytes
165
+ * without re-certifying them, and no identical set is skipped until a
166
+ * later operation completes the full barrier for that key.
167
+ * - Non-critical stores are debounced (50ms coalescing) and readable
168
+ * immediately (read-your-write), but such reads are NOT durable until
169
+ * `flush()` succeeds. A failed flush keeps the pending entry and throws,
170
+ * so the next `flush()` retries the same bytes.
171
+ * - `flush()` first waits for every operation admitted before it
172
+ * (barrier), then drains the pending writes those operations produced.
173
+ * Failures observed by the barrier propagate — a failed admitted write
174
+ * fails the flush — but only operations outstanding during that flush
175
+ * are reported, so history never poisons later flushes. A drain pass
176
+ * with any failure stops at that pass and leaves the failed entries
177
+ * for the next explicit flush. `flush()` never reports quiescence
178
+ * while prior admitted work is still running.
179
+ * - All operations on one key (set, delete, flush, concurrent batches) run
180
+ * through a per-key chain, so a stale failure can never erase newer
181
+ * state and an in-flight write can never resurrect a deleted key.
182
+ * - A failed delete restores the preceding pending/durable state, so an
183
+ * acknowledged value stays readable and flushable; only a successful
184
+ * delete (unlink + directory barrier) clears it. A delete retried while
185
+ * the key is uncertain re-runs the directory barrier instead of
186
+ * swallowing the uncertainty as idempotent absence — except that a
187
+ * directory removed externally surfaces ENOENT rather than success.
188
+ * - Every byte array handed back to callers is a copy.
54
189
  *
55
190
  * @param folder Directory to store bridge state files
191
+ * @param options Optional per-store file-I/O steps. Test/fault-injection
192
+ * seam only: the default implementation is the sole provider of the
193
+ * durability contract above.
56
194
  */
57
- export async function useBridgeStore(folder) {
195
+ export async function useBridgeStore(folder, options) {
58
196
  await mkdir(folder, { recursive: true });
59
- // Write-through cache with LRU eviction to bound memory
197
+ const openTmp = options?.io?.openTmp ?? realOpenTmp;
198
+ const io = {
199
+ writeTmp: options?.io?.writeTmp ?? ((tmpPath, value) => defaultWriteTmp(tmpPath, value, openTmp)),
200
+ publishTmp: options?.io?.publishTmp ?? defaultPublishTmp,
201
+ syncDir: options?.io?.syncDir ?? defaultSyncDir
202
+ };
203
+ /**
204
+ * Durable file replacement: write + fsync an exclusively-created temp
205
+ * file in the same directory, then atomically rename it over the
206
+ * target and sync the directory. The temp uses a short fixed-shape
207
+ * basename (independent of key length, so long-but-valid final names
208
+ * still fit NAME_MAX) created with mode `0600` (POSIX; Windows ignores
209
+ * mode bits), so replacing a hardened auth file never widens its
210
+ * permissions. The previous file (or its absence) is untouched until the rename succeeds, so a crash
211
+ * or a failed write can never leave a torn target behind — readers
212
+ * always see the old intact file or the new intact file. Cleanup only
213
+ * ever removes a temp this operation created; a temp that already
214
+ * existed is left alone.
215
+ *
216
+ * Guarantees are scoped honestly: the rename is atomic against a dead
217
+ * process, and the file + directory syncs raise the bar toward
218
+ * power-loss durability on platforms that honor them. No file-level
219
+ * test can prove power-loss survival — that would need a physical
220
+ * harness — so tests assert the checkable half (failure atomicity,
221
+ * retryability) and the code does not claim more.
222
+ *
223
+ * Every error propagates, including ENOENT: resolving a write whose
224
+ * bytes never reached disk would let the caller believe state is
225
+ * durable. A failure at or after the rename throws BarrierUncertainty
226
+ * so the caller invalidates prior durable knowledge for the key.
227
+ */
228
+ const durableWrite = async (finalPath, value) => {
229
+ const MAX_TMP_ATTEMPTS = 5;
230
+ for (let attempt = 0; attempt < MAX_TMP_ATTEMPTS; attempt++) {
231
+ // Short fixed-shape basename in the same directory (rename stays
232
+ // atomic): independent of the target key length so keys whose
233
+ // valid final names approach NAME_MAX still fit. Uniqueness from
234
+ // pid + sequence + randomness; the final stored name is untouched.
235
+ const tmpPath = join(dirname(finalPath), `.bstore-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}-${randomBytes(4).toString('hex')}.tmp`);
236
+ try {
237
+ await io.writeTmp(tmpPath, value);
238
+ }
239
+ catch (e) {
240
+ // EEXIST means a foreign temp owns this name (we create
241
+ // exclusively and never overwrite): retry with a fresh name
242
+ // instead of deleting or reusing it. Any earlier failure
243
+ // left the target untouched; `writeTmp` already cleaned up
244
+ // its own temp.
245
+ if (errnoOf(e) === 'EEXIST')
246
+ continue;
247
+ throw e;
248
+ }
249
+ try {
250
+ await io.publishTmp(tmpPath, finalPath);
251
+ await io.syncDir(dirname(finalPath));
252
+ }
253
+ catch (e) {
254
+ // Our own temp failed to publish or certify: remove only our
255
+ // temp. If the rename itself succeeded, the temp is already
256
+ // gone and this unlink is a tolerated no-op — but the
257
+ // barrier is uncertified either way.
258
+ await unlink(tmpPath).catch(() => { });
259
+ throw new BarrierUncertainty(e);
260
+ }
261
+ return;
262
+ }
263
+ throw new Error(`use-bridge-store durableWrite could not claim a unique temp name for ${finalPath}`);
264
+ };
265
+ // Last bytes known to have passed the FULL barrier per key (written,
266
+ // flushed, or read from disk outside uncertainty). Private copies only
267
+ // — never a caller-owned reference. Bounded by LRU eviction; eviction
268
+ // only drops knowledge, disk stays canonical.
60
269
  const MAX_CACHE_ENTRIES = 5000;
61
- const cache = new Map();
62
- const touchCache = (key, value) => {
270
+ const durable = new Map();
271
+ const rememberDurable = (key, value) => {
63
272
  // LRU: delete + re-insert moves to end of insertion order
64
- cache.delete(key);
65
- cache.set(key, value);
66
- // Evict oldest entries if over limit
67
- if (cache.size > MAX_CACHE_ENTRIES) {
68
- const first = cache.keys().next().value;
69
- cache.delete(first);
273
+ durable.delete(key);
274
+ durable.set(key, value);
275
+ if (durable.size > MAX_CACHE_ENTRIES) {
276
+ const first = durable.keys().next().value;
277
+ durable.delete(first);
70
278
  }
71
279
  };
280
+ // Keys whose last mutation never completed the full barrier (post-rename
281
+ // or post-unlink failure). Durable knowledge is discarded; reads serve
282
+ // best-available bytes without re-certifying them; identical sets are
283
+ // never skipped. Cleared only by a later operation that completes the
284
+ // full barrier for the key (write, flush, or certified delete).
285
+ const uncertain = new Set();
72
286
  const filePath = (store, key) => join(folder, `${store}-${encodeURIComponent(key)}.bin`);
73
- // Durable write that propagates real failures but tolerates the folder
74
- // having been removed (shutdown race). Used by both `set` and `setMany`.
75
- const writeCritical = async (store, key, value) => {
287
+ // Per-key serialization chain. Every key operation runs inside
288
+ // `withKeyLock` for that key; different keys never block each other.
289
+ // The tail swallows rejections so one failed op cannot wedge later ops
290
+ // for the same key. Every admitted op is also tracked in `admitted`
291
+ // until it settles so `flush()` can wait for work admitted before it.
292
+ const chains = new Map();
293
+ const admitted = new Set();
294
+ const withKeyLock = (cacheKey, fn) => {
295
+ const prev = chains.get(cacheKey) ?? Promise.resolve();
296
+ const next = prev.then(fn, fn);
297
+ const tail = next.then(() => undefined, () => undefined);
298
+ chains.set(cacheKey, tail);
299
+ tail.then(() => {
300
+ if (chains.get(cacheKey) === tail)
301
+ chains.delete(cacheKey);
302
+ });
303
+ admitted.add(next);
304
+ next.then(() => {
305
+ admitted.delete(next);
306
+ }, () => {
307
+ admitted.delete(next);
308
+ });
309
+ return next;
310
+ };
311
+ // Debounced non-critical writes not yet flushed to disk.
312
+ const pendingWrites = new Map();
313
+ const WRITE_DELAY_MS = 50;
314
+ const armTimer = (cacheKey, entry) => {
315
+ if (entry.timer)
316
+ clearTimeout(entry.timer);
317
+ entry.timer = setTimeout(() => {
318
+ void flushOne(cacheKey).catch(() => {
319
+ // No caller to report to; the entry stays pending and the
320
+ // next explicit flush() retries it and surfaces the error.
321
+ });
322
+ }, WRITE_DELAY_MS);
323
+ entry.timer.unref(); // Don't keep the process alive for debounced writes
324
+ };
325
+ // Full-barrier write shared by critical `set` and the flush drain.
326
+ // Certifies the key on success; on post-rename failure discards durable
327
+ // knowledge, marks uncertainty, and rethrows the original error.
328
+ const certifyWrite = async (cacheKey, path, value) => {
76
329
  try {
77
- await writeFile(filePath(store, key), value);
330
+ await durableWrite(path, value);
78
331
  }
79
332
  catch (e) {
80
- if (!isEnoent(e))
81
- throw e;
82
- // folder removed during shutdown — nothing to persist into
333
+ if (e instanceof BarrierUncertainty) {
334
+ durable.delete(cacheKey);
335
+ uncertain.add(cacheKey);
336
+ throw e.detail;
337
+ }
338
+ throw e;
83
339
  }
340
+ uncertain.delete(cacheKey);
341
+ rememberDurable(cacheKey, copyBytes(value));
84
342
  };
85
- // Batch write queue: coalesces rapid writes to the same key
86
- const pendingWrites = new Map();
87
- const WRITE_DELAY_MS = 50;
88
- const flushWrite = async (cacheKey) => {
343
+ // Flush one pending key. Runs under the key lock (not tracked in
344
+ // `admitted`: the drain loop drives it, so tracking it would make
345
+ // `flush()` wait on itself). On failure the entry is RETAINED (timer
346
+ // cleared) so an explicit `flush()` retries the same bytes, and the
347
+ // error propagates to that caller.
348
+ const flushOne = (cacheKey) => withKeyLockInternal(cacheKey, async () => {
89
349
  const pending = pendingWrites.get(cacheKey);
90
350
  if (!pending)
91
351
  return;
92
- clearTimeout(pending.timer);
93
- pendingWrites.delete(cacheKey);
94
- try {
95
- await writeFile(pending.path, pending.value);
352
+ if (pending.timer) {
353
+ clearTimeout(pending.timer);
354
+ pending.timer = undefined;
96
355
  }
97
- catch {
98
- // Ignore folder may have been deleted during cleanup
356
+ await certifyWrite(cacheKey, pending.path, pending.value);
357
+ if (pendingWrites.get(cacheKey) === pending)
358
+ pendingWrites.delete(cacheKey);
359
+ });
360
+ // Same as `withKeyLock` but invisible to the flush admission barrier.
361
+ // Only the flush drain loop may use this; key operations use
362
+ // `withKeyLock` so `flush()` observes them.
363
+ const withKeyLockInternal = (cacheKey, fn) => {
364
+ const prev = chains.get(cacheKey) ?? Promise.resolve();
365
+ const next = prev.then(fn, fn);
366
+ const tail = next.then(() => undefined, () => undefined);
367
+ chains.set(cacheKey, tail);
368
+ tail.then(() => {
369
+ if (chains.get(cacheKey) === tail)
370
+ chains.delete(cacheKey);
371
+ });
372
+ return next;
373
+ };
374
+ const FLUSH_MAX_PASSES = 32;
375
+ const flushAll = async () => {
376
+ // Each pass first waits for every operation admitted so far
377
+ // (barrier), then drains the pending writes those operations
378
+ // produced. A `set()` queued just before `flush()` is therefore
379
+ // observed even if it had not reached `pendingWrites` yet, and an
380
+ // already-running critical write completes before quiescence is
381
+ // declared. `flushOne` internals are not admitted, so the barrier
382
+ // never waits on the drain loop itself (no self-deadlock).
383
+ // Failures observed by the barrier propagate: a failed admitted
384
+ // write fails the flush. Only operations outstanding during this
385
+ // flush are reported — settled history never poisons later flushes.
386
+ // After a drain pass with any failure, stop and leave the failed
387
+ // entries for a subsequent explicit flush: retrying them inside the
388
+ // same call would resolve-or-reject on a stale error while hiding
389
+ // whether the retry itself persisted. A fully successful pass loops,
390
+ // since new work may have landed during the drain. The pass cap only
391
+ // guards a caller emitting writes in a tight loop —
392
+ // `Socket/index.ts.end()` waits on this and must return.
393
+ const errors = [];
394
+ for (let i = 0; i < FLUSH_MAX_PASSES; i++) {
395
+ const outstanding = [...admitted];
396
+ if (outstanding.length > 0) {
397
+ const settled = await Promise.allSettled(outstanding);
398
+ for (const result of settled) {
399
+ if (result.status === 'rejected')
400
+ errors.push(result.reason);
401
+ }
402
+ }
403
+ if (pendingWrites.size === 0) {
404
+ if (admitted.size === 0)
405
+ break;
406
+ continue;
407
+ }
408
+ const keys = [...pendingWrites.keys()];
409
+ let failed = false;
410
+ await Promise.all(keys.map(key => flushOne(key).then(() => { }, e => {
411
+ failed = true;
412
+ errors.push(e);
413
+ })));
414
+ if (failed)
415
+ break;
416
+ }
417
+ if (errors.length > 0)
418
+ throw errors[0];
419
+ if (pendingWrites.size > 0 || admitted.size > 0) {
420
+ throw new Error(`use-bridge-store flushAll did not quiesce after ${FLUSH_MAX_PASSES} passes (${pendingWrites.size} pending writes, ${admitted.size} in-flight operations remain)`);
99
421
  }
100
422
  };
101
- // Delete many keys concurrently (shared by `deleteMany` and `deletePrefix`).
102
- // Defined as a closure rather than a method so callers don't depend on
103
- // `this` the bridge invokes every store callback with `this = null`.
104
- const doDeleteMany = async (store, keys) => {
105
- if (keys.length === 0)
106
- return;
107
- await Promise.all(keys.map(async (key) => {
108
- const cacheKey = `${store}\0${key}`;
109
- cache.delete(cacheKey);
110
- const existing = pendingWrites.get(cacheKey);
111
- if (existing) {
112
- clearTimeout(existing.timer);
423
+ // Write path shared by `set` and `setMany`. `incoming` is already a
424
+ // private admission-time copy. Must run under the key lock callers
425
+ // wrap it via `withKeyLock`.
426
+ const doSetLocked = async (store, key, cacheKey, incoming) => {
427
+ // Skip only when the identical bytes are already barrier-certified
428
+ // or already queued. Uncertainty discards certification, so a
429
+ // retry after an uncertified barrier always re-attempts instead of
430
+ // resolving without persisting.
431
+ const pending = pendingWrites.get(cacheKey);
432
+ if (pending) {
433
+ if (bytesEqual(pending.value, incoming))
434
+ return;
435
+ }
436
+ else {
437
+ const known = durable.get(cacheKey);
438
+ if (known && bytesEqual(known, incoming))
439
+ return;
440
+ }
441
+ if (CRITICAL_STORES.has(store)) {
442
+ if (pending) {
443
+ if (pending.timer)
444
+ clearTimeout(pending.timer);
113
445
  pendingWrites.delete(cacheKey);
114
446
  }
447
+ // Propagate every failure (ENOSPC/EACCES/EIO/ENOTDIR/ENOENT) —
448
+ // losing a critical Signal write silently corrupts next decrypt.
449
+ await certifyWrite(cacheKey, filePath(store, key), incoming);
450
+ return;
451
+ }
452
+ // Non-critical writes: coalesce rapid writes to the same key
453
+ if (pending?.timer)
454
+ clearTimeout(pending.timer);
455
+ const entry = {
456
+ store,
457
+ key,
458
+ path: filePath(store, key),
459
+ value: incoming,
460
+ timer: undefined
461
+ };
462
+ armTimer(cacheKey, entry);
463
+ pendingWrites.set(cacheKey, entry);
464
+ };
465
+ const doSet = (store, key, value) => {
466
+ // Admission-time copy, synchronous with the call: a caller mutating
467
+ // its buffer immediately after (even before awaiting) cannot change
468
+ // what this operation persists. `Uint8Array.from` copies Buffer
469
+ // inputs too — `Buffer.slice` would only create a shared view.
470
+ const incoming = copyBytes(value);
471
+ const cacheKey = `${store}\0${key}`;
472
+ return withKeyLock(cacheKey, () => doSetLocked(store, key, cacheKey, incoming));
473
+ };
474
+ // Read path shared by `get` and `getMany`. Runs under the key lock so
475
+ // a concurrent delete cannot leave stale bytes resurrected in `durable`.
476
+ // Returns a copy — callers can never mutate the cache or in-flight data.
477
+ // A pending (debounced, not yet durable) value satisfies read-your-write
478
+ // immediately; it is exposed as pending, and `flush()` is the call that
479
+ // makes it durable. Under uncertainty, disk bytes are served
480
+ // best-available WITHOUT re-certifying them: caching them as durable
481
+ // would let a later identical set skip its still-required barrier.
482
+ const doGetLocked = async (store, key, cacheKey) => {
483
+ const pending = pendingWrites.get(cacheKey);
484
+ if (pending)
485
+ return copyBytes(pending.value);
486
+ const keyUncertain = uncertain.has(cacheKey);
487
+ if (!keyUncertain) {
488
+ const known = durable.get(cacheKey);
489
+ if (known)
490
+ return copyBytes(known);
491
+ }
492
+ let data;
493
+ try {
494
+ data = await readFile(filePath(store, key));
495
+ }
496
+ catch (e) {
497
+ // Absent key is a legitimate null. Any other read failure
498
+ // (EACCES/EIO/ENOTDIR) must NOT masquerade as "no value", or the
499
+ // core would treat persisted state as gone.
500
+ if (isEnoent(e))
501
+ return null;
502
+ throw e;
503
+ }
504
+ const arr = copyBytes(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
505
+ if (!keyUncertain)
506
+ rememberDurable(cacheKey, copyBytes(arr));
507
+ return arr;
508
+ };
509
+ const doGet = (store, key) => {
510
+ const cacheKey = `${store}\0${key}`;
511
+ return withKeyLock(cacheKey, () => doGetLocked(store, key, cacheKey));
512
+ };
513
+ // Delete path shared by `delete`, `deleteMany` and `deletePrefix`.
514
+ // Runs under the key lock. The preceding pending/durable state is only
515
+ // cleared once the unlink succeeds; if the unlink fails with a real
516
+ // error, that state is restored (timer re-armed) so an acknowledged
517
+ // value stays readable and flushable, and the error propagates. A
518
+ // successful unlink completes the directory barrier; if that barrier
519
+ // fails, the key is marked uncertain. A delete retried while uncertain
520
+ // re-runs the directory barrier instead of mistaking ENOENT for
521
+ // certified absence — but a directory removed externally still surfaces
522
+ // ENOENT rather than success, by explicit policy. Certain absence stays
523
+ // idempotent.
524
+ const doDeleteOne = (store, key) => {
525
+ const cacheKey = `${store}\0${key}`;
526
+ return withKeyLock(cacheKey, async () => {
527
+ const prevPending = pendingWrites.get(cacheKey);
528
+ const prevDurable = durable.get(cacheKey);
529
+ const wasUncertain = uncertain.has(cacheKey);
530
+ if (prevPending?.timer)
531
+ clearTimeout(prevPending.timer);
532
+ pendingWrites.delete(cacheKey);
533
+ durable.delete(cacheKey);
534
+ // Reinstate the state captured above after any delete failure
535
+ // that leaves the key's fate undecided, so an acknowledged
536
+ // value stays readable and flushable. Not used when absence is
537
+ // certified (the delete won) or when the unlink itself succeeded
538
+ // (the file is genuinely gone; only the barrier is uncertain).
539
+ const restoreDeleteState = () => {
540
+ if (prevDurable)
541
+ rememberDurable(cacheKey, prevDurable);
542
+ if (prevPending) {
543
+ armTimer(cacheKey, prevPending);
544
+ pendingWrites.set(cacheKey, prevPending);
545
+ }
546
+ };
115
547
  try {
116
548
  await unlink(filePath(store, key));
117
549
  }
118
- catch {
119
- // ignore if file doesn't exist
550
+ catch (e) {
551
+ if (isEnoent(e) && !wasUncertain)
552
+ return;
553
+ if (isEnoent(e)) {
554
+ // Absent on disk, but a prior barrier was never
555
+ // certified: certify the absence now instead of
556
+ // swallowing the uncertainty.
557
+ try {
558
+ await io.syncDir(folder);
559
+ }
560
+ catch (syncError) {
561
+ restoreDeleteState();
562
+ throw syncError;
563
+ }
564
+ uncertain.delete(cacheKey);
565
+ return;
566
+ }
567
+ restoreDeleteState();
568
+ throw e;
569
+ }
570
+ try {
571
+ await io.syncDir(folder);
572
+ }
573
+ catch (e) {
574
+ uncertain.add(cacheKey);
575
+ throw e;
120
576
  }
121
- }));
577
+ uncertain.delete(cacheKey);
578
+ });
122
579
  };
123
- const FLUSH_MAX_PASSES = 32;
124
- const flushAll = async () => {
125
- // Drain in a loop because new sets can land while we're awaiting the
126
- // previous batch's writeFile. Bounded so a caller emitting writes in
127
- // a tight loop can't lock us forever — `Socket/index.ts.end()` waits
128
- // on this and must always return. If the cap is hit with writes
129
- // still pending, surface that to the caller — silently dropping
130
- // them would lose Signal session steps and corrupt next decrypt.
131
- for (let i = 0; i < FLUSH_MAX_PASSES && pendingWrites.size > 0; i++) {
132
- const keys = [...pendingWrites.keys()];
133
- await Promise.all(keys.map(flushWrite));
134
- }
135
- if (pendingWrites.size > 0) {
136
- throw new Error(`use-bridge-store flushAll did not quiesce after ${FLUSH_MAX_PASSES} passes (${pendingWrites.size} pending writes remain)`);
137
- }
580
+ // Delete many keys concurrently (shared by `deleteMany` and
581
+ // `deletePrefix`). Defined as a closure rather than a method so callers
582
+ // don't depend on `this` the bridge invokes every store callback with
583
+ // `this = null`. Best-effort across keys: every key is attempted even
584
+ // when a sibling fails, and the first error propagates.
585
+ const doDeleteMany = async (store, keys) => {
586
+ if (keys.length === 0)
587
+ return;
588
+ await Promise.all(keys.map(key => doDeleteOne(store, key)));
138
589
  };
139
590
  // Enumerate live keys in a namespace (shared by `listKeys` and
140
591
  // `deletePrefix`). Closure, not a method, so it never depends on `this`.
141
592
  // Files are `<store>-<encodeURIComponent(key)>.bin`; store names never
142
593
  // contain a hyphen, so split on the FIRST hyphen and decode the remainder.
143
- // readdir (durable view) is unioned with not-yet-flushed debounced writes
144
- // so a key written <50ms ago isn't missed; a flush is awaited first so a
145
- // torn debounce window can't drop a key. Pending deletes both cancel their
146
- // `pendingWrites` entry AND unlink immediately, so they never appear here.
594
+ // A flush is awaited first so admitted and debounced writes are durable
595
+ // before the readdir; its failure propagates rather than enumerating a
596
+ // stale view. Pending deletes both cancel their `pendingWrites` entry
597
+ // AND unlink immediately, so they never appear here.
147
598
  const doListKeys = async (store, prefix) => {
148
599
  await flushAll();
149
600
  const filePrefix = `${store}-`;
@@ -190,150 +641,39 @@ export async function useBridgeStore(folder) {
190
641
  return [...found];
191
642
  };
192
643
  return {
193
- async get(store, key) {
194
- const cacheKey = `${store}\0${key}`;
195
- // Check cache first
196
- const cached = cache.get(cacheKey);
197
- if (cached)
198
- return cached;
199
- const pending = pendingWrites.get(cacheKey);
200
- if (pending)
201
- return pending.value;
202
- try {
203
- const data = await readFile(filePath(store, key));
204
- const arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
205
- touchCache(cacheKey, arr);
206
- return arr;
207
- }
208
- catch {
209
- return null;
210
- }
211
- },
212
- async set(store, key, value) {
213
- const cacheKey = `${store}\0${key}`;
214
- // Skip write if value is identical to cached version
215
- const prev = cache.get(cacheKey);
216
- if (prev && Buffer.from(prev).equals(Buffer.from(value))) {
217
- return;
218
- }
219
- touchCache(cacheKey, value);
220
- if (CRITICAL_STORES.has(store)) {
221
- const existing = pendingWrites.get(cacheKey);
222
- if (existing) {
223
- clearTimeout(existing.timer);
224
- pendingWrites.delete(cacheKey);
225
- }
226
- // Propagate real failures (ENOSPC/EACCES/…) — losing a critical
227
- // Signal write silently corrupts the next decrypt.
228
- await writeCritical(store, key, value);
229
- return;
230
- }
231
- // Non-critical writes: coalesce rapid writes to the same key
232
- const existing = pendingWrites.get(cacheKey);
233
- if (existing) {
234
- clearTimeout(existing.timer);
235
- }
236
- const path = filePath(store, key);
237
- const timer = setTimeout(() => void flushWrite(cacheKey), WRITE_DELAY_MS);
238
- timer.unref(); // Don't keep the process alive for debounced writes
239
- pendingWrites.set(cacheKey, { path, value, timer });
240
- },
241
- async delete(store, key) {
242
- const cacheKey = `${store}\0${key}`;
243
- cache.delete(cacheKey);
244
- // Cancel pending write
245
- const existing = pendingWrites.get(cacheKey);
246
- if (existing) {
247
- clearTimeout(existing.timer);
248
- pendingWrites.delete(cacheKey);
249
- }
250
- try {
251
- await unlink(filePath(store, key));
252
- }
253
- catch {
254
- // ignore if file doesn't exist
255
- }
256
- },
644
+ get: doGet,
645
+ set: doSet,
646
+ delete: doDeleteOne,
257
647
  // Batched variant of `set`. The bridge calls this (when present) to
258
648
  // persist a burst of entries in a single FFI crossing instead of N
259
- // round-trips (e.g. ~20k messageSecrets from a history sync). Per-entry
260
- // semantics are identical to `set` — same skip-if-equal, same cache
261
- // touch, same critical-vs-debounced write policy.
649
+ // round-trips (e.g. ~20k messageSecrets from a history sync).
650
+ // Per-entry semantics are identical to `set` — admission copies are
651
+ // taken synchronously for every entry, in order. Best-effort across
652
+ // keys per the bridge contract: every entry is attempted even when
653
+ // a sibling fails (writes are idempotent by key, so the core can
654
+ // retry the batch), and the first error propagates. No multi-key
655
+ // transaction is attempted — the filesystem does not provide one.
262
656
  async setMany(store, entries) {
263
657
  // Empty batch is a valid no-op.
264
658
  if (entries.length === 0)
265
659
  return;
266
- const critical = CRITICAL_STORES.has(store);
267
- // Collect critical writes so we can run them concurrently with
268
- // Promise.all instead of awaiting each writeFile serially in a loop.
269
- const criticalWrites = [];
270
- for (const [key, value] of entries) {
271
- const cacheKey = `${store}\0${key}`;
272
- // Skip write if value is identical to cached version.
273
- // Buffer.compare accepts Uint8Array directly (no copy), unlike
274
- // Buffer.from(prev).equals(Buffer.from(value)) which copied both.
275
- const prev = cache.get(cacheKey);
276
- if (prev && prev.length === value.length && Buffer.compare(prev, value) === 0) {
277
- continue;
278
- }
279
- touchCache(cacheKey, value);
280
- if (critical) {
281
- // Cancel any pending debounced write for this key first,
282
- // exactly like `set` does, then write immediately.
283
- const existing = pendingWrites.get(cacheKey);
284
- if (existing) {
285
- clearTimeout(existing.timer);
286
- pendingWrites.delete(cacheKey);
287
- }
288
- // Propagate real failures: if a critical write in the batch
289
- // fails, setMany rejects, and the Rust core skips the
290
- // follow-up self-index rewrite (so the index can't claim a
291
- // value exists that was never persisted).
292
- criticalWrites.push(writeCritical(store, key, value));
293
- continue;
294
- }
295
- // Non-critical writes: coalesce rapid writes to the same key.
296
- // The debounce already coalesces a burst, so scheduling each
297
- // entry is fine — no immediate flush needed.
298
- const existing = pendingWrites.get(cacheKey);
299
- if (existing) {
300
- clearTimeout(existing.timer);
301
- }
302
- const path = filePath(store, key);
303
- const timer = setTimeout(() => void flushWrite(cacheKey), WRITE_DELAY_MS);
304
- timer.unref(); // Don't keep the process alive for debounced writes
305
- pendingWrites.set(cacheKey, { path, value, timer });
306
- }
307
- // Run all critical writes concurrently.
308
- if (criticalWrites.length > 0) {
309
- await Promise.all(criticalWrites);
310
- }
660
+ // Schedule every entry synchronously so same-key duplicates chain
661
+ // in batch order; per-key locks serialize against concurrent
662
+ // sets, deletes and flushes.
663
+ await Promise.all(entries.map(([key, value]) => doSet(store, key, value)));
311
664
  },
312
665
  // Batched variant of `delete`. Per-key semantics are identical to
313
666
  // `delete`; unlinks run concurrently via Promise.all.
314
667
  deleteMany: doDeleteMany,
315
668
  // Read many keys at once. Cache-aside per key (like `get`), so a hit
316
- // never touches disk; misses read the file. Missing keys are omitted.
669
+ // never touches disk; misses read the file. Missing keys are omitted;
670
+ // real read failures propagate rather than silently dropping keys.
317
671
  async getMany(store, keys) {
318
672
  if (keys.length === 0)
319
673
  return [];
320
674
  const results = await Promise.all(keys.map(async (key) => {
321
- const cacheKey = `${store}\0${key}`;
322
- const cached = cache.get(cacheKey);
323
- if (cached)
324
- return [key, cached];
325
- const pending = pendingWrites.get(cacheKey);
326
- if (pending)
327
- return [key, pending.value];
328
- try {
329
- const data = await readFile(filePath(store, key));
330
- const arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
331
- touchCache(cacheKey, arr);
332
- return [key, arr];
333
- }
334
- catch {
335
- return null;
336
- }
675
+ const value = await doGet(store, key);
676
+ return value === null ? null : [key, value];
337
677
  }));
338
678
  return results.filter((r) => r !== null);
339
679
  },