sliftutils 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,71 @@
1
1
  import { sort } from "socket-function/src/misc";
2
2
  import { getTimeUnique } from "socket-function/src/bits";
3
- import { BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, loadBulkDatabase } from "./BulkDatabaseFormat";
3
+ import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, BulkHeaderInfo, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase, loadBulkHeader, TARGET_FILE_BYTES } from "./BulkDatabaseFormat";
4
4
  import { lazy } from "socket-function/src/caching";
5
5
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
6
  import { blue, red } from "socket-function/src/formatting/logColors";
7
7
  import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
8
8
  import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
9
- import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, RemoteWrite } from "./syncClient";
9
+ import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
10
+ import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
10
11
  import type { FileStorage } from "../FileFolderAPI";
11
12
 
13
+ // ───────────────────────────────────────────────────────────────────────────────────────────────
14
+ // KNOWN BUGS (accepted, documented):
15
+ //
16
+ // • Inconsistent directory listing under concurrent merges. A read lists the directory, then loads
17
+ // each listed file. If a file is missing when we go to read it (a merge deleted it), we re-list and
18
+ // retry — the deleting merge wrote the replacement first, so the data is never gone, just moved.
19
+ // But a directory listing is not guaranteed atomic on every filesystem: a listing taken while
20
+ // another writer is mid-swap can in principle return a set of files that never simultaneously
21
+ // existed (e.g. the replacement but not a sibling it depends on). That yields a momentarily
22
+ // INCONSISTENT view — some keys may read stale or missing. It does NOT lose data on disk: a reload
23
+ // (refresh the page) re-lists and resolves correctly. We accept this rather than reintroduce a
24
+ // manifest; it's rare and OS/filesystem-dependent.
25
+ // ───────────────────────────────────────────────────────────────────────────────────────────────
26
+
12
27
  // BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
13
28
  // folder rather than sharing bulkDatabases/.
14
29
  const BULK_ROOT_FOLDER = "bulkDatabases2";
15
30
  const FILE_EXTENSION = ".bulk";
16
- // Once this many bulk files pile up we run a merge pass to consolidate small ones (bounded by the
17
- // byte caps below), so reads don't fan out across an unbounded number of files.
18
- const MERGE_FILE_COUNT = 8;
19
-
20
- // Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
21
- // LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
22
- // memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
23
- // in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
24
- // the smaller on-disk compressed size — is what actually lands in memory during a merge.
25
- const MERGE_MIN_BYTES = 400 * 1024 * 1024;
26
- const MERGE_MAX_BYTES = 800 * 1024 * 1024;
27
-
28
- // Tier-0 streaming rolls over into a columnar bulk file once it gets big enough — by row count,
29
- // byte size, or file count (many threads each stream to their own file). A single writeBatch that
30
- // already exceeds the row/byte limits skips streaming and writes a bulk file directly.
31
+ // A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
32
+ // into a bulk file (streaming thousands of rows one frame at a time would be pointless).
31
33
  const ROLLOVER_ROWS = 5000;
32
34
  const ROLLOVER_BYTES = 5 * 1024 * 1024;
33
- const ROLLOVER_FILES = 100;
34
35
 
35
- // An unreadable file might be a write that is still in progress (another thread), so we can't delete
36
- // it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
37
- // writer is plausibly still working on it, so we delete it. Until then we just warn.
36
+ // An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
37
+ // can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
38
+ // no writer is plausibly still working on it, so we delete it. Until then we just warn.
38
39
  const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
39
40
 
41
+ // A read lists the directory then loads each file; if a file vanished (a merge deleted it) we re-list
42
+ // and retry, since the merge wrote the replacement first. Bounded so a pathological merge storm can't
43
+ // loop forever — after this many tries we load whatever's currently there (the documented inconsistent
44
+ // -view bug), which a later reload resolves.
45
+ const MAX_READ_ATTEMPTS = 8;
46
+
47
+ // The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
48
+ // file (half the target, so it has room to grow before it needs splitting). The key-stratified second
49
+ // merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
50
+ // (multi-file) keys exceeds DUP_THRESHOLD — i.e. only when deduping actually buys enough.
51
+ const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
52
+ const KEY_GROUP_BYTES = 800 * 1024 * 1024;
53
+ const DUP_THRESHOLD = 0.4;
54
+
55
+ // Time thresholds, mutable so tests can shrink them from hours to milliseconds.
56
+ export const bulkDatabase2Timing = {
57
+ // A writer stops appending to its current stream file once it's this old (starts a fresh one). A
58
+ // stream file older than this is therefore safe for a merge to delete: its writer has provably moved
59
+ // on to a new file and will never append to it again.
60
+ streamSealAgeMs: 10 * 60 * 60 * 1000,
61
+ // Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
62
+ mergeCheckIntervalMs: 30 * 60 * 1000,
63
+ // The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
64
+ firstMergeTriggerFiles: 20,
65
+ // ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
66
+ firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
67
+ };
68
+
40
69
  // Marks a key as deleted in the in-memory overlay.
41
70
  const DELETED = Symbol("deleted");
42
71
  // Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
@@ -86,6 +115,12 @@ const LOAD_SIGNAL = NULL + "load";
86
115
  const OVERLAY_SIGNAL = NULL + "overlay";
87
116
 
88
117
  let fileNameCounter = 0;
118
+ // Random per-process id baked into file names so two processes (tabs) writing the same collection
119
+ // never collide on a name when they pick the same timestamp/counter in the same millisecond.
120
+ const writerId = Math.random().toString(36).slice(2, 10);
121
+ function nextCounter(): number {
122
+ return ++fileNameCounter;
123
+ }
89
124
 
90
125
  type BulkFileInfo = { fileName: string; level: number; timestamp: number };
91
126
 
@@ -100,10 +135,9 @@ function nextFileTime(): number {
100
135
 
101
136
  // Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
102
137
  // of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
103
- // field kept so the name stays in the historical level_timestamp_counter shape parseFileName expects.
138
+ // field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
104
139
  function newFileName(timestamp: number): string {
105
- fileNameCounter++;
106
- return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
140
+ return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
107
141
  }
108
142
 
109
143
  type StreamFileInfo = { fileName: string; timestamp: number };
@@ -121,13 +155,21 @@ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
121
155
  function parseFileName(fileName: string): BulkFileInfo | undefined {
122
156
  if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
123
157
  const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
124
- if (parts.length !== 3) return undefined;
158
+ // Accept both the old 3-part (level_timestamp_counter) and new 4-part
159
+ // (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
160
+ if (parts.length < 3) return undefined;
125
161
  const level = parseInt(parts[0], 10);
126
162
  const timestamp = parseInt(parts[1], 10);
127
163
  if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
128
164
  return { fileName, level, timestamp };
129
165
  }
130
166
 
167
+ // A file we listed is gone now (a concurrent merge deleted it after writing its replacement). Distinct
168
+ // from a corrupt/torn file: missing => the data moved, so re-list and retry; corrupt => skip the file.
169
+ class MissingFileError extends Error { }
170
+ // Thrown out of a read build when a listed file went missing mid-load, so the build re-lists and retries.
171
+ class FilesChangedError extends Error { }
172
+
131
173
  // All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
132
174
  // Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
133
175
  export class BulkDatabaseBase<T extends { key: string }> {
@@ -160,42 +202,82 @@ export class BulkDatabaseBase<T extends { key: string }> {
160
202
  // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
161
203
  // so concurrent writers never touch the same file.
162
204
  private streamFileName: string | undefined;
163
- private streamRowsWritten = 0;
205
+ // Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
206
+ // first write — the first background merge check waits a full interval after construction.
207
+ private lastMergeCheck = Date.now();
164
208
  private getStreamFileName(): string {
209
+ // Seal (stop appending to) our current file once it's old enough, so no file is ever appended
210
+ // to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
211
+ if (this.streamFileName) {
212
+ const info = parseStreamFileName(this.streamFileName);
213
+ if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
214
+ }
165
215
  if (!this.streamFileName) {
166
216
  this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
167
217
  }
168
218
  return this.streamFileName;
169
219
  }
170
220
 
171
- // Sets an overlay entry and invalidates both that key's signal and the overlay-wide signal.
172
- private setOverlay(key: string, entry: OverlayEntry) {
173
- this.overlay.set(key, entry);
221
+ private invalidateOverlay(key: string) {
174
222
  this.deps.invalidate(key);
175
223
  this.deps.invalidate(OVERLAY_SIGNAL);
176
224
  }
177
225
 
178
- private reader = lazy(async (): Promise<BaseBulkDatabaseReader> => {
226
+ // Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
227
+ // only changes the columns it includes — the rest fall through to disk on read. A prior delete is
228
+ // reset (the key is being re-created).
229
+ private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
230
+ const existing = this.overlay.get(key);
231
+ const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
232
+ this.overlay.set(key, { time, value });
233
+ this.invalidateOverlay(key);
234
+ }
235
+
236
+ private setOverlayDeleted(key: string, time: number) {
237
+ this.overlay.set(key, { time, value: DELETED });
238
+ this.invalidateOverlay(key);
239
+ }
240
+
241
+ private reader = lazy(async (): Promise<ResolvedReader> => {
242
+ // A merge can delete a file between our directory listing and our read of it. The merge wrote the
243
+ // replacement first, so the data isn't gone — it just moved to a file our stale listing didn't
244
+ // include. So on a missing file we re-list and rebuild. Bounded; the last attempt tolerates a
245
+ // missing file (loads whatever is there — the documented inconsistent-view bug, fixed by reload).
179
246
  let start = Date.now();
180
- const [bulkFiles, streamFiles] = await Promise.all([this.listFiles(), this.listStreamFiles()]);
181
- // Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
182
- // A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
183
- // returning bad values: the write protocol always writes a new file before removing the old
184
- // ones it supersedes, so a partially-written file's data still exists in another file.
247
+ for (let attempt = 0; ; attempt++) {
248
+ try {
249
+ return await this.buildReader(start, attempt >= MAX_READ_ATTEMPTS);
250
+ } catch (e) {
251
+ if (e instanceof FilesChangedError && attempt < MAX_READ_ATTEMPTS) continue;
252
+ throw e;
253
+ }
254
+ }
255
+ });
256
+
257
+ // One read build over a directory listing. Loads every bulk file's columnar reader plus all streamed
258
+ // entries, then joins them by write-time. A corrupt/torn bulk file is skipped with a warning (its
259
+ // data lives in another file). A *missing* file (deleted by a concurrent merge) throws
260
+ // FilesChangedError so the caller re-lists — unless tolerateMissing, when we proceed without it.
261
+ private async buildReader(start: number, tolerateMissing: boolean): Promise<ResolvedReader> {
262
+ const { bulkFiles, streamFiles } = await this.listFiles();
263
+ let filesChanged = false;
185
264
  const [bulkReadersRaw, streamData] = await Promise.all([
186
265
  Promise.all(bulkFiles.map(async f => {
187
266
  try {
188
267
  return await this.loadFileReader(f.fileName);
189
268
  } catch (e) {
269
+ if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
190
270
  await this.handleUnreadableFile(f, (e as Error).message);
191
271
  return undefined;
192
272
  }
193
273
  })),
194
274
  this.loadStreamEntries(streamFiles),
195
275
  ]);
276
+ if (streamData.missing) filesChanged = true;
277
+ if (filesChanged && !tolerateMissing) throw new FilesChangedError();
278
+
196
279
  const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
197
- // Streamed entries are the newest writes, so their reader goes first (the join is newest-wins
198
- // and lets the stream's deletes tombstone keys in the older bulk readers).
280
+ // The join resolves purely by write-time, so reader order doesn't matter.
199
281
  const readers: BaseBulkDatabaseReader[] = [];
200
282
  const ordered = this.orderStreamEntries(streamData.entries);
201
283
  if (ordered.length) {
@@ -206,15 +288,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
206
288
  this.streamTimes = new Map();
207
289
  }
208
290
  readers.push(...bulkReaders);
209
- const joined = joinBulkDatabases(readers);
291
+ const joined = await joinBulkDatabases(readers);
210
292
 
211
293
  let time = Date.now() - start;
212
294
  if (time > 50) {
213
- let totalKeysSize = readers.map(x => x.columns.find(c => c.column === "key")?.byteSize || 0).reduce((a, b) => a + b, 0);
214
- console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files, ${blue(formatNumber(totalKeysSize))} keys size)`);
295
+ console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
215
296
  }
216
297
  return joined;
217
- });
298
+ }
218
299
 
219
300
  // Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
220
301
  // collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
@@ -224,7 +305,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
224
305
  private syncSetup = lazy(async () => {
225
306
  if (!isSyncSupported()) return;
226
307
  await this.reader();
227
- let recent = await syncConnect(this.name, write => this.applyRemote(write));
308
+ // onSeal: a peer is about to fold recent data; drop our current stream file so we stop appending
309
+ // to it (our next write starts a fresh one), letting the merge fold it whole.
310
+ let recent = await syncConnect(this.name, write => this.applyRemote(write), () => { this.streamFileName = undefined; });
228
311
  for (let write of recent) this.applyRemote(write);
229
312
  });
230
313
 
@@ -242,8 +325,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
242
325
  private applyRemote(write: RemoteWrite) {
243
326
  if (write.time <= this.localTime(write.key)) return;
244
327
  this.deps.batch(() => {
245
- if (write.deleted) this.setOverlay(write.key, { time: write.time, value: DELETED });
246
- else this.setOverlay(write.key, { time: write.time, value: write.value as Record<string, unknown> });
328
+ if (write.deleted) this.setOverlayDeleted(write.key, write.time);
329
+ else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
247
330
  });
248
331
  }
249
332
 
@@ -277,10 +360,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
277
360
  const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
278
361
  const framed = frameRows(stamped);
279
362
 
280
- // A batch that already exceeds the rollover limits skips tier-0 and writes a bulk file directly.
363
+ // A batch that already exceeds the limits skips the tier-0 stream and writes a bulk file directly
364
+ // (streaming thousands of rows one frame at a time would be pointless).
281
365
  if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
282
366
  await this.writeBulkFile(rows);
283
- this.resetReader();
284
367
  return;
285
368
  }
286
369
 
@@ -288,12 +371,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
288
371
  // overlay immediately — no reader reset.
289
372
  const storage = await this.storage();
290
373
  await storage.append(this.getStreamFileName(), framed);
291
- this.streamRowsWritten += entries.length;
292
374
  this.deps.batch(() => {
293
- for (const { time, row } of stamped) this.setOverlay(row.key as string, { time, value: row });
375
+ for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
294
376
  });
295
377
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
296
- await this.maybeRolloverStream();
378
+ void this.maybeMerge();
297
379
  }
298
380
 
299
381
  public async delete(key: string): Promise<void> {
@@ -306,46 +388,101 @@ export class BulkDatabaseBase<T extends { key: string }> {
306
388
  const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
307
389
  const storage = await this.storage();
308
390
  await storage.append(this.getStreamFileName(), frameDeletes(stamped));
309
- this.streamRowsWritten += keys.length;
310
391
  this.deps.batch(() => {
311
- for (const { time, key } of stamped) this.setOverlay(key, { time, value: DELETED });
392
+ for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
312
393
  });
313
394
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
314
- await this.maybeRolloverStream();
395
+ void this.maybeMerge();
315
396
  }
316
397
 
317
- // Writes the rows as one or more columnar bulk files (buildFileBuffer splits a too-large batch by
318
- // row range so no single file approaches the Buffer size limit), all sharing one timestamp since
319
- // they're one write with disjoint keys. Then, if enough files have accumulated, runs bounded merge
320
- // passes until nothing more can be consolidated.
321
- private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
322
- const storage = await this.storage();
323
- const timestamp = nextFileTime();
324
- for (const buffer of buildFileBuffer(rows)) {
325
- await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
326
- }
327
- if ((await this.listFiles()).length >= MERGE_FILE_COUNT) {
328
- while (await this.mergeFiles() > 0) { /* keep merging until no run can be consolidated */ }
398
+ public async update(entry: Partial<T> & { key: string }): Promise<void> {
399
+ return this.updateBatch([entry]);
400
+ }
401
+
402
+ // Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
403
+ // key. Partial fields merge onto the existing row (unset columns fall through to the current value);
404
+ // an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
405
+ public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
406
+ if (!entries.length) return;
407
+ void this.syncSetup();
408
+ const reader = await this.reader();
409
+ const diskKeys = new Set(reader.keys);
410
+ const present: T[] = [];
411
+ for (const entry of entries) {
412
+ const overlayEntry = this.overlay.get(entry.key);
413
+ const exists = overlayEntry ? overlayEntry.value !== DELETED : diskKeys.has(entry.key);
414
+ if (!exists) {
415
+ console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
416
+ continue;
417
+ }
418
+ present.push(entry as unknown as T);
329
419
  }
420
+ if (present.length) await this.writeBatch(present);
330
421
  }
331
422
 
332
- private async listStreamFiles(): Promise<StreamFileInfo[]> {
423
+ // Lists every bulk + stream file currently on disk (no manifest — every file is part of the
424
+ // database). Bulk newest-first, streams oldest-first. Duplicate data (from a crashed/raced merge)
425
+ // is harmless: reads resolve by write-time and a later merge with enough duplication removes it.
426
+ private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
333
427
  const storage = await this.storage();
334
428
  const names = await storage.getKeys();
335
- const files = names.flatMap(n => {
336
- const parsed = parseStreamFileName(n);
337
- return parsed && [parsed] || [];
429
+ const bulkFiles: BulkFileInfo[] = [];
430
+ const streamFiles: StreamFileInfo[] = [];
431
+ for (const n of names) {
432
+ if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
433
+ else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
434
+ }
435
+ // Newest-first by timestamp; ties broken by file name for determinism.
436
+ bulkFiles.sort((a, b) => {
437
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
438
+ return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
338
439
  });
339
- sort(files, f => f.timestamp);
340
- return files;
440
+ sort(streamFiles, f => f.timestamp);
441
+ return { bulkFiles, streamFiles };
442
+ }
443
+
444
+ // Writes `rows` directly as bulk file(s), stamped with the current time as their write-time (the rows
445
+ // are being written now). Used by the large-batch write path: no manifest, just new files on disk.
446
+ // The rows carry time=now, so the join orders them correctly against any older stream entry for the
447
+ // same key (newer time wins) — no clobber. A later testMerge consolidates them.
448
+ private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
449
+ const storage = await this.storage();
450
+ const timestamp = nextFileTime();
451
+ const now = Date.now();
452
+ const times = rows.map(() => now);
453
+ for (const buffer of buildFileBuffer(rows, times)) {
454
+ const name = newFileName(timestamp);
455
+ await storage.set(name, encodeCompressedBlocks(buffer));
456
+ }
457
+ this.resetReader();
458
+ void this.maybeMerge();
341
459
  }
342
460
 
343
461
  // Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
344
- // unique timestamp + originating file) so callers can order writes globally across files.
345
- private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
346
- if (!streamFiles.length) return { entries: [], totalBytes: 0 };
462
+ // unique timestamp + originating file) so callers can order writes globally across files, the
463
+ // prefix size we read per file (so a merge can verify nothing was appended before deleting it), and
464
+ // whether any listed file was missing (so a read can re-list and retry — a merge deleted it).
465
+ private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
466
+ const sizes = new Map<string, number>();
467
+ if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
347
468
  const storage = await this.storage();
348
- const buffers = await Promise.all(streamFiles.map(f => storage.get(f.fileName)));
469
+ let missing = false;
470
+ // Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
471
+ // concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
472
+ // prefix is tolerant — parseStream stops at the last complete frame, the file stays valid, and a
473
+ // later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
474
+ const buffers = await Promise.all(streamFiles.map(async f => {
475
+ try {
476
+ const info = await storage.getInfo(f.fileName);
477
+ if (!info) { missing = true; return undefined; }
478
+ sizes.set(f.fileName, info.size);
479
+ if (info.size === 0) return undefined;
480
+ return await storage.getRange(f.fileName, { start: 0, end: info.size });
481
+ } catch {
482
+ missing = true;
483
+ return undefined;
484
+ }
485
+ }));
349
486
  const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
350
487
  let totalBytes = 0;
351
488
  for (let i = 0; i < streamFiles.length; i++) {
@@ -360,7 +497,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
360
497
  entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
361
498
  }
362
499
  }
363
- return { entries, totalBytes };
500
+ return { entries, totalBytes, missing, sizes };
364
501
  }
365
502
 
366
503
  // Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
@@ -372,88 +509,77 @@ export class BulkDatabaseBase<T extends { key: string }> {
372
509
  return entries.map(e => e.entry);
373
510
  }
374
511
 
375
- private async maybeRolloverStream(): Promise<void> {
376
- const streamFiles = await this.listStreamFiles();
377
- const storage = await this.storage();
378
- let totalBytes = 0;
379
- for (const f of streamFiles) {
380
- const info = await storage.getInfo(f.fileName);
381
- totalBytes += info?.size || 0;
382
- }
383
- if (streamFiles.length > ROLLOVER_FILES || totalBytes > ROLLOVER_BYTES || this.streamRowsWritten > ROLLOVER_ROWS) {
384
- await this.rolloverStream(streamFiles);
385
- }
386
- }
387
-
388
- // Combine all tier-0 stream files into a single columnar bulk file (newest-wins per key, deletes
389
- // applied), delete the consumed stream files, and re-persist surviving tombstones to a fresh
390
- // stream file so deletes of keys that live in older bulk files are not lost.
391
- private async rolloverStream(streamFiles: StreamFileInfo[]): Promise<void> {
392
- const { entries } = await this.loadStreamEntries(streamFiles);
393
- const ordered = this.orderStreamEntries(entries);
394
- const byKey = new Map<string, Record<string, unknown>>();
395
- const deleted = new Map<string, number>();
396
- for (const e of ordered) {
397
- if (e.deletedKey !== undefined) {
398
- byKey.delete(e.deletedKey);
399
- deleted.set(e.deletedKey, e.time);
400
- } else if (e.row) {
401
- let key = e.row.key as string;
402
- byKey.set(key, e.row);
403
- deleted.delete(key);
404
- }
512
+ // Throttled, fire-and-forget after writes: run a background merge check at most once per interval.
513
+ private async maybeMerge(): Promise<void> {
514
+ const now = Date.now();
515
+ if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
516
+ this.lastMergeCheck = now;
517
+ try {
518
+ await this.tryMergeNow();
519
+ } catch (e) {
520
+ console.warn(`${this.name}: background merge failed: ${(e as Error).message}`);
521
+ }
522
+ }
523
+
524
+ // Runs one merge pass now (the same one maybeMerge runs on a timer). Returns whether it merged
525
+ // anything, and whether it bailed because another tab/process holds the merge lock so a caller
526
+ // (e.g. a 30-minute scheduler) can tell "nothing to do" from "someone else is doing it".
527
+ public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
528
+ if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
529
+ try {
530
+ return { merged: await this.testMerge(), lockFailed: false };
531
+ } finally {
532
+ releaseMergeLock(this.name, writerId);
405
533
  }
406
- if (byKey.size) await this.writeBulkFile([...byKey.values()]);
407
- const storage = await this.storage();
408
- // Persist surviving tombstones (keeping their original timestamps) to a FRESH stream file
409
- // before removing the consumed files, so a crash in between can't drop the deletes. The window
410
- // is at worst redundant (deletes present in both old and new files), never missing.
411
- this.streamFileName = undefined;
412
- this.streamRowsWritten = 0;
413
- if (deleted.size) {
414
- await storage.append(this.getStreamFileName(), frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
415
- }
416
- for (const f of streamFiles) await storage.remove(f.fileName);
417
- this.resetReader();
418
534
  }
419
535
 
420
- // Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
421
- // more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
422
- // so it never loads the whole collection into memory a multi-GB collection settles into several
423
- // capped files (sealed files are left as-is) rather than one giant one.
536
+ // Full compaction: fold + dedup everything into key-sorted, ~256MB files. Reads the whole collection
537
+ // into memory (the accepted soft bound), so it's an explicit, occasional call. Deletes consumed bulk
538
+ // files and any stream file it's safe to (aged, or sealed-and-stable).
424
539
  public async compact(): Promise<void> {
425
- let merged = false;
426
- while (await this.mergeFiles() > 0) merged = true;
427
- if (merged) this.resetReader();
540
+ if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
541
+ try {
542
+ syncBroadcastSeal(this.name);
543
+ this.streamFileName = undefined;
544
+ const { bulkFiles, streamFiles } = await this.listFiles();
545
+ if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles);
546
+ } finally {
547
+ releaseMergeLock(this.name, writerId);
548
+ }
428
549
  }
429
550
 
430
- private async listFiles(): Promise<BulkFileInfo[]> {
431
- const storage = await this.storage();
432
- const names = await storage.getKeys();
433
- const files = names.flatMap(n => {
434
- const parsed = parseFileName(n);
435
- return parsed && [parsed] || [];
551
+ // The unified merge entry point: rewrite everything overlapping [timeLo, timeHi] into fresh
552
+ // key-sorted ~256MB bulk file(s). Selects bulk files by their header time range and stream files by
553
+ // their (creation .. seal-age) window. If the range reaches the present, first asks peers to seal so
554
+ // recent stream data is complete. Callers: testMerge (recent / key-group ranges); external callers
555
+ // can pass any range — older data just produces older files.
556
+ public async merge(timeLo: number, timeHi: number): Promise<void> {
557
+ if (timeHi >= Date.now()) { syncBroadcastSeal(this.name); this.streamFileName = undefined; }
558
+ const { bulkFiles, streamFiles } = await this.listFiles();
559
+ const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
560
+ const selBulk = bulkFiles.filter((f, i) => {
561
+ const h = headers[i];
562
+ if (!h) return false;
563
+ // Old files (no recorded time range) only belong to a merge that reaches back to the start.
564
+ if (!h.maxTime && !h.minTime) return timeLo <= 0;
565
+ return h.minTime <= timeHi && h.maxTime >= timeLo;
436
566
  });
437
- // Newest-first by timestamp; ties broken by file name (descending) for a deterministic order.
438
- // A merged file inherits the newest timestamp of the run it replaced, so it lands exactly where
439
- // that run was keeping newest-wins correct without any level bookkeeping.
440
- files.sort((a, b) => {
441
- if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
442
- return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
443
- });
444
- return files;
567
+ const selStream = streamFiles.filter(f =>
568
+ f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
569
+ if (selBulk.length + selStream.length < 2) return;
570
+ await this.mergeFileSet(selBulk, selStream);
445
571
  }
446
572
 
447
- private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
573
+ // Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
574
+ // file a merge deleted (re-list and retry / skip) from a corrupt one (handle as unreadable).
575
+ private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
448
576
  const storage = await this.storage();
449
577
  const info = await storage.getInfo(fileName);
450
- if (!info) return undefined;
578
+ if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
451
579
  const rawGetRange: GetRange = async (start, end) => {
452
580
  if (end <= start) return EMPTY_BUFFER;
453
581
  const buf = await storage.getRange(fileName, { start, end });
454
- if (!buf) {
455
- throw new Error(`Expected range [${start}, ${end}) of ${fileName}, file was missing`);
456
- }
582
+ if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
457
583
  return buf;
458
584
  };
459
585
  return { rawGetRange, size: info.size };
@@ -461,9 +587,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
461
587
 
462
588
  private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
463
589
  const raw = await this.makeRawGetRange(fileName);
464
- if (!raw) {
465
- throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
466
- }
467
590
  const fileId = nullJoin(this.name, fileName);
468
591
  // Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
469
592
  // decompressing version (same interface) and read the logical (uncompressed) size from its
@@ -472,13 +595,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
472
595
  return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
473
596
  }
474
597
 
598
+ // Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
599
+ // planning. Returns undefined for a missing/corrupt file so the planner just leaves it out.
600
+ private async readBulkHeader(fileName: string): Promise<BulkHeaderInfo | undefined> {
601
+ try {
602
+ const raw = await this.makeRawGetRange(fileName);
603
+ const fileId = nullJoin(this.name, fileName);
604
+ const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
605
+ return await loadBulkHeader(opened.getRange, opened.uncompressedSize);
606
+ } catch {
607
+ return undefined;
608
+ }
609
+ }
610
+
475
611
  // Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
476
612
  // Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
477
613
  // missing or unreadable so the planner simply leaves it out of any merge.
478
614
  private async fileLogicalSize(fileName: string): Promise<number | undefined> {
479
615
  try {
480
616
  const raw = await this.makeRawGetRange(fileName);
481
- if (!raw) return undefined;
482
617
  const fileId = nullJoin(this.name, fileName);
483
618
  const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
484
619
  return opened.uncompressedSize;
@@ -507,92 +642,256 @@ export class BulkDatabaseBase<T extends { key: string }> {
507
642
  console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
508
643
  }
509
644
 
510
- // Merges exactly the files it's given (already newest-first), newest-wins per key, writing the
511
- // result with `timestamp` so the new file takes the slot of the run it replaced, then deletes the
512
- // consumed files. The caller is responsible for keeping the input under MERGE_MAX_BYTES this
513
- // function blindly reads it all into memory.
514
- private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<void> {
515
- const storage = await this.storage();
516
- const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
517
-
518
- const seen = new Set<string>();
519
- const mergedRows: Record<string, unknown>[] = [];
520
- for (const reader of readers) {
521
- const colData: Record<string, unknown[]> = {};
645
+ // Resolves a set of readers (stream + bulk) by ACTUAL write-time into merged rows + per-row times,
646
+ // plus the surviving tombstones (keys whose newest event is a delete). For each key/column, the
647
+ // value with the newest write-time across readers wins (non-ABSENT); the row's time is the newest of
648
+ // those. A key is deleted iff its newest delete is newer than its newest set. This is the same
649
+ // time-resolution reads use, captured so a merge can write the result back as bulk + a carry stream.
650
+ private async resolveReaders(readers: BaseBulkDatabaseReader[]): Promise<{ rows: Record<string, unknown>[]; times: number[]; deletes: Map<string, number> }> {
651
+ const loaded = await Promise.all(readers.map(async reader => {
652
+ const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
522
653
  for (const col of reader.columns) {
523
- colData[col.column] = (await reader.getColumn(col.column)).map(r => r.value);
654
+ if (col.column === KEY_COLUMN) continue;
655
+ const entries = await reader.getColumn(col.column);
656
+ cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
657
+ }
658
+ return { keyTimes: reader.keyTimes, deleteTimes: reader.deleteTimes, cols };
659
+ }));
660
+
661
+ const deleteTime = new Map<string, number>();
662
+ for (const l of loaded) {
663
+ if (!l.deleteTimes) continue;
664
+ for (const [k, t] of l.deleteTimes) deleteTime.set(k, Math.max(deleteTime.get(k) ?? -Infinity, t));
665
+ }
666
+ const keyTime = new Map<string, number>();
667
+ for (const l of loaded) {
668
+ for (const [k, t] of l.keyTimes) keyTime.set(k, Math.max(keyTime.get(k) ?? -Infinity, t));
669
+ }
670
+ const allCols = new Set<string>();
671
+ for (const l of loaded) for (const c of l.cols.keys()) allCols.add(c);
672
+
673
+ const rows: Record<string, unknown>[] = [];
674
+ const times: number[] = [];
675
+ const deletes = new Map<string, number>();
676
+ const allKeys = new Set<string>([...keyTime.keys(), ...deleteTime.keys()]);
677
+ for (const key of allKeys) {
678
+ const setT = keyTime.get(key) ?? -Infinity;
679
+ const delT = deleteTime.get(key) ?? -Infinity;
680
+ if (setT <= delT) {
681
+ // The newest event for this key is a delete — carry the tombstone forward so it keeps
682
+ // suppressing any older set living in a file outside this merge.
683
+ if (delT > -Infinity) deletes.set(key, delT);
684
+ continue;
524
685
  }
525
- for (let i = 0; i < reader.keys.length; i++) {
526
- const key = reader.keys[i];
527
- if (seen.has(key)) continue;
528
- seen.add(key);
529
- const row: Record<string, unknown> = {};
530
- for (const col of reader.columns) {
531
- row[col.column] = colData[col.column][i];
686
+ const row: Record<string, unknown> = { [KEY_COLUMN]: key };
687
+ let rowTime = setT;
688
+ for (const col of allCols) {
689
+ let bestTime = -Infinity;
690
+ let bestVal: unknown;
691
+ let found = false;
692
+ for (const l of loaded) {
693
+ const cell = l.cols.get(col)?.get(key);
694
+ if (!cell || cell.value === ABSENT) continue;
695
+ if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
532
696
  }
533
- mergedRows.push(row);
697
+ if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
534
698
  }
699
+ rows.push(row);
700
+ times.push(rowTime === -Infinity ? 0 : rowTime);
535
701
  }
702
+ return { rows, times, deletes };
703
+ }
704
+
705
+ // The one merge primitive. Reads the given bulk + stream files (skipping any that vanished or won't
706
+ // parse — their data lives elsewhere), resolves them by write-time, writes the result back as fresh
707
+ // key-sorted ~256MB bulk file(s) plus a carry stream for surviving tombstones, THEN deletes the
708
+ // inputs it consumed. Output is always written before any delete, so a crash leaves duplicates (next
709
+ // merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
710
+ // only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
711
+ // didn't change while we read it. Returns whether it produced anything.
712
+ private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]): Promise<boolean> {
713
+ const storage = await this.storage();
714
+ const timestamp = nextFileTime();
715
+ const now = Date.now();
716
+
717
+ const consumedBulk: BulkFileInfo[] = [];
718
+ const bulkReaders: BaseBulkDatabaseReader[] = [];
719
+ await Promise.all(bulkFiles.map(async f => {
720
+ try {
721
+ const r = await this.loadFileReader(f.fileName);
722
+ bulkReaders.push(r);
723
+ consumedBulk.push(f); // only files we actually read are safe to delete afterwards
724
+ } catch { /* missing or corrupt — skip; its data lives in another file */ }
725
+ }));
536
726
 
537
- // The input is under the cap, so buildFileBuffer almost always returns a single buffer; the loop
538
- // is only here to stay correct if a merge's deduped output still happens to exceed the split size.
539
- for (const buffer of buildFileBuffer(mergedRows)) {
540
- await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
727
+ const streamData = await this.loadStreamEntries(streamFiles);
728
+ const ordered = this.orderStreamEntries(streamData.entries);
729
+ const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
730
+
731
+ const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
732
+ if (!readers.length) return false;
733
+
734
+ const { rows, times, deletes } = await this.resolveReaders(readers);
735
+
736
+ // Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
737
+ const newNames: string[] = [];
738
+ if (rows.length) {
739
+ for (const buffer of buildFileBuffer(rows, times)) {
740
+ const name = newFileName(timestamp);
741
+ await storage.set(name, encodeCompressedBlocks(buffer));
742
+ newNames.push(name);
743
+ }
541
744
  }
542
- for (const f of files) {
543
- await storage.remove(f.fileName);
745
+ if (deletes.size) {
746
+ const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
747
+ await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
544
748
  }
749
+
750
+ const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
751
+ for (const f of consumedBulk) await remove(f.fileName);
752
+ for (const f of streamFiles) {
753
+ if (await this.canDeleteStream(f, now, streamData.sizes)) await remove(f.fileName);
754
+ }
755
+
756
+ this.resetReader();
757
+ return newNames.length > 0 || deletes.size > 0;
758
+ }
759
+
760
+ // A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
761
+ // age (its writer has provably started a fresh file), OR cross-tab sync is active (so the seal we
762
+ // broadcast reached peers) and its size hasn't changed since we read it (nothing was appended during
763
+ // the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
764
+ // and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
765
+ // data, but the aged check also rules out the rare sparse-offset append race.)
766
+ private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>): Promise<boolean> {
767
+ if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
768
+ if (!isSyncSupported()) return false;
769
+ const readSize = sizes.get(f.fileName);
770
+ if (readSize === undefined) return false;
771
+ let info;
772
+ try { info = await (await this.storage()).getInfo(f.fileName); } catch { return false; }
773
+ return !!info && info.size === readSize;
545
774
  }
546
775
 
547
- // The cap-aware merge planner. Walks the files newest-first and merges contiguous runs of
548
- // non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
549
- // loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
550
- // breaks the run, as does an unreadable file. Because each run is contiguous in the newest-first
551
- // order and its merged file keeps the run's newest timestamp, newest-wins ordering is preserved
552
- // with no inversions. Returns the number of runs merged (0 means nothing left to consolidate).
553
- private async mergeFiles(): Promise<number> {
554
- const files = await this.listFiles();
555
- const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
776
+ // The merge policy. Up to two passes:
777
+ // 1) Consolidate recent fragmentation: take the newest files up to ~FIRST_MERGE_BYTES and, if they
778
+ // number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs, merge them
779
+ // into one file. Seals first so recent stream data is complete; in Node (no cross-tab seal) only
780
+ // aged streams are folded, so we never re-fold the same un-deletable stream forever.
781
+ // 2) Key-stratify: sort all keys, walk them in ~KEY_GROUP_BYTES groups, and rewrite the single group
782
+ // whose fraction of duplicate (multi-file) keys is highest above DUP_THRESHOLD — merging every
783
+ // bulk file overlapping that key range. Over time this sorts the data into key-disjoint files.
784
+ // Returns whether either pass merged anything.
785
+ private async testMerge(): Promise<boolean> {
786
+ let merged = false;
556
787
 
557
- const batches: BulkFileInfo[][] = [];
558
- let batch: BulkFileInfo[] = [];
559
- let batchBytes = 0;
560
- const flush = () => {
561
- // A run of one file is pointless to "merge" — only consolidate when there are at least two.
562
- if (batch.length >= 2) batches.push(batch);
563
- batch = [];
564
- batchBytes = 0;
565
- };
566
- for (let i = 0; i < files.length; i++) {
567
- const size = sizes[i];
568
- if (size === undefined || size >= MERGE_MIN_BYTES) {
569
- flush();
570
- continue;
788
+ // ── Pass 1: consolidate recent files. ──
789
+ // Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
790
+ // actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
791
+ // no benefit, since canDeleteStream there only deletes aged files anyway.
792
+ const foldRecentStreams = isSyncSupported(); // see canDeleteStream: else we'd re-fold forever
793
+ if (foldRecentStreams) {
794
+ syncBroadcastSeal(this.name);
795
+ this.streamFileName = undefined; // seal our own current stream so its recent data is complete
796
+ }
797
+ {
798
+ const { bulkFiles, streamFiles } = await this.listFiles();
799
+ const bulkMeta = await Promise.all(bulkFiles.map(async f => {
800
+ const [size, header] = await Promise.all([this.fileLogicalSize(f.fileName), this.readBulkHeader(f.fileName)]);
801
+ return { kind: "bulk" as const, file: f, bytes: size ?? 0, time: header?.maxTime || f.timestamp };
802
+ }));
803
+ const streamMeta: { kind: "stream"; file: StreamFileInfo; bytes: number; time: number }[] = [];
804
+ for (const f of streamFiles) {
805
+ const aged = Date.now() - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs;
806
+ if (!foldRecentStreams && !aged) continue;
807
+ let bytes = 0;
808
+ try { const info = await (await this.storage()).getInfo(f.fileName); bytes = info?.size ?? 0; } catch { bytes = 0; }
809
+ streamMeta.push({ kind: "stream", file: f, bytes, time: f.timestamp });
810
+ }
811
+ const items = [...bulkMeta, ...streamMeta].sort((a, b) => b.time - a.time);
812
+ const recent: typeof items = [];
813
+ let recentBytes = 0;
814
+ for (const it of items) {
815
+ recent.push(it);
816
+ recentBytes += it.bytes;
817
+ if (recentBytes >= FIRST_MERGE_BYTES) break;
818
+ }
819
+ const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
820
+ const triggered = recent.length >= 2
821
+ && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs);
822
+ if (triggered) {
823
+ const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
824
+ const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
825
+ if (await this.mergeFileSet(rb, rs)) merged = true;
571
826
  }
572
- if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
573
- batch.push(files[i]);
574
- batchBytes += size;
575
827
  }
576
- flush();
577
828
 
578
- // batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
579
- for (const runFiles of batches) {
580
- await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
829
+ // ── Pass 2: key-stratify the bulk files to remove duplication. ──
830
+ {
831
+ const { bulkFiles } = await this.listFiles();
832
+ if (bulkFiles.length >= 2) {
833
+ const infos = await Promise.all(bulkFiles.map(async f => {
834
+ try {
835
+ const reader = await this.loadFileReader(f.fileName);
836
+ const keys = reader.keys;
837
+ let min = keys[0], max = keys[0];
838
+ for (const k of keys) { if (k < min) min = k; if (k > max) max = k; }
839
+ return { file: f, keys, bytes: reader.totalBytes, min, max };
840
+ } catch {
841
+ return { file: f, keys: [] as string[], bytes: 0, min: undefined as string | undefined, max: undefined as string | undefined };
842
+ }
843
+ }));
844
+ const usable = infos.filter(i => i.keys.length > 0);
845
+ const keyCount = new Map<string, number>();
846
+ let totalSlots = 0, totalBytes = 0;
847
+ for (const i of usable) {
848
+ totalBytes += i.bytes;
849
+ for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
850
+ }
851
+ if (totalSlots > 0) {
852
+ const bytesPerSlot = totalBytes / totalSlots;
853
+ const sortedKeys = [...keyCount.keys()].sort();
854
+ // Walk sorted keys forming ~KEY_GROUP_BYTES groups; remember the group with the highest
855
+ // duplicate fraction over the threshold (the most benefit), then merge its files.
856
+ let best: { lo: string; hi: string; dup: number } | undefined;
857
+ let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
858
+ for (let i = 0; i < sortedKeys.length; i++) {
859
+ const c = keyCount.get(sortedKeys[i])!;
860
+ gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
861
+ if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
862
+ const dup = (gSlots - gUnique) / gSlots;
863
+ if (dup > DUP_THRESHOLD && (!best || dup > best.dup)) best = { lo: sortedKeys[gStart], hi: sortedKeys[i], dup };
864
+ gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
865
+ }
866
+ }
867
+ if (best) {
868
+ const lo = best.lo, hi = best.hi;
869
+ const groupFiles = usable
870
+ .filter(i => i.min !== undefined && i.max !== undefined && i.min <= hi && i.max >= lo)
871
+ .map(i => i.file);
872
+ if (groupFiles.length >= 2 && await this.mergeFileSet(groupFiles, [])) merged = true;
873
+ }
874
+ }
875
+ }
581
876
  }
582
- return batches.length;
877
+
878
+ return merged;
583
879
  }
584
880
 
585
- private formatInfo(reader: BaseBulkDatabaseReader): string {
881
+ private formatInfo(reader: ResolvedReader): string {
586
882
  return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
587
883
  }
588
884
 
589
- // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
885
+ // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
886
+ // overlay entry that doesn't include this column leaves the base (disk) value in place — a partial
887
+ // write/update only overrides the columns it set; everything else falls through.
590
888
  private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
591
889
  if (this.overlay.size === 0) return base;
592
890
  const map = new Map(base.map(e => [e.key, e.value]));
593
891
  for (const [key, entry] of this.overlay) {
594
- if (entry.value === DELETED) map.delete(key);
595
- else map.set(key, entry.value[column]);
892
+ if (entry.value === DELETED) { map.delete(key); continue; }
893
+ if (column in entry.value) map.set(key, entry.value[column]);
894
+ else if (!map.has(key)) map.set(key, undefined);
596
895
  }
597
896
  return [...map].map(([key, value]) => ({ key, value }));
598
897
  }
@@ -604,7 +903,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
604
903
  const entry = this.overlay.get(key);
605
904
  if (entry !== undefined) {
606
905
  if (entry.value === DELETED) return undefined;
607
- return entry.value[String(column)] as T[Column];
906
+ if (String(column) in entry.value) return entry.value[String(column)] as T[Column];
907
+ // column not set in the overlay entry — fall through to disk
608
908
  }
609
909
  let time = Date.now();
610
910
  let reader = await this.reader();
@@ -689,7 +989,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
689
989
  let entry = this.overlay.get(key);
690
990
  if (entry !== undefined) {
691
991
  if (entry.value === DELETED) return undefined;
692
- return entry.value[col] as T[Column];
992
+ if (col in entry.value) return entry.value[col] as T[Column];
993
+ // column not set in the overlay entry — fall through to the base field cache
693
994
  }
694
995
  let cacheKey = nullJoin(col, key);
695
996
  if (!this.baseFields.has(cacheKey)) {
@@ -730,24 +1031,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
730
1031
  }
731
1032
  }
732
1033
 
733
- // Lowest indexes are read first (newest-wins). A reader's deletedKeys tombstone a key in all older
734
- // readers; the newest reader that has a key live wins.
735
- function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabaseReader {
736
- const keySets = databases.map(db => new Set(db.keys));
737
- const deleted = new Set<string>();
1034
+ // The merged, time-resolved view over all readers. getColumn/getSingleField return plain resolved
1035
+ // values (not {value,time}); the base layers the overlay on top of these.
1036
+ type ResolvedReader = {
1037
+ rowCount: number;
1038
+ totalBytes: number;
1039
+ keys: string[];
1040
+ columns: { column: string; byteSize: number }[];
1041
+ getColumn: (column: string) => Promise<{ key: string; value: unknown }[]>;
1042
+ getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
1043
+ };
1044
+
1045
+ // Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
1046
+ // - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
1047
+ // a reader that never set the column for that key falls through to an older reader.
1048
+ // - a key is live iff its newest write is newer than its newest delete; per column, the value is
1049
+ // suppressed if a delete is newer than that column's newest set.
1050
+ // No reliance on file order or partitioning — time is the only thing that decides.
1051
+ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
1052
+ const deleteTime = new Map<string, number>();
738
1053
  for (const db of databases) {
739
- if (db.deletedKeys) for (const key of db.deletedKeys) deleted.add(key);
1054
+ if (!db.deleteTimes) continue;
1055
+ for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
740
1056
  }
741
-
742
- const keys: string[] = [];
743
- const keySeen = new Set<string>();
1057
+ const keyTime = new Map<string, number>();
744
1058
  for (const db of databases) {
745
- for (const key of db.keys) {
746
- if (keySeen.has(key) || deleted.has(key)) continue;
747
- keySeen.add(key);
748
- keys.push(key);
749
- }
1059
+ for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
750
1060
  }
1061
+ const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
1062
+ // Live keys: newest write strictly newer than newest delete.
1063
+ const keys: string[] = [];
1064
+ for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
1065
+
751
1066
  const columns: { column: string; byteSize: number }[] = [];
752
1067
  const columnByName = new Map<string, { column: string; byteSize: number }>();
753
1068
  for (const db of databases) {
@@ -761,37 +1076,41 @@ function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabas
761
1076
  existing.byteSize += col.byteSize;
762
1077
  }
763
1078
  }
1079
+
764
1080
  return {
765
1081
  totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
766
1082
  rowCount: keys.length,
767
1083
  keys,
768
1084
  columns,
769
1085
  async getColumn(column) {
770
- const result: { key: string; value: unknown }[] = [];
771
- const taken = new Set<string>();
772
- for (const db of databases) {
773
- // NOTE: This is annoying logic that's needed so that if the column is removed and you write to it, it will clobber the old value. Otherwise, if we just start making something undefined, it might not clobber the old value because the column wouldn't exist. Ugh...
774
- let values: unknown[] | undefined;
775
- if (db.columns.some(c => c.column === column)) {
776
- values = (await db.getColumn(column)).map(r => r.value);
1086
+ const perReader = await Promise.all(databases.map(async db => {
1087
+ if (!db.columns.some(c => c.column === column)) return undefined;
1088
+ const entries = await db.getColumn(column);
1089
+ return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
1090
+ }));
1091
+ return keys.map(key => {
1092
+ let bestTime = -Infinity;
1093
+ let bestVal: unknown;
1094
+ let found = false;
1095
+ for (const m of perReader) {
1096
+ const cell = m && m.get(key);
1097
+ if (!cell || cell.value === ABSENT) continue;
1098
+ if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
777
1099
  }
778
- for (let i = 0; i < db.keys.length; i++) {
779
- const key = db.keys[i];
780
- if (taken.has(key) || deleted.has(key)) continue;
781
- taken.add(key);
782
- result.push({ key, value: values && values[i] });
783
- }
784
- }
785
- return result;
1100
+ return { key, value: (found && bestTime > delOf(key)) ? bestVal : undefined };
1101
+ });
786
1102
  },
787
1103
  async getSingleField(key, column) {
788
- if (deleted.has(key)) return undefined;
789
- for (let i = 0; i < databases.length; i++) {
790
- if (!keySets[i].has(key)) continue;
791
- if (!databases[i].columns.some(c => c.column === column)) return undefined;
792
- return await databases[i].getSingleField(key, column);
1104
+ let bestTime = -Infinity;
1105
+ let bestVal: unknown;
1106
+ let found = false;
1107
+ for (const db of databases) {
1108
+ if (!db.columns.some(c => c.column === column)) continue;
1109
+ const r = await db.getSingleField(key, column);
1110
+ if (r === ABSENT) continue;
1111
+ if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
793
1112
  }
794
- return undefined;
1113
+ return (found && bestTime > delOf(key)) ? bestVal : undefined;
795
1114
  },
796
1115
  };
797
1116
  }