sliftutils 1.4.48 → 1.4.50

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,136 +1,67 @@
1
1
  import { sort } from "socket-function/src/misc";
2
2
  import { getTimeUnique } from "socket-function/src/bits";
3
- import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, BulkHeaderInfo, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase, loadBulkHeader, TARGET_FILE_BYTES } from "./BulkDatabaseFormat";
4
3
  import { lazy } from "socket-function/src/caching";
5
- import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
- import { blue, red } from "socket-function/src/formatting/logColors";
7
- import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
8
- import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
9
- import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
10
- import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
11
4
  import { isNode } from "typesafecss";
12
5
  import type { FileStorage } from "../FileFolderAPI";
6
+ import {
7
+ BaseBulkDatabaseReader,
8
+ BulkHeaderInfo,
9
+ buildFileBuffer,
10
+ loadBulkHeader,
11
+ TARGET_FILE_BYTES,
12
+ } from "./BulkDatabaseFormat";
13
+ import { runPlannedMerge } from "./BulkDatabaseMerge";
14
+ import { blockCache, encodeCompressedBlocks } from "./blockCache";
15
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
16
+ import { blue } from "socket-function/src/formatting/logColors";
17
+ import { STREAM_EXTENSION, frameDeletes, frameRows, streamReaderFromEntries } from "./streamLog";
18
+ import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, RemoteWrite } from "./syncClient";
19
+ import { DELETED } from "./WriteOverlay";
20
+ import { releaseMergeLock, tryAcquireMergeLock } from "./mergeLock";
21
+ import {
22
+ BulkFileInfo,
23
+ LoadedIndex,
24
+ loadFileReader,
25
+ loadStreamEntries,
26
+ makeRawGetRange,
27
+ MissingFileError,
28
+ orderStreamEntries,
29
+ StreamFileInfo,
30
+ SubReaderCaches,
31
+ } from "./LoadedIndex";
32
+ import { BulkDatabaseReader, nullJoin } from "./BulkDatabaseReader";
13
33
 
14
- // ───────────────────────────────────────────────────────────────────────────────────────────────
15
- // KNOWN BUGS (accepted, documented):
16
- //
17
- // • Inconsistent directory listing under concurrent merges. A read lists the directory, then loads
18
- // each listed file. If a file is missing when we go to read it (a merge deleted it), we re-list and
19
- // retry — the deleting merge wrote the replacement first, so the data is never gone, just moved.
20
- // But a directory listing is not guaranteed atomic on every filesystem: a listing taken while
21
- // another writer is mid-swap can in principle return a set of files that never simultaneously
22
- // existed (e.g. the replacement but not a sibling it depends on). That yields a momentarily
23
- // INCONSISTENT view — some keys may read stale or missing. It does NOT lose data on disk: a reload
24
- // (refresh the page) re-lists and resolves correctly. We accept this rather than reintroduce a
25
- // manifest; it's rare and OS/filesystem-dependent.
26
- // ───────────────────────────────────────────────────────────────────────────────────────────────
27
-
28
- // BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
29
- // folder rather than sharing bulkDatabases/. Exported so a server-side compactor can find collections.
30
34
  export const BULK_ROOT_FOLDER = "bulkDatabases2";
31
35
  const FILE_EXTENSION = ".bulk";
32
- // A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
33
- // into a bulk file (streaming thousands of rows one frame at a time would be pointless).
34
36
  const ROLLOVER_ROWS = 5000;
35
37
  const ROLLOVER_BYTES = 5 * 1024 * 1024;
36
-
37
- // How often the global memory-pressure watchdog samples the heap (the ACTION it may take is throttled
38
- // separately, see bulkDatabase2Timing.memoryFlushThrottleMs).
39
38
  const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
40
-
41
- // An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
42
- // can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
43
- // no writer is plausibly still working on it, so we delete it. Until then we just warn.
44
39
  const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
45
-
46
- // A read lists the directory then loads each file; if a file vanished (a merge deleted it) we re-list
47
- // and retry, since the merge wrote the replacement first. Bounded so a pathological merge storm can't
48
- // loop forever — after this many tries we load whatever's currently there (the documented inconsistent
49
- // -view bug), which a later reload resolves.
50
- const MAX_READ_ATTEMPTS = 8;
51
-
52
- // A read that hits a file a concurrent merge deleted reloads the index and retries; if it keeps hitting
53
- // missing files this many times it gives up and throws (so a genuinely-unreadable file can't loop forever).
54
40
  const MAX_INDEX_RELOAD_ATTEMPTS = 3;
55
-
56
- // The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
57
- // file (half the target, so it has room to grow before it needs splitting). The key-stratified second
58
- // merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
59
- // (multi-file) keys exceeds DUP_THRESHOLD — i.e. only when deduping actually buys enough.
60
41
  const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
61
42
  const KEY_GROUP_BYTES = 800 * 1024 * 1024;
62
43
  const DUP_THRESHOLD = 0.4;
63
-
64
- // The browser File System Access API has no real append — every "append" rewrites the whole file — so
65
- // streaming one write at a time is quadratic. We coalesce stream writes and flush on a RAMPING delay:
66
- // the first write after a lull flushes immediately (a single checkbox-then-close is saved at once), and
67
- // as writes keep coming the delay doubles from this step up to writeFlushMaxDelayMs, batching a burst
68
- // into one rewrite. (Node has a real append, so there the default delay is 0 = flush every write.)
69
44
  const WRITE_FLUSH_FIRST_STEP_MS = 250;
70
45
 
71
- // Time thresholds, mutable so tests can shrink them from hours to milliseconds.
72
46
  export const bulkDatabase2Timing = {
73
- // A writer stops appending to its current stream file once it's this old (starts a fresh one). A
74
- // stream file older than this is therefore safe for a merge to delete: its writer has provably moved
75
- // on to a new file and will never append to it again.
76
47
  streamSealAgeMs: 10 * 60 * 60 * 1000,
77
- // Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
78
48
  mergeCheckIntervalMs: 30 * 60 * 1000,
79
- // A single testMerge can do several merges (one pass-1 + several pass-2 groups). We wait this long
80
- // after each one before the next, so a burst of writes doesn't rewrite every index on disk at once —
81
- // it spreads the work out to keep peak lag low (important in the browser). The merge lock is kept
82
- // alive across the wait. Set to 0 to disable spacing (tests).
83
49
  mergeSpacingMs: 5 * 60 * 1000,
84
- // The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
85
50
  firstMergeTriggerFiles: 20,
86
- // ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
87
51
  firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
88
- // Tier-0 stream data can't be read per-cell — a read pulls the whole stream — so once the stream
89
- // exceeds BOTH of these it's folded into bulk (columnar, range-readable) promptly, bypassing the merge
90
- // throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
91
52
  streamFoldTriggerRows: 100,
92
53
  streamFoldTriggerBytes: 64 * 1024 * 1024,
93
- // Rolling cap on OUR OWN current stream file: once we've appended this much to it, we seal it (stop
94
- // writing) and fold it into bulk immediately. Without this, many small appends grow a single stream
95
- // file without bound (the per-batch ROLLOVER only catches one huge batch). We can fold our own file at
96
- // once — the 10h seal-age rule is only for other writers' files; we know we're done with ours on seal.
97
54
  streamFileMaxBytes: 50 * 1024 * 1024,
98
- // HARD limit: once the stream tier passes this, fold it NOW regardless of age (even un-sealed, recent
99
- // streams) — a stream this large makes every read pull a huge file, i.e. the collection becomes
100
- // essentially unreadable. We still only delete a stream whose size didn't change while we read it, so
101
- // a writer mid-append never loses data (it just gets re-folded next pass).
102
55
  streamFoldHardLimitBytes: 768 * 1024 * 1024,
103
- // Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
104
- // this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
105
- // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
56
+ // 0 = flush every write (Node append is real and cheap); browser ramps to 15s to avoid rewriting
106
57
  // the whole stream file per write.
107
58
  writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
108
- // We proactively re-list files this often; if the set changed under us (another tab/process merged) we
109
- // reload the index, so reads pick up the change even without first hitting a missing-file error.
110
59
  fileSetPollIntervalMs: 30 * 60 * 1000,
111
- // Memory-pressure auto-flush (browser only; needs performance.memory). When the JS heap exceeds
112
- // memoryFlushHeapBytes, at most once per memoryFlushThrottleMs, drop the in-memory observable state of
113
- // every collection whose loaded data exceeds memoryFlushMinCollectionBytes (they reload lazily). Tuned
114
- // so it does nothing for a healthy app (heap fine) or small collections.
115
60
  memoryFlushHeapBytes: 1500 * 1024 * 1024,
116
61
  memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
117
62
  memoryFlushThrottleMs: 15 * 60 * 1000,
118
63
  };
119
64
 
120
- // Marks a key as deleted in the in-memory overlay.
121
- const DELETED = Symbol("deleted");
122
- // Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
123
- // remote write only overrides a key if it's newer than what we already have.
124
- type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
125
-
126
- // Composite cache/signal keys join two arbitrary strings with a NUL separator (which can't occur in
127
- // the inputs). NUL is built from a char code so an actual NUL byte never appears in this source file
128
- // (which would otherwise make tools treat it as binary).
129
- const NULL = String.fromCharCode(0);
130
- function nullJoin(a: string, b: string): string {
131
- return a + NULL + b;
132
- }
133
-
134
65
  function fmtBytes(n: number): string {
135
66
  if (n < 1024) return n + "B";
136
67
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
@@ -138,26 +69,17 @@ function fmtBytes(n: number): string {
138
69
  return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
139
70
  }
140
71
 
141
- // A tiny reactivity seam so this file has zero dependency on mobx (or any specific UI framework). The
142
- // reactive in-memory state (the overlay map + the load/reset lifecycle) is plain; whenever it's read
143
- // we "observe" a signal, and whenever it changes we "invalidate" that signal. A consumer that wants
144
- // reactivity (e.g. the mobx subclass) supplies a ReactiveDeps that maps each signal string onto its
145
- // own framework's dependency tracking; a consumer that doesn't can pass noopReactiveDeps.
72
+ // Reactivity seam (no mobx dependency in this file). The mobx subclass supplies a ReactiveDeps that
73
+ // maps signal strings to its own dependency tracking; non-reactive callers pass noopReactiveDeps.
146
74
  export interface ReactiveDeps {
147
- // Register `signal` as a dependency of whatever reactive context is currently reading.
148
75
  observe(signal: string): void;
149
- // Notify any context that observed `signal` that it changed.
150
76
  invalidate(signal: string): void;
151
- // Run a group of mutations + invalidations as one batch, so observers re-run at most once.
152
77
  batch(fn: () => void): void;
153
- // Whether `signal` currently has any observer watching it. Optional a backend that can't tell
154
- // returns undefined (treated as "assume watched"). Lets writes skip per-key notification work for rows
155
- // nothing is watching (see isWatchedSync).
78
+ // Optional lets writes skip per-key invalidation for rows nothing is watching. Undefined =
79
+ // "assume watched".
156
80
  isObserved?(signal: string): boolean;
157
81
  }
158
82
 
159
- // A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
160
- // re-renders. Use this when you don't need a UI to react to writes. Nothing is ever observed.
161
83
  export const noopReactiveDeps: ReactiveDeps = {
162
84
  observe() { },
163
85
  invalidate() { },
@@ -165,82 +87,44 @@ export const noopReactiveDeps: ReactiveDeps = {
165
87
  isObserved() { return false; },
166
88
  };
167
89
 
168
- // Provides the FileStorage for a given path (the caller decides where data physically lives, so this
169
- // file needn't know about getFileStorageNested2 / the browser-vs-node storage details).
170
90
  export type StorageFactory = (path: string) => Promise<FileStorage>;
171
91
 
172
- // Optional per-collection configuration.
173
92
  export type BulkDatabase2Config = {
174
- // The MAXIMUM throttle (ms) for reactive change notifications; the actual delay RAMPS UP to it, it is
175
- // never applied all at once. When set (> 0), the notifications writes/loads emit are batched globally
176
- // (across all keys) so a high-frequency write source doesn't re-run watchers on every single change: a
177
- // change after a lull notifies immediately, then under sustained changes the delay doubles up to this
178
- // ceiling, coalescing the burst into one notification. In-memory/async reads are always current — only
179
- // the OBSERVABLE notification (the mobx re-render trigger) is delayed and merged.
93
+ // See BulkDatabaseReader.cfg.maxTriggerThrottleMs.
180
94
  maxTriggerThrottleMs?: number;
181
95
  };
182
96
 
183
- // Trigger-throttle ramp: the first deferred notification waits this long, then the delay doubles on each
184
- // further change up to BulkDatabase2Config.maxTriggerThrottleMs. ~16ms ≈ one animation frame, so an
185
- // isolated burst still notifies within a frame.
186
- const TRIGGER_THROTTLE_FIRST_STEP_MS = 16;
187
-
188
- // The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
189
- // reader resets or a base column/field finishes loading. The overlay's per-key signal is the key
190
- // itself (a point read observes just its key), plus one overlay-wide signal that whole-column reads
191
- // observe so they recompute on any overlay change. The NUL prefix keeps the two special signals
192
- // from ever colliding with a real data key.
193
- const LOAD_SIGNAL = NULL + "load";
194
- const OVERLAY_SIGNAL = NULL + "overlay";
195
-
196
- // Over network storage we skip automatic (background) compaction by default — reading and rewriting whole
197
- // files over the network is expensive. An app that wants it anyway opts in once via
198
- // BulkDatabaseBase.enableNetworkCompaction(). Explicit compact()/tryMergeNow() calls are unaffected.
199
97
  let networkCompactionEnabled = false;
200
98
 
201
99
  let fileNameCounter = 0;
202
- // Random per-process id baked into file names so two processes (tabs) writing the same collection
203
- // never collide on a name when they pick the same timestamp/counter in the same millisecond.
100
+ // Per-process ID so two writers picking the same timestamp + counter never collide on a name.
204
101
  const writerId = Math.random().toString(36).slice(2, 10);
205
- function nextCounter(): number {
206
- return ++fileNameCounter;
207
- }
208
-
209
- type BulkFileInfo = { fileName: string; level: number; timestamp: number };
102
+ function nextCounter(): number { return ++fileNameCounter; }
210
103
 
211
104
  let lastFileTime = 0;
212
- // A strictly-increasing integer timestamp for newly written files, so the newest-first order is
213
- // unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
214
- // because it may return a fractional value, which wouldn't round-trip through the integer file name.)
105
+ // Strictly-increasing integer so newest-first ordering is unambiguous within a millisecond.
215
106
  function nextFileTime(): number {
216
107
  lastFileTime = Math.max(Date.now(), lastFileTime + 1);
217
108
  return lastFileTime;
218
109
  }
219
110
 
220
- // Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
221
- // of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
222
- // field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
223
111
  function newFileName(timestamp: number): string {
224
112
  return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
225
113
  }
226
114
 
227
- type StreamFileInfo = { fileName: string; timestamp: number };
228
-
229
115
  function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
230
116
  if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
231
117
  const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
232
- // stream_<timestamp>_<random>
233
118
  if (parts.length !== 3 || parts[0] !== "stream") return undefined;
234
119
  const timestamp = parseInt(parts[1], 10);
235
120
  if (!Number.isFinite(timestamp)) return undefined;
236
121
  return { fileName, timestamp };
237
122
  }
238
123
 
124
+ // Accept old 3-part (level_timestamp_counter) and new 4-part (level_timestamp_writerId_counter) shapes.
239
125
  function parseFileName(fileName: string): BulkFileInfo | undefined {
240
126
  if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
241
127
  const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
242
- // Accept both the old 3-part (level_timestamp_counter) and new 4-part
243
- // (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
244
128
  if (parts.length < 3) return undefined;
245
129
  const level = parseInt(parts[0], 10);
246
130
  const timestamp = parseInt(parts[1], 10);
@@ -248,14 +132,6 @@ function parseFileName(fileName: string): BulkFileInfo | undefined {
248
132
  return { fileName, level, timestamp };
249
133
  }
250
134
 
251
- // A file we listed is gone now (a concurrent merge deleted it after writing its replacement). Distinct
252
- // from a corrupt/torn file: missing => the data moved, so re-list and retry; corrupt => skip the file.
253
- class MissingFileError extends Error { }
254
- // Thrown out of a read build when a listed file went missing mid-load, so the build re-lists and retries.
255
- class FilesChangedError extends Error { }
256
-
257
- // All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
258
- // Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
259
135
  export class BulkDatabaseBase<T extends { key: string }> {
260
136
  constructor(
261
137
  public readonly name: string,
@@ -263,9 +139,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
263
139
  private storageFactory: StorageFactory,
264
140
  private config: BulkDatabase2Config = {},
265
141
  ) {
266
- // Best-effort: persist buffered stream writes when the tab is hidden/closing. visibilitychange →
267
- // hidden fires early enough that the (async) flush usually completes; pagehide is the backstop.
268
- // No-op outside a browser window (Node).
142
+ this.reader = new BulkDatabaseReader<T>({
143
+ name,
144
+ deps,
145
+ maxTriggerThrottleMs: config.maxTriggerThrottleMs,
146
+ });
147
+ this.reader.setEnsureIndex(() => this.ensureIndex());
148
+
269
149
  if (typeof window !== "undefined") {
270
150
  try {
271
151
  window.addEventListener("pagehide", () => void this.flushPending());
@@ -276,21 +156,36 @@ export class BulkDatabaseBase<T extends { key: string }> {
276
156
  }
277
157
  } catch { /* not in a DOM context */ }
278
158
  }
279
- // Proactively notice another writer's merge (files added/removed) and reload the index, so reads
280
- // stay correct even without first hitting a missing-file error.
281
159
  this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
282
160
  (this.fileSetPollTimer as { unref?: () => void }).unref?.();
283
- // Register for the global memory-pressure watchdog (browser only — needs performance.memory).
284
161
  BulkDatabaseBase.liveInstances.add(this);
285
162
  BulkDatabaseBase.startMemoryWatchdog();
286
163
  }
287
164
 
288
- // ---- memory-pressure watchdog (global, browser-only) ----
289
- // When the JS heap gets large, the biggest contributors are usually a few collections whose in-memory
290
- // observable state (loaded reader + base column/field caches) has grown. Rather than cap every
291
- // collection all the time, we watch the heap and, only when it's actually under pressure, drop the
292
- // observable state of the large collections (they reload lazily on demand). Throttled hard so it never
293
- // thrashes a healthy app.
165
+ private reader: BulkDatabaseReader<T>;
166
+ private subCaches: SubReaderCaches = { bulk: new Map(), stream: new Map() };
167
+
168
+ private pendingAppends: { framed: Buffer; rows: number }[] = [];
169
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
170
+ private flushChain: Promise<void> = Promise.resolve();
171
+ private currentFlushDelay = 0;
172
+ private lastWriteTime = 0;
173
+
174
+ private streamFileName: string | undefined;
175
+ private currentStreamFileName: string | undefined;
176
+ private currentStreamFileBytes = 0;
177
+ private lastMergeCheck = Date.now();
178
+ // Running counters of stream-tier rows + bytes on disk. Seeded from each LoadedIndex build, then
179
+ // incremented per flush so the fold-trigger checks current data without an extra directory listing.
180
+ private streamRowsOnDisk = 0;
181
+ private streamBytesOnDisk = 0;
182
+
183
+ private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
184
+ private rebuildPromise: Promise<void> | undefined;
185
+ private rebuildDirty = false;
186
+ private rebuildOptions: { dropStaleFallback: boolean } = { dropStaleFallback: false };
187
+
188
+ // ── memory-pressure watchdog (global, browser-only) ──
294
189
  private static liveInstances = new Set<BulkDatabaseBase<{ key: string }>>();
295
190
  private static memoryWatchdogStarted = false;
296
191
  private static lastMemoryFlushMs = 0;
@@ -301,16 +196,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
301
196
  try { return (performance as unknown as { memory?: { usedJSHeapSize?: number } })?.memory?.usedJSHeapSize; }
302
197
  catch { return undefined; }
303
198
  };
304
- if (typeof performance === "undefined" || usedHeap() === undefined) return; // no heap API (Node / non-Chromium)
199
+ if (typeof performance === "undefined" || usedHeap() === undefined) return;
305
200
  const timer = setInterval(() => {
306
201
  const used = usedHeap();
307
202
  if (used !== undefined) BulkDatabaseBase.checkMemoryPressure(used);
308
203
  }, MEMORY_WATCHDOG_INTERVAL_MS);
309
204
  (timer as { unref?: () => void }).unref?.();
310
205
  }
311
- // If the heap is over the limit (and we haven't flushed recently), drop the in-memory observable state
312
- // of every collection whose loaded data exceeds the size threshold, so it reloads lazily and frees the
313
- // rest. Exposed (with the heap value injected) so it's callable/testable without the real timer.
314
206
  public static checkMemoryPressure(usedHeapBytes: number): void {
315
207
  if (usedHeapBytes < bulkDatabase2Timing.memoryFlushHeapBytes) return;
316
208
  const now = Date.now();
@@ -318,439 +210,147 @@ export class BulkDatabaseBase<T extends { key: string }> {
318
210
  BulkDatabaseBase.lastMemoryFlushMs = now;
319
211
  const flushed: string[] = [];
320
212
  for (const db of BulkDatabaseBase.liveInstances) {
321
- if (db.loadedTotalBytes > bulkDatabase2Timing.memoryFlushMinCollectionBytes) {
322
- flushed.push(`${db.name} (${fmtBytes(db.loadedTotalBytes)})`);
213
+ const bytes = db.reader.index?.totalBytes ?? 0;
214
+ if (bytes > bulkDatabase2Timing.memoryFlushMinCollectionBytes) {
215
+ flushed.push(`${db.name} (${fmtBytes(bytes)})`);
323
216
  db.reloadFromDisk();
324
217
  }
325
218
  }
326
- if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed observable state of ${flushed.length} large collection(s) to reclaim memory: ${flushed.join(", ")}`);
219
+ if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed ${flushed.length} large collection(s): ${flushed.join(", ")}`);
327
220
  }
328
221
 
329
- // ---- buffered stream writes (per collection) ----
330
- // The browser rewrites the whole stream file on every append, so we coalesce writes and flush on a
331
- // ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
332
- // `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
333
- // writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
334
- private pendingAppends: { framed: Buffer; apply: () => void; rows: number }[] = [];
335
- private flushTimer: ReturnType<typeof setTimeout> | undefined;
336
- private flushChain: Promise<void> = Promise.resolve();
337
- private currentFlushDelay = 0;
338
- private lastWriteTime = 0;
339
-
340
- // Block range cache is global and immutable-file-safe; clear it to simulate a cold page load (e.g.
341
- // between an untimed prep step and the timed benchmark). The per-instance sub-reader caches need no
342
- // clearing here — a cold load is a fresh instance, which starts with empty caches.
343
222
  public static clearCache() {
344
223
  blockCache.clear();
345
224
  }
346
225
 
347
- // Opt in to automatic compaction even when the storage is remote (off by default — see
348
- // networkCompactionEnabled). Global; affects every collection. Explicit compact()/tryMergeNow() always
349
- // run regardless of this.
350
226
  public static enableNetworkCompaction() {
351
227
  networkCompactionEnabled = true;
352
228
  }
353
229
 
354
230
  public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
355
231
 
356
- // True when this collection's storage is served over the network (a remote server). Apps can branch on
357
- // this to adjust behavior for the higher latency (e.g. show a "slower storage" hint, prefetch less).
358
232
  public async isRemote(): Promise<boolean> {
359
233
  return !!(await this.storage()).isRemote;
360
234
  }
361
235
 
362
- // Whether the tier-0 stream is big enough to fold into bulk now (both bounds): too many rows AND too
363
- // many bytes to keep reading whole. See bulkDatabase2Timing.streamFoldTrigger*.
364
236
  private streamNeedsFold(): boolean {
365
- return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows && this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
237
+ return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows
238
+ && this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
366
239
  }
367
240
 
368
- // Automatic (background) compaction is skipped over the network unless the app opted in. Explicit
369
- // compact()/tryMergeNow() bypass this.
370
241
  private async automaticCompactionAllowed(): Promise<boolean> {
371
242
  if (networkCompactionEnabled) return true;
372
243
  return !(await this.storage()).isRemote;
373
244
  }
374
245
 
375
- // In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
376
- // are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
377
- //
378
- // NOTE: we never bound or clear this in-memory state during normal operation (only on a structural
379
- // rollover/reset, where the data has been persisted into bulk files). The whole database must be
380
- // resident in memory anyway — file merging reads every row — so a database large enough to blow
381
- // the in-memory cache would already fail at merge time. There is no partial-load mode.
382
- private overlay = new Map<string, OverlayEntry>();
383
- // Latest stream-on-disk timestamp per key (from the loaded stream files). Used together with the
384
- // overlay to decide whether an incoming remote write is actually newer than what we have.
385
- private streamTimes = new Map<string, number>();
386
-
387
- // Cache of fully-resolved (overlay-patched) column results, keyed by column name. The result only
388
- // changes when the overlay mutates or the reader resets, so we keep it until then — repeat whole-column
389
- // reads (common in a UI re-render) are then free, however large the column. Each cached array is frozen
390
- // SHALLOWLY: Object.freeze locks the array itself (callers can't mutate a shared result) but never the
391
- // element objects or their values, so typed-array column values keep their fast representation.
392
- private columnCache = new Map<string, { key: string; value: unknown; time: number }[]>();
393
- // Live keys of the currently-loaded reader (disk only, no overlay), so a write can tell a partial
394
- // update of an existing key (only the written columns change) from adding/removing a key (which appears
395
- // in / disappears from EVERY column). Set on reader build, cleared on reset.
396
- private readerKeys: Set<string> | undefined;
397
-
398
- // ---- the on-disk index ----
399
- // The "index" is the loaded reader (file headers + key columns joined into a resolved view) plus the
400
- // set of files it was built from. A concurrent merge in another tab/process rewrites files out from
401
- // under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
402
- // (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
403
- private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
404
- // Total (uncompressed) size of the data the current reader spans, as a proxy for this collection's
405
- // in-memory footprint — used by the memory-pressure watchdog. 0 when no reader is loaded.
406
- private loadedTotalBytes = 0;
407
- // Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
408
- // triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
409
- // (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
410
- private readerEpoch = 0;
411
- private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
412
-
413
- // Per-file decoded sub-reader caches (keyed by fileName), so reloading the index re-reads only the
414
- // files that actually changed — the stitch (join) still runs, but unchanged files skip re-decoding.
415
- // Per-INSTANCE (not global), because a cached reader's getRange is bound to THIS instance's storage
416
- // backend; in production a collection has one backend, and a "fresh client" is a new process with empty
417
- // caches anyway. Bulk files are immutable, so a name maps to fixed content (cache until the file is
418
- // gone). Stream files are append-only, so size is the version: reuse on a size match, else parse only
419
- // the appended suffix. Pruned per build for files a merge removed (pruneFileCaches); survive the reader
420
- // reset (that's the point — a reload reuses them).
421
- private bulkReaderCache = new Map<string, BaseBulkDatabaseReader>();
422
- private streamReaderCache = new Map<string, { readSize: number; parsedPos: number; entries: StreamEntry[] }>();
423
- // Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
424
- // and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
425
- // the cache and may swap the reader) can never leave a stale entry behind.
426
- private dataGen = 0;
427
-
428
- // ---- trigger throttle (see BulkDatabase2Config.maxTriggerThrottleMs) ----
429
- // Signals whose notification is deferred, the pending flush timer, and the ramping delay. Data state is
430
- // already updated synchronously; only these observable notifications are batched/delayed.
431
- private pendingSignals = new Set<string>();
432
- private triggerTimer: ReturnType<typeof setTimeout> | undefined;
433
- private currentTriggerDelay = 0;
434
- private lastTriggerTime = 0;
435
-
436
- // Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
437
- // kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
438
- // reset on resetReader, since after a merge/reset the next reader build re-measures it.
439
- private streamRowsOnDisk = 0;
440
- private streamBytesOnDisk = 0;
441
-
442
- // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
443
- // so concurrent writers never touch the same file.
444
- private streamFileName: string | undefined;
445
- // Rolling size of the current stream file, so we can seal+fold it once it passes STREAM_FILE_MAX_BYTES
446
- // (reset whenever the file rotates). currentStreamFileName tracks which file the count belongs to.
447
- private currentStreamFileName: string | undefined;
448
- private currentStreamFileBytes = 0;
449
- // Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
450
- // first write — the first background merge check waits a full interval after construction.
451
- private lastMergeCheck = Date.now();
452
- private getStreamFileName(): string {
453
- // Seal (stop appending to) our current file once it's old enough, so no file is ever appended
454
- // to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
455
- if (this.streamFileName) {
456
- const info = parseStreamFileName(this.streamFileName);
457
- if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
458
- }
459
- if (!this.streamFileName) {
460
- this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
461
- }
462
- return this.streamFileName;
463
- }
464
-
465
- // Invalidate after an overlay change. `columns` is the set of columns whose resolved result actually
466
- // changed — only those cached columns are dropped — or "all" when the key set itself changed (a key
467
- // added or removed appears in / disappears from every column).
468
- private invalidateOverlay(key: string, columns: Iterable<string> | "all") {
469
- this.dataGen++;
470
- if (columns === "all") this.columnCache.clear();
471
- else for (const c of columns) this.columnCache.delete(c);
472
- // The per-key signal only re-runs getSingleFieldObjSync watchers of THIS key; skip it when nothing
473
- // is watching the row (a new watcher reads the current overlay anyway). The overlay-wide signal +
474
- // column-cache drop above still cover whole-column watchers and read correctness.
475
- if (this.deps.isObserved?.(key) ?? true) this.invalidateSignal(key);
476
- this.invalidateSignal(OVERLAY_SIGNAL);
477
- }
478
-
479
- // Whether a row (key) is currently being watched — i.e. some reactive observer is subscribed to it via
480
- // getSingleFieldObjSync / getSingleFieldSync. Useful for skipping per-row work when nothing's watching.
481
- // Returns true if the backend can't tell (conservative).
482
246
  public isKeyWatched(key: string): boolean {
483
- return this.deps.isObserved?.(key) ?? true;
247
+ return this.reader.isKeyWatched(key);
484
248
  }
485
249
 
486
- // Whether `key` is currently a live key in the resolved view (overlay wins over disk). A write to a key
487
- // that's already live only changes the columns it sets; a write to an absent key (or one currently
488
- // deleted in the overlay) makes it appear in every column, so every column's cache must drop.
489
- private isLiveNow(key: string): boolean {
490
- const e = this.overlay.get(key);
491
- if (e) return e.value !== DELETED;
492
- return this.readerKeys?.has(key) ?? false;
250
+ // ── index lifecycle ──────────────────────────────────────────────────────────────────────────────
251
+ private async ensureIndex(): Promise<LoadedIndex<T>> {
252
+ if (this.reader.index) return this.reader.index;
253
+ await this.triggerRebuild();
254
+ const idx = this.reader.index;
255
+ if (!idx) throw new Error(`${this.name}: index failed to build`);
256
+ return idx;
493
257
  }
494
258
 
495
- // Notify observers of `signal`. With maxTriggerThrottleMs set, notifications are batched and delayed on
496
- // a ramping schedule so a high-frequency source can't re-run watchers on every change: a change after a
497
- // lull notifies on the next tick (no real delay, but all of one change's signals batch together);
498
- // under sustained changes the delay doubles up to the max, coalescing the burst into one notification.
499
- // Only the OBSERVABLE notification is delayed — the underlying data was already updated, so a read in
500
- // the meantime still sees current values.
501
- private invalidateSignal(signal: string) {
502
- const maxMs = this.config.maxTriggerThrottleMs ?? 0;
503
- if (maxMs <= 0) { this.deps.invalidate(signal); return; }
504
- this.pendingSignals.add(signal);
505
- const now = Date.now();
506
- const lull = now - this.lastTriggerTime > maxMs;
507
- this.lastTriggerTime = now;
508
- if (this.triggerTimer !== undefined) return; // a flush is already scheduled; it will pick this up
509
- // Lull resets the ramp (notify next tick); an active burst ramps the delay toward the max.
510
- this.currentTriggerDelay = lull ? 0 : Math.min(maxMs, Math.max(TRIGGER_THROTTLE_FIRST_STEP_MS, this.currentTriggerDelay * 2));
511
- this.triggerTimer = setTimeout(() => { this.triggerTimer = undefined; this.flushSignals(); }, this.currentTriggerDelay);
512
- (this.triggerTimer as { unref?: () => void }).unref?.();
259
+ // Coalescing rebuild loop: triggers during a build set rebuildDirty so the loop iterates once more
260
+ // when it finishes N rapid triggers cause at most ONE extra rebuild after the current one ends.
261
+ private triggerRebuild(opts: { dropStaleFallback?: boolean } = {}): Promise<void> {
262
+ if (opts.dropStaleFallback) this.rebuildOptions.dropStaleFallback = true;
263
+ if (this.rebuildPromise) {
264
+ this.rebuildDirty = true;
265
+ return this.rebuildPromise;
266
+ }
267
+ this.rebuildPromise = (async () => {
268
+ try {
269
+ do {
270
+ this.rebuildDirty = false;
271
+ await this.doOneRebuild();
272
+ } while (this.rebuildDirty);
273
+ } finally {
274
+ this.rebuildPromise = undefined;
275
+ this.rebuildOptions.dropStaleFallback = false;
276
+ }
277
+ })();
278
+ return this.rebuildPromise;
513
279
  }
514
280
 
515
- private flushSignals() {
516
- if (this.pendingSignals.size === 0) return;
517
- const signals = [...this.pendingSignals];
518
- this.pendingSignals.clear();
519
- this.deps.batch(() => { for (const s of signals) this.deps.invalidate(s); });
281
+ private async doOneRebuild(): Promise<void> {
282
+ const { bulkFiles, streamFiles } = await this.listFiles();
283
+ const storage = await this.storage();
284
+ const newIndex = await LoadedIndex.build<T>({
285
+ name: this.name,
286
+ storage,
287
+ bulkFiles,
288
+ streamFiles,
289
+ subCaches: this.subCaches,
290
+ onUnreadableFile: (f, msg) => this.handleUnreadableFile(f, msg),
291
+ });
292
+ const oldIndex = this.reader.index;
293
+ this.reader.setIndex(newIndex, { dropStaleFallback: this.rebuildOptions.dropStaleFallback });
294
+ this.streamRowsOnDisk = newIndex.streamRowsOnDisk;
295
+ this.streamBytesOnDisk = newIndex.streamBytesOnDisk;
296
+ if (oldIndex) {
297
+ for (const f of oldIndex.fileSet) {
298
+ if (!newIndex.fileSet.has(f)) blockCache.evict(nullJoin(this.name, f));
299
+ }
300
+ }
520
301
  }
521
302
 
522
- // Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
523
- // only changes the columns it includes — the rest fall through to disk on read. A prior delete is
524
- // reset (the key is being re-created).
525
- private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
526
- // Decide BEFORE mutating: if the key is already live, only the columns this write sets change;
527
- // otherwise it's a new/re-created key that joins every column.
528
- const wasLive = this.isLiveNow(key);
529
- const existing = this.overlay.get(key);
530
- const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
531
- this.overlay.set(key, { time, value });
532
- this.invalidateOverlay(key, wasLive ? Object.keys(row) : "all");
303
+ // Drop everything in-memory, hard reset. Pending un-flushed writes survive (still in overlay).
304
+ public reloadFromDisk(): void {
305
+ this.subCaches.bulk.clear();
306
+ this.subCaches.stream.clear();
307
+ this.reader.index?.dropLoadedValues();
308
+ void this.triggerRebuild({ dropStaleFallback: true });
533
309
  }
534
310
 
535
- private setOverlayDeleted(key: string, time: number) {
536
- // Deleting a live key removes it from every column; deleting an absent key changes no column.
537
- const columns = this.isLiveNow(key) ? "all" : [];
538
- this.overlay.set(key, { time, value: DELETED });
539
- this.invalidateOverlay(key, columns);
311
+ // External-merge detection: if some other tab/process changed the file set under us, rebuild.
312
+ private async pollFileSet(): Promise<void> {
313
+ if (!this.reader.index) return;
314
+ let current: Set<string>;
315
+ try {
316
+ const { bulkFiles, streamFiles } = await this.listFiles();
317
+ current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
318
+ } catch { return; }
319
+ const prev = this.reader.index?.fileSet;
320
+ if (!prev) return;
321
+ const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
322
+ if (changed) void this.triggerRebuild();
540
323
  }
541
324
 
542
- private reader = lazy(async (): Promise<ResolvedReader> => {
543
- // A merge can delete a file between our directory listing and our read of it. The merge wrote the
544
- // replacement first, so the data isn't gone — it just moved to a file our stale listing didn't
545
- // include. So on a missing file we re-list and rebuild. Bounded; the last attempt tolerates a
546
- // missing file (loads whatever is there — the documented inconsistent-view bug, fixed by reload).
547
- let start = Date.now();
325
+ private async readWithRetry<R>(fn: () => Promise<R>): Promise<R> {
326
+ await this.ensureIndex();
548
327
  for (let attempt = 0; ; attempt++) {
549
- try {
550
- return await this.buildReader(start, attempt >= MAX_READ_ATTEMPTS);
551
- } catch (e) {
552
- if (e instanceof FilesChangedError && attempt < MAX_READ_ATTEMPTS) continue;
553
- throw e;
328
+ const before = this.reader.index;
329
+ try { return await fn(); }
330
+ catch (e) {
331
+ if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
332
+ if (this.reader.index === before) await this.triggerRebuild();
554
333
  }
555
334
  }
556
- });
557
-
558
- // One read build over a directory listing. Loads every bulk file's columnar reader plus all streamed
559
- // entries, then joins them by write-time. A corrupt/torn bulk file is skipped with a warning (its
560
- // data lives in another file). A *missing* file (deleted by a concurrent merge) throws
561
- // FilesChangedError so the caller re-lists — unless tolerateMissing, when we proceed without it.
562
- private async buildReader(start: number, tolerateMissing: boolean): Promise<ResolvedReader> {
563
- const { bulkFiles, streamFiles } = await this.listFiles();
564
- let filesChanged = false;
565
- const [bulkReadersRaw, streamData] = await Promise.all([
566
- Promise.all(bulkFiles.map(async f => {
567
- try {
568
- return await this.loadFileReader(f.fileName);
569
- } catch (e) {
570
- if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
571
- await this.handleUnreadableFile(f, (e as Error).message);
572
- return undefined;
573
- }
574
- })),
575
- this.loadStreamEntries(streamFiles),
576
- ]);
577
- if (streamData.missing) filesChanged = true;
578
- if (filesChanged && !tolerateMissing) throw new FilesChangedError();
579
-
580
- // Accurate stream size as of this load; subsequent flushes keep it current (see doFlush).
581
- this.streamRowsOnDisk = streamData.entries.length;
582
- this.streamBytesOnDisk = streamData.totalBytes;
583
-
584
- const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
585
- // The join resolves purely by write-time, so reader order doesn't matter.
586
- const readers: BaseBulkDatabaseReader[] = [];
587
- const ordered = this.orderStreamEntries(streamData.entries);
588
- if (ordered.length) {
589
- const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
590
- readers.push(stream.reader);
591
- this.streamTimes = stream.times;
592
- } else {
593
- this.streamTimes = new Map();
594
- }
595
- readers.push(...bulkReaders);
596
- const joined = await joinBulkDatabases(readers);
597
- // Live keys of this reader, so per-column cache invalidation can tell an update of an existing key
598
- // (only its set columns change) from a key add/remove (which touches every column).
599
- this.readerKeys = new Set(joined.keys);
600
- // The files this index was built from, so the poll can detect a concurrent merge changing the set.
601
- this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
602
- this.loadedTotalBytes = joined.totalBytes; // footprint proxy for the memory-pressure watchdog
603
-
604
- // Evict cached sub-readers for files a merge removed, so the caches track the live file set.
605
- this.pruneFileCaches(bulkFiles, streamFiles);
606
-
607
- let time = Date.now() - start;
608
- if (time > 50) {
609
- // Bytes we actually had to read: the full stream files + each bulk file's key column (the
610
- // part we read on load to get its keys).
611
- let bytesRead = streamData.totalBytes;
612
- for (const r of bulkReaders) bytesRead += r.columns.find(c => c.column === KEY_COLUMN)?.byteSize ?? 0;
613
- console.log(`${blue(this.name)} loaded in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files, read ${blue(formatNumber(bytesRead))}B)`);
614
- }
615
- return joined;
616
335
  }
617
336
 
618
- // Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
619
- // collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
620
- // We wait for the reader (and thus streamTimes) first so conflict resolution can see disk
621
- // timestamps, then peers reply to our hello with recent writes that may not be on disk yet (applied
622
- // through the same applyRemote callback).
337
+ // ── cross-tab sync ───────────────────────────────────────────────────────────────────────────────
623
338
  private syncSetup = lazy(async () => {
624
339
  if (!isSyncSupported()) return;
625
- await this.reader();
626
- // onSeal: a peer is about to fold recent data; drop our current stream file so we stop appending
627
- // to it (our next write starts a fresh one), letting the merge fold it whole.
628
- let recent = await syncConnect(this.name, write => this.applyRemote(write), () => { this.streamFileName = undefined; });
629
- for (let write of recent) this.applyRemote(write);
340
+ await this.ensureIndex();
341
+ const recent = await syncConnect(this.name, w => this.applyRemote(w), () => { this.streamFileName = undefined; });
342
+ for (const w of recent) this.applyRemote(w);
630
343
  });
631
344
 
632
- // The timestamp of the value we currently hold for a key (overlay first, then disk stream).
633
- private localTime(key: string): number {
634
- let entry = this.overlay.get(key);
635
- if (entry) return entry.time;
636
- let streamTime = this.streamTimes.get(key);
637
- if (streamTime !== undefined) return streamTime;
638
- return -Infinity;
639
- }
640
-
641
- // Applies a write received from another tab. Only takes effect if it's newer than what we have,
642
- // so it never clobbers our own (or disk's) more recent write for the same key.
643
345
  private applyRemote(write: RemoteWrite) {
644
- if (write.time <= this.localTime(write.key)) return;
645
- this.deps.batch(() => {
646
- if (write.deleted) this.setOverlayDeleted(write.key, write.time);
647
- else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
648
- });
649
- }
650
-
651
- // Drop the loaded index (reader) and everything derived from it, so the next read rebuilds via
652
- // buildReader. Does NOT touch the overlay — callers decide what to do with pending writes.
653
- private clearReaderState() {
654
- this.reader.reset();
655
- // Preserve the last-known sync base values so a reload/compact serves them (not empty) while the
656
- // fresh ones reload — observers transition old → new, never flashing through nothing.
657
- for (const [k, v] of this.baseColumns) this.staleBaseColumns.set(k, v);
658
- for (const [k, v] of this.baseFields) this.staleBaseFields.set(k, v);
659
- this.baseColumns.clear();
660
- this.baseColumnsLoading.clear();
661
- this.baseFields.clear();
662
- this.baseFieldsLoading.clear();
663
- this.columnCache.clear();
664
- this.readerKeys = undefined; // the next build repopulates it
665
- this.loadedFileSet = undefined; // ditto
666
- this.loadedTotalBytes = 0; // nothing loaded ⇒ no footprint for the memory watchdog
667
- // The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
668
- // looking "heavy" and re-trigger a fold before then.
669
- this.streamRowsOnDisk = 0;
670
- this.streamBytesOnDisk = 0;
671
- this.dataGen++;
672
- this.readerEpoch++; // so an in-flight read knows the index changed under it (see readWithReload)
673
- }
674
-
675
- // Reset the loaded reader AND drop the overlay. Used only on structural changes WE made (large
676
- // direct-bulk write, rollover, compact) after the data has been persisted. Writes still buffered (not
677
- // yet on disk) are re-applied so the reset doesn't drop them from reads — they aren't in the reloaded
678
- // reader until their append lands.
679
- private resetReader() {
346
+ if (write.time <= this.reader.localTime(write.key)) return;
680
347
  this.deps.batch(() => {
681
- this.clearReaderState();
682
- this.overlay.clear();
683
- for (const p of this.pendingAppends) p.apply();
684
- this.invalidateSignal(LOAD_SIGNAL);
685
- this.invalidateSignal(OVERLAY_SIGNAL);
348
+ if (write.deleted) this.reader.applyDelete(write.key, write.time);
349
+ else this.reader.applyWrite(write.key, write.value as Record<string, unknown>, write.time);
686
350
  });
687
351
  }
688
352
 
689
- // Reload just the on-disk index after the file set changed UNDER us (a concurrent merge). Keeps the
690
- // overlay: our pending writes are in-memory and independent of which files exist on disk.
691
- private reloadReader() {
692
- this.deps.batch(() => {
693
- this.clearReaderState();
694
- this.invalidateSignal(LOAD_SIGNAL);
695
- this.invalidateSignal(OVERLAY_SIGNAL);
696
- });
697
- }
698
-
699
- // Drop ALL of this collection's in-memory loaded caches (the resolved reader, the sync base column /
700
- // field caches and their stale fallbacks, the per-file decoded readers) and re-trigger every watcher,
701
- // so they re-request and reload from disk. Unlike the automatic reloads, this is a genuine full clear
702
- // (no stale-fallback served — watchers go through a real loading state). Pending un-flushed writes (the
703
- // overlay) are KEPT, so nothing not-yet-on-disk is lost. Per-collection (this instance only).
704
- public reloadFromDisk(): void {
705
- this.deps.batch(() => {
706
- this.clearReaderState(); // drops reader + base caches (→ stale) + column cache; bumps gens
707
- this.staleBaseColumns.clear(); // and the stale fallbacks — a genuine full clear, not a swap
708
- this.staleBaseFields.clear();
709
- this.bulkReaderCache.clear(); // force re-decode from disk
710
- this.streamReaderCache.clear();
711
- this.invalidateSignal(LOAD_SIGNAL);
712
- this.invalidateSignal(OVERLAY_SIGNAL);
713
- });
714
- }
715
-
716
- // Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
717
- // reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
718
- // Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
719
- // its reader (readerEpoch unchanged). So N concurrent failures cause ONE rebuild — the first resets, the
720
- // rest see the bumped epoch and just await the in-flight rebuild that lazy() shares — and a good rebuild
721
- // is never thrown away by a straggler.
722
- private async readWithReload<R>(fn: (reader: ResolvedReader) => Promise<R>): Promise<R> {
723
- for (let attempt = 0; ; attempt++) {
724
- const reader = await this.reader();
725
- const epoch = this.readerEpoch; // epoch of the reader we're about to use
726
- try {
727
- return await fn(reader);
728
- } catch (e) {
729
- if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
730
- if (this.readerEpoch === epoch) this.reloadReader();
731
- // else: another reader already reloaded the index — loop and use its rebuild.
732
- }
733
- }
734
- }
735
-
736
- // Proactively reload the index if the on-disk file set changed under us (a merge in another tab/
737
- // process), so reads pick up the new files even without first hitting a read error. Cheap — just lists
738
- // the directory. Runs on a timer (fileSetPollIntervalMs).
739
- private async pollFileSet(): Promise<void> {
740
- if (!this.loadedFileSet) return; // reader not built yet → nothing loaded to compare against
741
- let current: Set<string>;
742
- try {
743
- const { bulkFiles, streamFiles } = await this.listFiles();
744
- current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
745
- } catch { return; }
746
- const prev = this.loadedFileSet;
747
- if (!prev) return; // reloaded during the await
748
- const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
749
- if (changed) this.reloadReader();
750
- }
751
-
752
- // ---- writes ----
753
-
353
+ // ── writes ───────────────────────────────────────────────────────────────────────────────────────
754
354
  public async write(entry: T): Promise<void> {
755
355
  return this.writeBatch([entry]);
756
356
  }
@@ -759,24 +359,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
759
359
  if (!entries.length) return;
760
360
  void this.syncSetup();
761
361
  const rows = entries as unknown as Record<string, unknown>[];
762
- // Stamp each row with a unique timestamp now, so the same time is used on disk, in the overlay,
763
- // and in the cross-tab broadcast.
764
362
  const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
765
363
  const framed = frameRows(stamped);
766
364
 
767
- // A batch that already exceeds the limits skips the tier-0 stream and writes a bulk file directly
768
- // (streaming thousands of rows one frame at a time would be pointless).
365
+ // Big batches skip the stream and become a bulk file directly — streaming thousands of rows
366
+ // one frame at a time would just churn.
769
367
  if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
770
368
  await this.writeBulkFile(rows);
771
369
  return;
772
370
  }
773
371
 
774
- // Reflect in the overlay + broadcast to other tabs IMMEDIATELY (in-memory + cross-tab are always
775
- // current), but buffer the disk append and flush it on a ramping schedule.
776
- const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
777
- this.deps.batch(apply);
372
+ this.deps.batch(() => {
373
+ for (const { time, row } of stamped) this.reader.applyWrite(row.key as string, row, time);
374
+ });
778
375
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
779
- await this.streamAppend(framed, apply, stamped.length);
376
+ await this.streamAppend(framed, stamped.length);
780
377
  void this.maybeMerge();
781
378
  }
782
379
 
@@ -788,29 +385,27 @@ export class BulkDatabaseBase<T extends { key: string }> {
788
385
  if (!keys.length) return;
789
386
  void this.syncSetup();
790
387
  const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
791
- const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
792
- this.deps.batch(apply);
388
+ this.deps.batch(() => {
389
+ for (const { time, key } of stamped) this.reader.applyDelete(key, time);
390
+ });
793
391
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
794
- await this.streamAppend(frameDeletes(stamped), apply, stamped.length);
392
+ await this.streamAppend(frameDeletes(stamped), stamped.length);
795
393
  void this.maybeMerge();
796
394
  }
797
395
 
798
- // Buffers framed stream bytes and flushes on a ramping per-collection schedule (see the field block).
799
- // `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
800
- // disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
801
- // saved at once; a burst returns fast and is flushed in the background.
802
- private async streamAppend(framed: Buffer, apply: () => void, rows: number): Promise<void> {
803
- this.pendingAppends.push({ framed, apply, rows });
396
+ // Coalesce stream appends on a ramping per-collection schedule (the browser rewrites the whole
397
+ // file per append). The first write after a lull flushes immediately so a single edit-then-close
398
+ // is saved at once; sustained writes ramp toward writeFlushMaxDelayMs.
399
+ private async streamAppend(framed: Buffer, rows: number): Promise<void> {
400
+ this.pendingAppends.push({ framed, rows });
804
401
  const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
805
402
  const now = Date.now();
806
- // Immediate when batching is off (Node / real append), or for the first write after a lull.
807
403
  if (max <= 0 || this.currentFlushDelay <= 0 || now - this.lastWriteTime > max) {
808
404
  this.lastWriteTime = now;
809
405
  this.currentFlushDelay = max > 0 ? Math.min(max, WRITE_FLUSH_FIRST_STEP_MS) : 0;
810
406
  await this.flushPending();
811
407
  return;
812
408
  }
813
- // Active burst: coalesce into one scheduled flush and ramp the delay toward max.
814
409
  this.lastWriteTime = now;
815
410
  if (this.flushTimer === undefined) {
816
411
  this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flushPending(); }, this.currentFlushDelay);
@@ -818,8 +413,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
818
413
  this.currentFlushDelay = Math.min(max, this.currentFlushDelay * 2);
819
414
  }
820
415
 
821
- // Flushes all buffered stream writes to disk as one append. Serialized (so two flushes never write the
822
- // same file concurrently) and best-effort: a failed append keeps the data buffered for the next try.
823
416
  public async flush(): Promise<void> {
824
417
  await this.flushPending();
825
418
  }
@@ -838,21 +431,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
838
431
  const combined = Buffer.concat(batch.map(p => p.framed));
839
432
  const storage = await this.storage();
840
433
  const fileName = this.getStreamFileName();
841
- if (fileName !== this.currentStreamFileName) { // rotated (age-seal / first write) → reset the count
434
+ if (fileName !== this.currentStreamFileName) {
842
435
  this.currentStreamFileName = fileName;
843
436
  this.currentStreamFileBytes = 0;
844
437
  }
845
- // Throws on failure -> we don't splice, so the data stays buffered and a later flush retries it.
438
+ // On failure the throw leaves pendingAppends intact so a later flush retries.
846
439
  await storage.append(fileName, combined);
847
- // New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
440
+ // New entries appended during the await are after `batch` removing the front is exactly the
441
+ // flushed set.
848
442
  this.pendingAppends.splice(0, batch.length);
849
- // Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
850
443
  this.streamBytesOnDisk += combined.length;
851
444
  for (const p of batch) this.streamRowsOnDisk += p.rows;
852
445
  this.currentStreamFileBytes += combined.length;
853
- // Cap our own stream file: once it's grown past the limit, seal it (next write starts a fresh file)
854
- // and fold this now-complete file into bulk in the background. New writes go to the new file, so the
855
- // sealed one is stable and safe to fold+delete immediately — no 10h wait for our own files.
856
446
  if (this.currentStreamFileBytes >= bulkDatabase2Timing.streamFileMaxBytes) {
857
447
  this.streamFileName = undefined;
858
448
  this.currentStreamFileName = undefined;
@@ -861,9 +451,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
861
451
  }
862
452
  }
863
453
 
864
- // Fold one of OUR OWN sealed stream files into bulk and delete it. Safe to delete immediately (force):
865
- // we've stopped appending to it (a new file is current now), and canDeleteStream still only deletes it
866
- // if its size is unchanged since we read it, so an in-flight flush can never lose data.
454
+ private getStreamFileName(): string {
455
+ // Seal our current file once it ages past the seal threshold no file is ever appended to past
456
+ // its seal age, which lets a consolidation safely fold it once aged.
457
+ if (this.streamFileName) {
458
+ const info = parseStreamFileName(this.streamFileName);
459
+ if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
460
+ }
461
+ if (!this.streamFileName) {
462
+ this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
463
+ }
464
+ return this.streamFileName;
465
+ }
466
+
867
467
  private async foldOwnStream(fileName: string): Promise<void> {
868
468
  const info = parseStreamFileName(fileName);
869
469
  if (!info) return;
@@ -878,18 +478,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
878
478
  return this.updateBatch([entry]);
879
479
  }
880
480
 
881
- // Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
882
- // key. Partial fields merge onto the existing row (unset columns fall through to the current value);
883
- // an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
884
481
  public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
885
482
  if (!entries.length) return;
886
483
  void this.syncSetup();
887
- const reader = await this.reader();
888
- const diskKeys = new Set(reader.keys);
484
+ const index = await this.ensureIndex();
889
485
  const present: T[] = [];
890
486
  for (const entry of entries) {
891
- const overlayEntry = this.overlay.get(entry.key);
892
- const exists = overlayEntry ? overlayEntry.value !== DELETED : diskKeys.has(entry.key);
487
+ const overlayEntry = this.reader.overlay.get(entry.key);
488
+ const exists = overlayEntry ? overlayEntry.value !== DELETED : index.keys.has(entry.key);
893
489
  if (!exists) {
894
490
  console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
895
491
  continue;
@@ -899,9 +495,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
899
495
  if (present.length) await this.writeBatch(present);
900
496
  }
901
497
 
902
- // Lists every bulk + stream file currently on disk (no manifest — every file is part of the
903
- // database). Bulk newest-first, streams oldest-first. Duplicate data (from a crashed/raced merge)
904
- // is harmless: reads resolve by write-time and a later merge with enough duplication removes it.
498
+ // ── file listings ────────────────────────────────────────────────────────────────────────────────
905
499
  private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
906
500
  const storage = await this.storage();
907
501
  const names = await storage.getKeys();
@@ -911,7 +505,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
911
505
  if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
912
506
  else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
913
507
  }
914
- // Newest-first by timestamp; ties broken by file name for determinism.
915
508
  bulkFiles.sort((a, b) => {
916
509
  if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
917
510
  return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
@@ -920,10 +513,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
920
513
  return { bulkFiles, streamFiles };
921
514
  }
922
515
 
923
- // Writes `rows` directly as bulk file(s), stamped with the current time as their write-time (the rows
924
- // are being written now). Used by the large-batch write path: no manifest, just new files on disk.
925
- // The rows carry time=now, so the join orders them correctly against any older stream entry for the
926
- // same key (newer time wins) — no clobber. A later testMerge consolidates them.
927
516
  private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
928
517
  const storage = await this.storage();
929
518
  const timestamp = nextFileTime();
@@ -933,78 +522,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
933
522
  const name = newFileName(timestamp);
934
523
  await storage.set(name, encodeCompressedBlocks(built.buffer));
935
524
  }
936
- this.resetReader();
525
+ await this.triggerRebuild();
937
526
  void this.maybeMerge();
938
527
  }
939
528
 
940
- // Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
941
- // unique timestamp + originating file) so callers can order writes globally across files, the
942
- // prefix size we read per file (so a merge can verify nothing was appended before deleting it), and
943
- // whether any listed file was missing (so a read can re-list and retry — a merge deleted it).
944
- private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
945
- const sizes = new Map<string, number>();
946
- if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
947
- const storage = await this.storage();
948
- let missing = false;
949
- // Per-file parse, reusing streamCache when the file hasn't grown. Stream files are append-only, so
950
- // size is the version: same size ⇒ same parsed entries; a larger size ⇒ parse only the appended
951
- // suffix and tack it on. We read a bounded prefix [0, size) — a foreign writer may be appending, and
952
- // storage.get() errors past the stat'd size; parseStream stops at the last complete frame, so a
953
- // later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
954
- const perFile = await Promise.all(streamFiles.map(async (f): Promise<{ fileName: string; size: number; entries: StreamEntry[] } | undefined> => {
955
- try {
956
- const info = await storage.getInfo(f.fileName);
957
- if (!info) { missing = true; return undefined; }
958
- const size = info.size;
959
- sizes.set(f.fileName, size);
960
- const cached = this.streamReaderCache.get(f.fileName);
961
- if (cached && cached.readSize === size) return { fileName: f.fileName, size, entries: cached.entries };
962
- if (size === 0) { this.streamReaderCache.set(f.fileName, { readSize: 0, parsedPos: 0, entries: [] }); return { fileName: f.fileName, size: 0, entries: [] }; }
963
- if (cached && size > cached.readSize) {
964
- // Grew: parse only the appended bytes from where we last stopped (a frame boundary).
965
- const suffix = await storage.getRange(f.fileName, { start: cached.parsedPos, end: size });
966
- if (!suffix) { missing = true; return undefined; }
967
- const parsed = parseStream(suffix);
968
- if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
969
- const entries = parsed.entries.length ? cached.entries.concat(parsed.entries) : cached.entries;
970
- const parsedPos = cached.parsedPos + (suffix.length - parsed.badBytes);
971
- this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos, entries });
972
- return { fileName: f.fileName, size, entries };
973
- }
974
- // Cold (or the rare shrink/rewrite): full read from the start.
975
- const buffer = await storage.getRange(f.fileName, { start: 0, end: size });
976
- if (!buffer) { missing = true; return undefined; }
977
- const parsed = parseStream(buffer);
978
- if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
979
- this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos: size - parsed.badBytes, entries: parsed.entries });
980
- return { fileName: f.fileName, size, entries: parsed.entries };
981
- } catch {
982
- missing = true;
983
- return undefined;
984
- }
985
- }));
986
- const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
987
- let totalBytes = 0;
988
- for (const pf of perFile) {
989
- if (!pf) continue;
990
- totalBytes += pf.size;
991
- for (const entry of pf.entries) entries.push({ time: entry.time, fileName: pf.fileName, entry });
992
- }
993
- return { entries, totalBytes, missing, sizes };
994
- }
995
-
996
- // Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
997
- private orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
998
- entries.sort((a, b) => {
999
- if (a.time !== b.time) return a.time - b.time;
1000
- return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
1001
- });
1002
- return entries.map(e => e.entry);
1003
- }
1004
-
1005
- // Throttled, fire-and-forget after writes: run a background merge check at most once per interval — but
1006
- // a tier-0 stream that's grown too big folds promptly, bypassing the throttle. Skipped entirely over
1007
- // the network unless the app opted in (see automaticCompactionAllowed).
529
+ // ── merge policy ─────────────────────────────────────────────────────────────────────────────────
1008
530
  private async maybeMerge(): Promise<void> {
1009
531
  if (!await this.automaticCompactionAllowed()) return;
1010
532
  const now = Date.now();
@@ -1017,9 +539,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
1017
539
  }
1018
540
  }
1019
541
 
1020
- // Runs one merge pass now (the same one maybeMerge runs on a timer). Returns whether it merged
1021
- // anything, and whether it bailed because another tab/process holds the merge lock — so a caller
1022
- // (e.g. a 30-minute scheduler) can tell "nothing to do" from "someone else is doing it".
1023
542
  public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
1024
543
  if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
1025
544
  try {
@@ -1029,29 +548,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
1029
548
  }
1030
549
  }
1031
550
 
1032
- // Full compaction: fold + dedup everything into key-sorted, ~256MB files. Reads the whole collection
1033
- // into memory (the accepted soft bound), so it's an explicit, occasional call. Deletes consumed bulk
1034
- // files and any stream file it's safe to (aged, or sealed-and-stable).
1035
551
  public async compact(): Promise<void> {
1036
- if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
552
+ if (!tryAcquireMergeLock(this.name, writerId)) return;
1037
553
  try {
1038
- await this.flushPending(); // get buffered writes on disk so they're folded in
554
+ await this.flushPending();
1039
555
  syncBroadcastSeal(this.name);
1040
556
  this.streamFileName = undefined;
1041
557
  const { bulkFiles, streamFiles } = await this.listFiles();
1042
- // compact() merges every file on disk, so nothing older survives outside it tombstones for
1043
- // fully-deleted keys can be dropped rather than carried into a fresh carry stream.
558
+ // compact() folds every file no older data survives outside it surviving tombstones
559
+ // can be dropped (nothing left to suppress).
1044
560
  if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
1045
561
  } finally {
1046
562
  releaseMergeLock(this.name, writerId);
1047
563
  }
1048
564
  }
1049
565
 
1050
- // The unified merge entry point: rewrite everything overlapping [timeLo, timeHi] into fresh
1051
- // key-sorted ~256MB bulk file(s). Selects bulk files by their header time range and stream files by
1052
- // their (creation .. seal-age) window. If the range reaches the present, first asks peers to seal so
1053
- // recent stream data is complete. Callers: testMerge (recent / key-group ranges); external callers
1054
- // can pass any range — older data just produces older files.
1055
566
  public async merge(timeLo: number, timeHi: number): Promise<void> {
1056
567
  if (timeHi >= Date.now()) { syncBroadcastSeal(this.name); this.streamFileName = undefined; }
1057
568
  const { bulkFiles, streamFiles } = await this.listFiles();
@@ -1059,65 +570,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
1059
570
  const selBulk = bulkFiles.filter((f, i) => {
1060
571
  const h = headers[i];
1061
572
  if (!h) return false;
1062
- // Old files (no recorded time range) only belong to a merge that reaches back to the start.
1063
573
  if (!h.maxTime && !h.minTime) return timeLo <= 0;
1064
574
  return h.minTime <= timeHi && h.maxTime >= timeLo;
1065
575
  });
1066
576
  const selStream = streamFiles.filter(f =>
1067
577
  f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
1068
578
  if (selBulk.length + selStream.length < 2) return;
1069
- // timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
1070
- // merge, so no older set survives outside it and surviving tombstones can be dropped.
1071
579
  await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
1072
580
  }
1073
581
 
1074
- // Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
1075
- // file a merge deleted (re-list and retry / skip) from a corrupt one (handle as unreadable).
1076
- private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
1077
- const storage = await this.storage();
1078
- const info = await storage.getInfo(fileName);
1079
- if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
1080
- const rawGetRange: GetRange = async (start, end) => {
1081
- if (end <= start) return EMPTY_BUFFER;
1082
- const buf = await storage.getRange(fileName, { start, end });
1083
- if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
1084
- return buf;
1085
- };
1086
- return { rawGetRange, size: info.size };
1087
- }
1088
-
1089
- private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
1090
- // Bulk files are immutable, so a decoded sub-reader (keys + keyTimes + columns) is valid until the
1091
- // file is gone — reuse it so an index reload doesn't re-decode unchanged files. (We skip the getInfo
1092
- // existence check on a hit; if the file was merged away mid-build the deferred read fails and
1093
- // readWithReload recovers — and buildReader won't ask for files that aren't currently listed.)
1094
- const cached = this.bulkReaderCache.get(fileName);
1095
- if (cached) return cached;
1096
- const raw = await this.makeRawGetRange(fileName);
1097
- const fileId = nullJoin(this.name, fileName);
1098
- // Stored as compressed blocks; replace getRange with a block-cached, decompressing version (same
1099
- // interface) and read the logical (uncompressed) size from its index. open() validates the file
1100
- // size against the index and throws if it's truncated/corrupt.
1101
- const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
1102
- const reader = await loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
1103
- this.bulkReaderCache.set(fileName, reader);
1104
- return reader;
1105
- }
1106
-
1107
- // Drop cached sub-readers for files that no longer exist (a merge replaced them), so the caches track
1108
- // the live file set instead of growing with churn. Called after each successful build.
1109
- private pruneFileCaches(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]) {
1110
- const liveBulk = new Set(bulkFiles.map(f => f.fileName));
1111
- const liveStream = new Set(streamFiles.map(f => f.fileName));
1112
- for (const name of this.bulkReaderCache.keys()) if (!liveBulk.has(name)) this.bulkReaderCache.delete(name);
1113
- for (const name of this.streamReaderCache.keys()) if (!liveStream.has(name)) this.streamReaderCache.delete(name);
1114
- }
1115
-
1116
- // Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
1117
- // planning. Returns undefined for a missing/corrupt file so the planner just leaves it out.
1118
582
  private async readBulkHeader(fileName: string): Promise<BulkHeaderInfo | undefined> {
1119
583
  try {
1120
- const raw = await this.makeRawGetRange(fileName);
584
+ const storage = await this.storage();
585
+ const raw = await makeRawGetRange(storage, fileName);
1121
586
  const fileId = nullJoin(this.name, fileName);
1122
587
  const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
1123
588
  return await loadBulkHeader(opened.getRange, opened.uncompressedSize);
@@ -1126,12 +591,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
1126
591
  }
1127
592
  }
1128
593
 
1129
- // Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
1130
- // Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
1131
- // missing or unreadable so the planner simply leaves it out of any merge.
1132
594
  private async fileLogicalSize(fileName: string): Promise<number | undefined> {
1133
595
  try {
1134
- const raw = await this.makeRawGetRange(fileName);
596
+ const storage = await this.storage();
597
+ const raw = await makeRawGetRange(storage, fileName);
1135
598
  const fileId = nullJoin(this.name, fileName);
1136
599
  const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
1137
600
  return opened.uncompressedSize;
@@ -1140,98 +603,28 @@ export class BulkDatabaseBase<T extends { key: string }> {
1140
603
  }
1141
604
  }
1142
605
 
1143
- // A bulk file that won't load is either a write still in progress (recent) or a stale partial write
1144
- // left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
1145
- // enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
1146
- // Deleting is safe — the write protocol always writes a new file before removing the files it
1147
- // supersedes, so an abandoned partial file's data still lives in another (older) file.
606
+ // A bulk file that won't load is either an in-progress write (recent) or a crashed partial write
607
+ // (stale). Warn while recent, delete once clearly abandoned deleting is safe because the write
608
+ // protocol always lands the replacement before removing the file it supersedes.
1148
609
  private async handleUnreadableFile(file: BulkFileInfo, message: string): Promise<void> {
1149
- let ageMs = Date.now() - file.timestamp;
610
+ const ageMs = Date.now() - file.timestamp;
1150
611
  if (ageMs > STALE_DELETE_MS) {
1151
612
  console.warn(`${this.name}: deleting stale unreadable bulk file ${file.fileName} (${Math.round(ageMs / 86400000)}d old): ${message}`);
1152
613
  try {
1153
- let storage = await this.storage();
614
+ const storage = await this.storage();
1154
615
  await storage.remove(file.fileName);
1155
616
  } catch (removeError) {
1156
617
  console.warn(`${this.name}: failed to delete ${file.fileName}: ${(removeError as Error).message}`);
1157
618
  }
1158
619
  return;
1159
620
  }
1160
- console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
1161
- }
1162
-
1163
- // Resolves a set of readers (stream + bulk) by ACTUAL write-time into merged rows + per-row times,
1164
- // plus the surviving tombstones (keys whose newest event is a delete). For each key/column, the
1165
- // value with the newest write-time across readers wins (non-ABSENT); the row's time is the newest of
1166
- // those. A key is deleted iff its newest delete is newer than its newest set. This is the same
1167
- // time-resolution reads use, captured so a merge can write the result back as bulk + a carry stream.
1168
- private async resolveReaders(readers: BaseBulkDatabaseReader[]): Promise<{ rows: Record<string, unknown>[]; times: number[]; deletes: Map<string, number> }> {
1169
- const loaded = await Promise.all(readers.map(async reader => {
1170
- const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
1171
- for (const col of reader.columns) {
1172
- if (col.column === KEY_COLUMN) continue;
1173
- const entries = await reader.getColumn(col.column);
1174
- cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
1175
- }
1176
- return { keyTimes: reader.keyTimes, deleteTimes: reader.deleteTimes, cols };
1177
- }));
1178
-
1179
- const deleteTime = new Map<string, number>();
1180
- for (const l of loaded) {
1181
- if (!l.deleteTimes) continue;
1182
- for (const [k, t] of l.deleteTimes) deleteTime.set(k, Math.max(deleteTime.get(k) ?? -Infinity, t));
1183
- }
1184
- const keyTime = new Map<string, number>();
1185
- for (const l of loaded) {
1186
- for (const [k, t] of l.keyTimes) keyTime.set(k, Math.max(keyTime.get(k) ?? -Infinity, t));
1187
- }
1188
- const allCols = new Set<string>();
1189
- for (const l of loaded) for (const c of l.cols.keys()) allCols.add(c);
1190
-
1191
- const rows: Record<string, unknown>[] = [];
1192
- const times: number[] = [];
1193
- const deletes = new Map<string, number>();
1194
- const allKeys = new Set<string>([...keyTime.keys(), ...deleteTime.keys()]);
1195
- for (const key of allKeys) {
1196
- const setT = keyTime.get(key) ?? -Infinity;
1197
- const delT = deleteTime.get(key) ?? -Infinity;
1198
- if (setT <= delT) {
1199
- // The newest event for this key is a delete — carry the tombstone forward so it keeps
1200
- // suppressing any older set living in a file outside this merge.
1201
- if (delT > -Infinity) deletes.set(key, delT);
1202
- continue;
1203
- }
1204
- const row: Record<string, unknown> = { [KEY_COLUMN]: key };
1205
- let rowTime = setT;
1206
- for (const col of allCols) {
1207
- let bestTime = -Infinity;
1208
- let bestVal: unknown;
1209
- let found = false;
1210
- for (const l of loaded) {
1211
- const cell = l.cols.get(col)?.get(key);
1212
- if (!cell || cell.value === ABSENT) continue;
1213
- if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
1214
- }
1215
- if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
1216
- }
1217
- rows.push(row);
1218
- times.push(rowTime === -Infinity ? 0 : rowTime);
1219
- }
1220
- return { rows, times, deletes };
621
+ console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be in-progress): ${message}`);
1221
622
  }
1222
623
 
1223
- // The one merge primitive. Reads the given bulk + stream files (skipping any that vanished or won't
1224
- // parse their data lives elsewhere), resolves them by write-time, writes the result back as fresh
1225
- // key-sorted ~256MB bulk file(s) plus a carry stream for surviving tombstones, THEN deletes the
1226
- // inputs it consumed. Output is always written before any delete, so a crash leaves duplicates (next
1227
- // merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
1228
- // only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
1229
- // didn't change while we read it. Returns whether it produced anything.
1230
- // `includesOldest` means this merge consumes every file at or before its time range — there is no
1231
- // file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
1232
- // outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
1233
- // nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
1234
- // merge that reaches time 0 are the cases where this holds.)
624
+ // The one merge primitive. Reads + plans + writes outputs before deleting any input, so a crash
625
+ // leaves duplicates (next merge dedupes) rather than a gap. After the file set changes on disk,
626
+ // we trigger an index rebuild + atomic swap; once swap completes, the consumed files' block-cache
627
+ // entries are evicted (no consumer can ask for them now).
1235
628
  private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
1236
629
  const storage = await this.storage();
1237
630
  const timestamp = nextFileTime();
@@ -1241,21 +634,20 @@ export class BulkDatabaseBase<T extends { key: string }> {
1241
634
  const bulkReaders: BaseBulkDatabaseReader[] = [];
1242
635
  await Promise.all(bulkFiles.map(async f => {
1243
636
  try {
1244
- const r = await this.loadFileReader(f.fileName);
637
+ const r = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
1245
638
  bulkReaders.push(r);
1246
- consumedBulk.push(f); // only files we actually read are safe to delete afterwards
639
+ consumedBulk.push(f);
1247
640
  } catch { /* missing or corrupt — skip; its data lives in another file */ }
1248
641
  }));
1249
642
 
1250
- const streamData = await this.loadStreamEntries(streamFiles);
1251
- const ordered = this.orderStreamEntries(streamData.entries);
643
+ const streamData = await loadStreamEntries(this.name, storage, streamFiles, this.subCaches.stream);
644
+ const ordered = orderStreamEntries(streamData.entries);
1252
645
  const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
1253
646
 
1254
647
  const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
648
+ const readerNames = streamReader ? ["(streams)", ...consumedBulk.map(f => f.fileName)] : consumedBulk.map(f => f.fileName);
1255
649
  if (!readers.length) return false;
1256
650
 
1257
- // Log the inputs of a REAL merge (files + on-disk sizes). Only here, never for the planning checks
1258
- // in testMerge/findDuplicateGroups, so the log marks actual rewrites and their before/after I/O.
1259
651
  const inputs = [
1260
652
  ...await Promise.all(consumedBulk.map(async f => ({ name: f.fileName, size: (await storage.getInfo(f.fileName).catch(() => undefined))?.size ?? 0 }))),
1261
653
  ...streamFiles.map(f => ({ name: f.fileName, size: streamData.sizes.get(f.fileName) ?? 0 })),
@@ -1265,37 +657,28 @@ export class BulkDatabaseBase<T extends { key: string }> {
1265
657
  console.log(`${blue(this.name)} merge: reading ${inputs.length} files (${fmtBytes(inTotal)}) at ${new Date(mergeStartMs).toISOString()}`);
1266
658
  for (const f of inputs) console.log(` in ${f.name} ${fmtBytes(f.size)}`);
1267
659
 
1268
- const { rows, times, deletes } = await this.resolveReaders(readers);
1269
-
1270
- // Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
1271
660
  const newNames: string[] = [];
1272
- if (rows.length) {
1273
- const built = buildFileBuffer(rows, times);
1274
- // When the result is big enough to split across several files, log each sub-write (its key
1275
- // range + start/finish), since each storage.set can be large/slow and we want to see progress.
1276
- const split = built.length > 1;
1277
- if (split) console.log(`${blue(this.name)} merge: output split into ${built.length} files (> ${fmtBytes(TARGET_FILE_BYTES)} each)`);
1278
- for (let i = 0; i < built.length; i++) {
1279
- const part = built[i];
1280
- const name = newFileName(timestamp);
1281
- const subStart = Date.now();
1282
- if (split) console.log(` [${i + 1}/${built.length}] writing ${formatNumber(part.rowCount)} rows [${part.minKey} .. ${part.maxKey}] → ${name} at ${new Date(subStart).toISOString()}`);
1283
- await storage.set(name, encodeCompressedBlocks(part.buffer));
1284
- newNames.push(name);
1285
- if (split) console.log(` [${i + 1}/${built.length}] wrote ${name} (${fmtBytes((await storage.getInfo(name).catch(() => undefined))?.size ?? 0)}) in ${formatTime(Date.now() - subStart)}`);
1286
- }
1287
- }
1288
- // Carry surviving tombstones forward only if older files exist outside this merge that they still
1289
- // need to suppress; when this merge includes the oldest data there's nothing older to suppress.
1290
- const carriedDeletes = includesOldest ? 0 : deletes.size;
661
+ const mergeResult = await runPlannedMerge({
662
+ sources: readers,
663
+ sourceNames: readerNames,
664
+ collectionName: this.name,
665
+ writeFile: async (data) => {
666
+ const fname = newFileName(timestamp);
667
+ await storage.set(fname, encodeCompressedBlocks(data));
668
+ newNames.push(fname);
669
+ const size = (await storage.getInfo(fname).catch(() => undefined))?.size ?? 0;
670
+ return { name: fname, size };
671
+ },
672
+ });
673
+
674
+ const carriedDeletes = includesOldest ? 0 : mergeResult.carriedDeletes.size;
1291
675
  const outNames = [...newNames];
1292
676
  if (carriedDeletes) {
1293
677
  const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
1294
- await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
678
+ await storage.set(carryName, frameDeletes([...mergeResult.carriedDeletes].map(([key, time]) => ({ time, key }))));
1295
679
  outNames.push(carryName);
1296
680
  }
1297
681
 
1298
- // Log the result (files + on-disk sizes), so the before→after of the merge is visible.
1299
682
  const outputs = await Promise.all(outNames.map(async n => ({ name: n, size: (await storage.getInfo(n).catch(() => undefined))?.size ?? 0 })));
1300
683
  const outTotal = outputs.reduce((a, f) => a + f.size, 0);
1301
684
  console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""} at ${new Date().toISOString()} (took ${formatTime(Date.now() - mergeStartMs)})`);
@@ -1307,21 +690,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
1307
690
  if (await this.canDeleteStream(f, now, streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
1308
691
  }
1309
692
 
1310
- this.resetReader();
693
+ // File set changed — rebuild + swap. After the swap, consumed files' block-cache entries are
694
+ // evicted (no reader will request them now).
695
+ await this.triggerRebuild();
1311
696
  return newNames.length > 0 || carriedDeletes > 0;
1312
697
  }
1313
698
 
1314
- // A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
1315
- // age (its writer has provably started a fresh file), OR cross-tab sync is active (so the seal we
1316
- // broadcast reached peers) and its size hasn't changed since we read it (nothing was appended during
1317
- // the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
1318
- // and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
1319
- // data, but the aged check also rules out the rare sparse-offset append race.)
699
+ // A stream is safe to delete iff no writer will append to it again: it's aged past the seal age
700
+ // (writer has provably switched files) OR cross-tab sync is on AND its size didn't change while
701
+ // we read it. Else leave it the data is also in the new bulk file; a later merge deletes it
702
+ // once aged.
1320
703
  private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>, force = false): Promise<boolean> {
1321
704
  if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
1322
- // Without cross-tab sync we normally can't tell a writer is done before the seal age — UNLESS the
1323
- // caller forces it (the hard stream limit). Even then we only delete a stream whose size didn't
1324
- // change while we read it, so a writer mid-append never loses data (it's re-folded next pass).
1325
705
  if (!isSyncSupported() && !force) return false;
1326
706
  const readSize = sizes.get(f.fileName);
1327
707
  if (readSize === undefined) return false;
@@ -1330,49 +710,35 @@ export class BulkDatabaseBase<T extends { key: string }> {
1330
710
  return !!info && info.size === readSize;
1331
711
  }
1332
712
 
1333
- // Waits mergeSpacingMs between merges so a burst of work doesn't rewrite every index at once (keeps
1334
- // peak lag low for the browser). Heartbeats the merge lock through the wait; returns false if another
1335
- // tab took the lock over, so the caller stops doing further merges.
1336
713
  private async mergeSpacingDelay(): Promise<boolean> {
1337
714
  const total = bulkDatabase2Timing.mergeSpacingMs;
1338
715
  if (total <= 0) return tryAcquireMergeLock(this.name, writerId);
1339
- const step = 15 * 1000; // re-stamp the lock well within its TTL
716
+ const step = 15 * 1000;
1340
717
  let waited = 0;
1341
718
  while (waited < total) {
1342
719
  await new Promise<void>(r => setTimeout(r, Math.min(step, total - waited)));
1343
720
  waited += step;
1344
- if (!tryAcquireMergeLock(this.name, writerId)) return false; // another tab took over
721
+ if (!tryAcquireMergeLock(this.name, writerId)) return false;
1345
722
  }
1346
723
  return true;
1347
724
  }
1348
725
 
1349
- // The merge policy. Two passes, with a mergeSpacingMs pause after every merge (so a write burst
1350
- // doesn't rewrite all indexes at once):
1351
- // 1) Consolidate recent fragmentation (one merge): take the newest files up to ~FIRST_MERGE_BYTES
1352
- // and, if they number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs,
1353
- // merge them into one file. Seals first so recent stream data is complete; in Node (no cross-tab
1354
- // seal) only aged streams are folded, so we never re-fold the same un-deletable stream forever.
1355
- // 2) Key-stratify (possibly several merges): sort all keys, walk them in ~KEY_GROUP_BYTES groups, and
1356
- // rewrite EVERY group whose fraction of duplicate (multi-file) keys exceeds DUP_THRESHOLD —
1357
- // highest-duplication first — merging the bulk files overlapping that key range. Groups have
1358
- // disjoint key ranges, so one group's merge doesn't change another's duplication; we re-select
1359
- // each group's files at merge time (the file set shifts as we go). Over time this sorts the data
1360
- // into key-disjoint files. Returns whether any merge happened.
726
+ // The merge policy (two passes, spaced by mergeSpacingMs):
727
+ // 1) Consolidate recent fragmentation: take newest files up to ~FIRST_MERGE_BYTES; merge them
728
+ // into one when they fragment or span too wide a time range.
729
+ // 2) Key-stratify: walk all keys in ~KEY_GROUP_BYTES groups; rewrite groups whose duplicate-key
730
+ // fraction passes DUP_THRESHOLD, highest first.
1361
731
  private async testMerge(): Promise<boolean> {
1362
732
  let merged = false;
1363
- await this.flushPending(); // get buffered writes on disk so this pass can fold/consider them
1364
- // Run a merge, pausing first if we've already merged this pass (so the pause is BETWEEN merges,
1365
- // never before the first or after the last). Returns false if we lost the lock — stop entirely.
733
+ await this.flushPending();
1366
734
  const runMerge = async (bulk: BulkFileInfo[], stream: StreamFileInfo[]): Promise<boolean> => {
1367
735
  if (merged && !await this.mergeSpacingDelay()) return false;
1368
736
  if (await this.mergeFileSet(bulk, stream)) merged = true;
1369
737
  return true;
1370
738
  };
1371
739
 
1372
- // ── Hard stream limit: once the tier-0 stream has grown past the hard cap, fold ALL of it into bulk
1373
- // NOW regardless of age a stream this big makes every read pull a huge file, i.e. the collection
1374
- // is essentially unreadable. Force-delete the folded streams (canDeleteStream still only deletes
1375
- // size-stable ones, so an active writer never loses data; it's just re-folded next pass). ──
740
+ // Hard stream limit: a stream this big makes every read pull a huge file → fold ALL of it now,
741
+ // force-delete (canDeleteStream still requires size-stable, so an active writer never loses data).
1376
742
  {
1377
743
  const { streamFiles } = await this.listFiles();
1378
744
  if (streamFiles.length) {
@@ -1386,14 +752,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
1386
752
  }
1387
753
  }
1388
754
 
1389
- // ── Pass 1: consolidate recent files. ──
1390
- // Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
1391
- // actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
1392
- // no benefit, since canDeleteStream there only deletes aged files anyway.
1393
- const foldRecentStreams = isSyncSupported(); // see canDeleteStream: else we'd re-fold forever
755
+ // Pass 1: consolidate recent. Only seal when cross-tab sync can fold recent streams — in Node
756
+ // canDeleteStream needs them aged anyway, so sealing would just fragment streams every pass.
757
+ const foldRecentStreams = isSyncSupported();
1394
758
  if (foldRecentStreams) {
1395
759
  syncBroadcastSeal(this.name);
1396
- this.streamFileName = undefined; // seal our own current stream so its recent data is complete
760
+ this.streamFileName = undefined;
1397
761
  }
1398
762
  {
1399
763
  const { bulkFiles, streamFiles } = await this.listFiles();
@@ -1418,11 +782,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
1418
782
  if (recentBytes >= FIRST_MERGE_BYTES) break;
1419
783
  }
1420
784
  const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
1421
- // Fold when there's enough fragmentation to consolidate, OR when the foldable stream data here
1422
- // has grown past the byte threshold (stream data can't be read per-cell, so a big stream is
1423
- // costly to pull whole). The stream size is derived from THIS fresh listing — not a cached
1424
- // counter — so it's correct even for an instance that never built a reader (e.g. the host's
1425
- // autocompactor) and for streams that grew in place since the index was last loaded.
1426
785
  const recentStreamBytes = recent.reduce((a, it) => a + (it.kind === "stream" ? it.bytes : 0), 0);
1427
786
  const heavyStream = recentStreamBytes > bulkDatabase2Timing.streamFoldTriggerBytes;
1428
787
  const triggered =
@@ -1435,18 +794,15 @@ export class BulkDatabaseBase<T extends { key: string }> {
1435
794
  }
1436
795
  }
1437
796
 
1438
- // ── Pass 2: key-stratify the bulk files to remove duplication. ──
1439
- // Compute the qualifying groups' key ranges once (over a post-pass-1 listing); their key ranges
1440
- // are disjoint, so they stay valid as we merge each in turn.
797
+ // Pass 2: key-stratified deduplication. Disjoint key ranges one group's merge doesn't
798
+ // change another's duplication; re-select each group's files at merge time (set shifts).
1441
799
  const groups = await this.findDuplicateGroups();
1442
800
  for (const g of groups) {
1443
- // Re-select the files overlapping this group's key range now (earlier merges shifted the set).
1444
801
  const { bulkFiles } = await this.listFiles();
1445
802
  const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
1446
803
  const groupFiles = bulkFiles.filter((f, i) => {
1447
804
  const h = headers[i];
1448
805
  if (!h) return false;
1449
- // Old files without a key range are treated as spanning all keys (so always overlap).
1450
806
  if (h.minKey === undefined || h.maxKey === undefined) return true;
1451
807
  return h.minKey <= g.hi && h.maxKey >= g.lo;
1452
808
  });
@@ -1456,15 +812,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
1456
812
  return merged;
1457
813
  }
1458
814
 
1459
- // Finds key-range groups worth deduping: sorts all bulk keys, walks them in ~KEY_GROUP_BYTES groups,
1460
- // and returns the [lo, hi] of every group whose duplicate (multi-file) key fraction exceeds
1461
- // DUP_THRESHOLD, highest-duplication first (most benefit). Empty when nothing is worth it.
1462
815
  private async findDuplicateGroups(): Promise<{ lo: string; hi: string; dup: number }[]> {
1463
816
  const { bulkFiles } = await this.listFiles();
1464
817
  if (bulkFiles.length < 2) return [];
818
+ const storage = await this.storage();
1465
819
  const infos = await Promise.all(bulkFiles.map(async f => {
1466
820
  try {
1467
- const reader = await this.loadFileReader(f.fileName);
821
+ const reader = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
1468
822
  return { keys: reader.keys, bytes: reader.totalBytes };
1469
823
  } catch {
1470
824
  return { keys: [] as string[], bytes: 0 };
@@ -1482,7 +836,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1482
836
  const groups: { lo: string; hi: string; dup: number }[] = [];
1483
837
  let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
1484
838
  for (let i = 0; i < sortedKeys.length; i++) {
1485
- const c = keyCount.get(sortedKeys[i])!;
839
+ const c = keyCount.get(sortedKeys[i]) ?? 0;
1486
840
  gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
1487
841
  if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
1488
842
  const dup = (gSlots - gUnique) / gSlots;
@@ -1494,290 +848,81 @@ export class BulkDatabaseBase<T extends { key: string }> {
1494
848
  return groups;
1495
849
  }
1496
850
 
1497
- private formatInfo(reader: ResolvedReader): string {
1498
- return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
1499
- }
1500
-
1501
- // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
1502
- // overlay entry that doesn't include this column leaves the base (disk) value+time in place — a
1503
- // partial write/update only overrides the columns it set; everything else falls through. An overlay
1504
- // override carries the overlay write's time (when that pending write happened).
1505
- private patchColumn(base: { key: string; value: unknown; time: number }[], column: string): { key: string; value: unknown; time: number }[] {
1506
- if (this.overlay.size === 0) return base;
1507
- const map = new Map(base.map(e => [e.key, { value: e.value, time: e.time }]));
1508
- for (const [key, entry] of this.overlay) {
1509
- if (entry.value === DELETED) { map.delete(key); continue; }
1510
- if (column in entry.value) map.set(key, { value: entry.value[column], time: entry.time });
1511
- else if (!map.has(key)) map.set(key, { value: undefined, time: entry.time });
1512
- }
1513
- return [...map].map(([key, v]) => ({ key, value: v.value, time: v.time }));
1514
- }
1515
-
1516
- // ---- async reads (overlay-aware) ----
1517
-
1518
- public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
1519
- return (await this.getSingleFieldObj(key, column))?.value;
851
+ // ── reads forwarded to BulkDatabaseReader, with rebuild-on-missing retry ───────────────────────
852
+ public async getSingleField<C extends keyof T>(key: string, column: C): Promise<T[C] | undefined> {
853
+ void this.syncSetup();
854
+ return this.readWithRetry(() => this.reader.getSingleField(key, column));
1520
855
  }
1521
856
 
1522
- // Like getSingleField, but returns the same shape a getColumn entry has: { key, value, time }, where
1523
- // time is roughly when that value last changed (the resolved write-time; for a row-merged value it's
1524
- // the newest contributing write). Returns undefined only when the key isn't present/live.
1525
- public async getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{ key: string; value: T[Column]; time: number } | undefined> {
857
+ public async getSingleFieldObj<C extends keyof T>(key: string, column: C): Promise<{ key: string; value: T[C]; time: number } | undefined> {
1526
858
  void this.syncSetup();
1527
- const col = String(column);
1528
- const entry = this.overlay.get(key);
1529
- if (entry !== undefined) {
1530
- if (entry.value === DELETED) return undefined;
1531
- if (col in entry.value) return { key, value: entry.value[col] as T[Column], time: entry.time };
1532
- // column not set in the overlay entry — fall through to disk for this column
1533
- }
1534
- let time = Date.now();
1535
- const r = await this.readWithReload(reader => reader.getSingleField(key, col));
1536
- time = Date.now() - time;
1537
- if (time > 50) {
1538
- console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1539
- }
1540
- if (r === undefined) {
1541
- // Not live on disk; but if the overlay holds the key (a partial write of a not-yet-on-disk
1542
- // key) it's live with this column unset.
1543
- if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
1544
- return undefined;
1545
- }
1546
- return { key, value: r.value as T[Column], time: r.time };
859
+ return this.readWithRetry(() => this.reader.getSingleFieldObj(key, column));
1547
860
  }
1548
861
 
1549
- public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column]; time: number }[]> {
862
+ public async getColumn<C extends keyof T>(column: C): Promise<{ key: string; value: T[C]; time: number }[]> {
1550
863
  void this.syncSetup();
1551
- const col = String(column);
1552
- const cached = this.columnCache.get(col);
1553
- if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1554
- const gen = this.dataGen;
1555
- let time = Date.now();
1556
- let base = await this.readWithReload(reader => reader.getColumn(col));
1557
- let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1558
- time = Date.now() - time;
1559
- if (time > 50) {
1560
- console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1561
- }
1562
- Object.freeze(result);
1563
- // Only cache if no write/reset happened during the awaits above (else this result may be stale).
1564
- if (this.dataGen === gen) this.columnCache.set(col, result);
1565
- return result;
864
+ return this.readWithRetry(() => this.reader.getColumn(column));
1566
865
  }
1567
866
 
1568
867
  public async getKeys(): Promise<string[]> {
1569
868
  void this.syncSetup();
1570
- let reader = await this.reader();
1571
- if (this.overlay.size === 0) return reader.keys;
1572
- let set = new Set(reader.keys);
1573
- for (const [key, entry] of this.overlay) {
1574
- if (entry.value === DELETED) set.delete(key);
1575
- else set.add(key);
1576
- }
1577
- return [...set];
869
+ return this.readWithRetry(() => this.reader.getKeys());
1578
870
  }
1579
871
 
1580
- // ---- sync (reactive) reads ----
1581
- // These observe the overlay + load signals, so a reactive context that reads them re-runs when a
1582
- // write/delete happens or when a base value finishes loading. The immutable base column/field is
1583
- // loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
1584
- // because the overlay mutates).
1585
-
1586
- private baseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
1587
- private baseColumnsLoading = new Set<string>();
1588
- // The disk-resolved field: { value, time } when the key is live on disk, or undefined when it isn't
1589
- // (Map.has distinguishes "loaded" from "not loaded yet").
1590
- private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
1591
- private baseFieldsLoading = new Set<string>();
1592
- // Last-known sync base values, kept across a reader reset so a reload/compact serves the previous data
1593
- // instead of flashing empty while the fresh value reloads in the background. Each entry is dropped once
1594
- // ensureBase* has the corresponding fresh value (so reads transition old → new, never old → empty → new).
1595
- private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
1596
- private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
1597
-
1598
- private ensureBaseColumn(column: string) {
1599
- if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
1600
- this.baseColumnsLoading.add(column);
1601
- void (async () => {
1602
- try {
1603
- const base = await this.readWithReload(reader => reader.getColumn(column));
1604
- this.deps.batch(() => {
1605
- this.baseColumns.set(column, base);
1606
- this.staleBaseColumns.delete(column); // fresh value in hand; stop serving the stale one
1607
- this.baseColumnsLoading.delete(column);
1608
- this.invalidateSignal(LOAD_SIGNAL);
1609
- });
1610
- } catch (e) {
1611
- // The load failed (e.g. a file vanished and the reload retry also failed). Clear the loading
1612
- // flag so a later read retries, rather than leaving the column wedged as "loading" forever.
1613
- this.baseColumnsLoading.delete(column);
1614
- console.warn(`${this.name}.getColumnSync(${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1615
- }
1616
- })();
1617
- }
1618
-
1619
- private ensureBaseField(key: string, column: string) {
1620
- let cacheKey = nullJoin(column, key);
1621
- if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
1622
- this.baseFieldsLoading.add(cacheKey);
1623
- void (async () => {
1624
- try {
1625
- const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
1626
- this.deps.batch(() => {
1627
- this.baseFields.set(cacheKey, resolved);
1628
- this.staleBaseFields.delete(cacheKey); // fresh value in hand; stop serving the stale one
1629
- this.baseFieldsLoading.delete(cacheKey);
1630
- this.invalidateSignal(LOAD_SIGNAL);
1631
- });
1632
- } catch (e) {
1633
- this.baseFieldsLoading.delete(cacheKey);
1634
- console.warn(`${this.name}.getSingleFieldSync(${JSON.stringify(key)}, ${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1635
- }
1636
- })();
1637
- }
1638
-
1639
- public getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined {
1640
- return this.getSingleFieldObjSync(key, column)?.value;
872
+ public getSingleFieldSync<C extends keyof T>(key: string, column: C): T[C] | undefined {
873
+ void this.syncSetup();
874
+ return this.reader.getSingleFieldSync(key, column);
1641
875
  }
1642
876
 
1643
- // Sync (reactive) counterpart of getSingleFieldObj: { key, value, time } once loaded, undefined while
1644
- // loading or when the key isn't present/live. time is roughly when the value last changed.
1645
- public getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): { key: string; value: T[Column]; time: number } | undefined {
877
+ public getSingleFieldObjSync<C extends keyof T>(key: string, column: C): { key: string; value: T[C]; time: number } | undefined {
1646
878
  void this.syncSetup();
1647
- this.deps.observe(LOAD_SIGNAL);
1648
- this.deps.observe(key);
1649
- let col = String(column);
1650
- let entry = this.overlay.get(key);
1651
- if (entry !== undefined) {
1652
- if (entry.value === DELETED) return undefined;
1653
- if (col in entry.value) {
1654
- // Warm the disk-backed base in the background even though the overlay serves this now — so
1655
- // when the overlay is later cleared (e.g. compaction persists it), there's a value to serve
1656
- // and the read doesn't flash empty.
1657
- this.ensureBaseField(key, col);
1658
- return { key, value: entry.value[col] as T[Column], time: entry.time };
1659
- }
1660
- // column not set in the overlay entry — fall through to the base field cache for this column
1661
- }
1662
- let cacheKey = nullJoin(col, key);
1663
- // Use the fresh value if loaded; mid-reload fall back to the last-known one so we don't flash empty.
1664
- let src: Map<string, { value: unknown; time: number } | undefined> | undefined;
1665
- if (this.baseFields.has(cacheKey)) {
1666
- src = this.baseFields;
1667
- } else {
1668
- this.ensureBaseField(key, col);
1669
- src = this.staleBaseFields.has(cacheKey) ? this.staleBaseFields : undefined;
1670
- }
1671
- if (!src) {
1672
- // Genuine first load (nothing known); but an overlay entry makes the key live with this column unset.
1673
- if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
1674
- return undefined;
1675
- }
1676
- const base = src.get(cacheKey);
1677
- if (base === undefined) {
1678
- // Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
1679
- if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
1680
- return undefined;
1681
- }
1682
- return { key, value: base.value as T[Column], time: base.time };
879
+ return this.reader.getSingleFieldObjSync(key, column);
1683
880
  }
1684
881
 
1685
- public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column]; time: number }[] | undefined {
882
+ public getColumnSync<C extends keyof T>(column: C): { key: string; value: T[C]; time: number }[] | undefined {
1686
883
  void this.syncSetup();
1687
- this.deps.observe(LOAD_SIGNAL);
1688
- // Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
1689
- this.deps.observe(OVERLAY_SIGNAL);
1690
- let col = String(column);
1691
- const cached = this.columnCache.get(col);
1692
- if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1693
- let base = this.baseColumns.get(col);
1694
- if (!base) {
1695
- this.ensureBaseColumn(col);
1696
- // Mid-reload: serve the last-known value (patched with the current overlay) so we don't flash
1697
- // empty. Don't cache it — it's stale until ensureBaseColumn swaps in the fresh value.
1698
- const stale = this.staleBaseColumns.get(col);
1699
- if (stale) return this.patchColumn(stale, col) as { key: string; value: T[Column]; time: number }[];
1700
- return undefined; // genuine first load — nothing known yet
1701
- }
1702
- // Synchronous (no awaits) → reads the current overlay and caches it atomically; an observer re-runs
1703
- // (and the cache is cleared) on any later change, so the frozen result is safe to share.
1704
- let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1705
- Object.freeze(result);
1706
- this.columnCache.set(col, result);
1707
- return result;
884
+ return this.reader.getColumnSync(column);
1708
885
  }
1709
886
 
1710
- // Reactive: whether (key, column) is available to read synchronously yet. true once it's loaded — we
1711
- // know the answer, whether that's a value, absent, or deleted; false while it's still loading from disk.
1712
- // Pairs with getSingleFieldObjSync, which returns undefined for BOTH "loading" and "absent" — use this
1713
- // to tell them apart (e.g. show a spinner only when this is false). Triggers the load if not started,
1714
- // and (like the sync reads) counts the last-known value served during a reload as loaded.
1715
- public isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean {
887
+ public isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean {
1716
888
  void this.syncSetup();
1717
- this.deps.observe(LOAD_SIGNAL);
1718
- this.deps.observe(key);
1719
- const entry = this.overlay.get(key);
1720
- if (entry !== undefined) {
1721
- if (entry.value === DELETED) return true; // known: deleted
1722
- if (String(column) in entry.value) return true; // known: overlay holds this column
1723
- // else: this column falls through to disk — check the base caches below
1724
- }
1725
- const cacheKey = nullJoin(String(column), key);
1726
- if (this.baseFields.has(cacheKey) || this.staleBaseFields.has(cacheKey)) return true;
1727
- this.ensureBaseField(key, String(column));
1728
- return false;
889
+ return this.reader.isFieldLoadedSync(key, column);
1729
890
  }
1730
891
 
1731
- // Reactive: whether a whole column is available to read synchronously yet (see isFieldLoadedSync).
1732
- public isColumnLoadedSync<Column extends keyof T>(column: Column): boolean {
892
+ public isColumnLoadedSync<C extends keyof T>(column: C): boolean {
1733
893
  void this.syncSetup();
1734
- this.deps.observe(LOAD_SIGNAL);
1735
- this.deps.observe(OVERLAY_SIGNAL);
1736
- const col = String(column);
1737
- if (this.columnCache.has(col) || this.baseColumns.has(col) || this.staleBaseColumns.has(col)) return true;
1738
- this.ensureBaseColumn(col);
1739
- return false;
894
+ return this.reader.isColumnLoadedSync(column);
1740
895
  }
1741
896
 
1742
897
  public async getColumnInfo() {
1743
- let reader = await this.reader();
1744
- return reader.columns;
898
+ const index = await this.ensureIndex();
899
+ return index.reader.columns;
1745
900
  }
1746
901
 
1747
- // Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
1748
- // would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
1749
- // tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
1750
- // keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
1751
- // this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
1752
- // fragmented, compaction would shrink it).
1753
902
  public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
1754
- const reader = await this.reader();
1755
- const rawKeys = reader.rawKeyCount;
1756
- const finalKeys = reader.keys.length;
903
+ const index = await this.ensureIndex();
904
+ const rawKeys = index.reader.rawKeyCount;
905
+ const finalKeys = index.reader.keys.length;
1757
906
  return {
1758
907
  rawKeys,
1759
908
  finalKeys,
1760
909
  wastedKeys: rawKeys - finalKeys,
1761
910
  duplication: finalKeys ? rawKeys / finalKeys : 0,
1762
- readers: reader.readerCount,
911
+ readers: index.reader.readerCount,
1763
912
  };
1764
913
  }
1765
914
 
1766
915
  public async getReaderInfo() {
1767
- let reader = await this.reader();
916
+ const index = await this.ensureIndex();
1768
917
  return {
1769
- rowCount: reader.rowCount,
1770
- totalBytes: reader.totalBytes,
1771
- keyCount: reader.keys.length,
1772
- sampleKey: reader.keys[0] as string | undefined,
1773
- columns: reader.columns,
918
+ rowCount: index.reader.rowCount,
919
+ totalBytes: index.reader.totalBytes,
920
+ keyCount: index.reader.keys.length,
921
+ sampleKey: index.reader.keys[0] as string | undefined,
922
+ columns: index.reader.columns,
1774
923
  };
1775
924
  }
1776
925
 
1777
- // Per-file breakdown of the collection's on-disk files, read FRESH from disk each call (so it
1778
- // reflects the latest sizes, including stream files still being appended). `bytes` is the actual
1779
- // on-disk (compressed, for bulk) size. Useful for showing collection size / fragmentation, and to
1780
- // decide whether to call tryMergeNow()/compact().
1781
926
  public async getFileInfo(): Promise<{ files: { name: string; type: "bulk" | "stream"; bytes: number }[]; count: number; totalBytes: number }> {
1782
927
  const { bulkFiles, streamFiles } = await this.listFiles();
1783
928
  const storage = await this.storage();
@@ -1790,101 +935,3 @@ export class BulkDatabaseBase<T extends { key: string }> {
1790
935
  }
1791
936
  }
1792
937
 
1793
- // The merged, time-resolved view over all readers. getColumn/getSingleField return the resolved value
1794
- // AND its write-time (so reads can expose roughly when a value last changed); the base layers the
1795
- // overlay on top of these. getSingleField returns undefined only when the key isn't live (deleted or
1796
- // absent) — a live key whose column is merely unset returns { value: undefined, time: rowTime }.
1797
- type ResolvedReader = {
1798
- rowCount: number;
1799
- totalBytes: number;
1800
- keys: string[];
1801
- // Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
1802
- // N files counts N times) — and how many readers there are. Already in memory after the join, so
1803
- // getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
1804
- rawKeyCount: number;
1805
- readerCount: number;
1806
- columns: { column: string; byteSize: number }[];
1807
- getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
1808
- getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
1809
- };
1810
-
1811
- // Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
1812
- // - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
1813
- // a reader that never set the column for that key falls through to an older reader.
1814
- // - a key is live iff its newest write is newer than its newest delete; per column, the value is
1815
- // suppressed if a delete is newer than that column's newest set.
1816
- // No reliance on file order or partitioning — time is the only thing that decides.
1817
- async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
1818
- const deleteTime = new Map<string, number>();
1819
- for (const db of databases) {
1820
- if (!db.deleteTimes) continue;
1821
- for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
1822
- }
1823
- const keyTime = new Map<string, number>();
1824
- for (const db of databases) {
1825
- for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
1826
- }
1827
- const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
1828
- // Live keys: newest write strictly newer than newest delete.
1829
- const keys: string[] = [];
1830
- for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
1831
-
1832
- const columns: { column: string; byteSize: number }[] = [];
1833
- const columnByName = new Map<string, { column: string; byteSize: number }>();
1834
- for (const db of databases) {
1835
- for (const col of db.columns) {
1836
- let existing = columnByName.get(col.column);
1837
- if (!existing) {
1838
- existing = { column: col.column, byteSize: 0 };
1839
- columnByName.set(col.column, existing);
1840
- columns.push(existing);
1841
- }
1842
- existing.byteSize += col.byteSize;
1843
- }
1844
- }
1845
-
1846
- return {
1847
- totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
1848
- rowCount: keys.length,
1849
- keys,
1850
- rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
1851
- readerCount: databases.length,
1852
- columns,
1853
- async getColumn(column) {
1854
- const perReader = await Promise.all(databases.map(async db => {
1855
- if (!db.columns.some(c => c.column === column)) return undefined;
1856
- const entries = await db.getColumn(column);
1857
- return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
1858
- }));
1859
- return keys.map(key => {
1860
- let bestTime = -Infinity;
1861
- let bestVal: unknown;
1862
- let found = false;
1863
- for (const m of perReader) {
1864
- const cell = m && m.get(key);
1865
- if (!cell || cell.value === ABSENT) continue;
1866
- if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
1867
- }
1868
- // time = the column value's write-time when this column has one, else the row's last
1869
- // write-time (the key is live but never set this column).
1870
- const live = found && bestTime > delOf(key);
1871
- return { key, value: live ? bestVal : undefined, time: live ? bestTime : (keyTime.get(key) ?? 0) };
1872
- });
1873
- },
1874
- async getSingleField(key, column) {
1875
- const kt = keyTime.get(key);
1876
- if (kt === undefined || kt <= delOf(key)) return undefined; // key not live
1877
- let bestTime = -Infinity;
1878
- let bestVal: unknown;
1879
- let found = false;
1880
- for (const db of databases) {
1881
- if (!db.columns.some(c => c.column === column)) continue;
1882
- const r = await db.getSingleField(key, column);
1883
- if (r === ABSENT) continue;
1884
- if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
1885
- }
1886
- const live = found && bestTime > delOf(key);
1887
- return { value: live ? bestVal : undefined, time: live ? bestTime : kt };
1888
- },
1889
- };
1890
- }