sliftutils 1.2.39 → 1.3.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.
@@ -0,0 +1,797 @@
1
+ import { sort } from "socket-function/src/misc";
2
+ import { getTimeUnique } from "socket-function/src/bits";
3
+ import { BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, loadBulkDatabase } from "./BulkDatabaseFormat";
4
+ 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, isSyncSupported, RemoteWrite } from "./syncClient";
10
+ import type { FileStorage } from "../FileFolderAPI";
11
+
12
+ // BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
13
+ // folder rather than sharing bulkDatabases/.
14
+ const BULK_ROOT_FOLDER = "bulkDatabases2";
15
+ 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
+ const ROLLOVER_ROWS = 5000;
32
+ const ROLLOVER_BYTES = 5 * 1024 * 1024;
33
+ const ROLLOVER_FILES = 100;
34
+
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.
38
+ const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
39
+
40
+ // Marks a key as deleted in the in-memory overlay.
41
+ const DELETED = Symbol("deleted");
42
+ // Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
43
+ // remote write only overrides a key if it's newer than what we already have.
44
+ type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
45
+
46
+ // Composite cache/signal keys join two arbitrary strings with a NUL separator (which can't occur in
47
+ // the inputs). NUL is built from a char code so an actual NUL byte never appears in this source file
48
+ // (which would otherwise make tools treat it as binary).
49
+ const NULL = String.fromCharCode(0);
50
+ function nullJoin(a: string, b: string): string {
51
+ return a + NULL + b;
52
+ }
53
+
54
+ // A tiny reactivity seam so this file has zero dependency on mobx (or any specific UI framework). The
55
+ // reactive in-memory state (the overlay map + the load/reset lifecycle) is plain; whenever it's read
56
+ // we "observe" a signal, and whenever it changes we "invalidate" that signal. A consumer that wants
57
+ // reactivity (e.g. the mobx subclass) supplies a ReactiveDeps that maps each signal string onto its
58
+ // own framework's dependency tracking; a consumer that doesn't can pass noopReactiveDeps.
59
+ export interface ReactiveDeps {
60
+ // Register `signal` as a dependency of whatever reactive context is currently reading.
61
+ observe(signal: string): void;
62
+ // Notify any context that observed `signal` that it changed.
63
+ invalidate(signal: string): void;
64
+ // Run a group of mutations + invalidations as one batch, so observers re-run at most once.
65
+ batch(fn: () => void): void;
66
+ }
67
+
68
+ // A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
69
+ // re-renders. Use this when you don't need a UI to react to writes.
70
+ export const noopReactiveDeps: ReactiveDeps = {
71
+ observe() { },
72
+ invalidate() { },
73
+ batch(fn) { fn(); },
74
+ };
75
+
76
+ // Provides the FileStorage for a given path (the caller decides where data physically lives, so this
77
+ // file needn't know about getFileStorageNested2 / the browser-vs-node storage details).
78
+ export type StorageFactory = (path: string) => Promise<FileStorage>;
79
+
80
+ // The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
81
+ // reader resets or a base column/field finishes loading. The overlay's per-key signal is the key
82
+ // itself (a point read observes just its key), plus one overlay-wide signal that whole-column reads
83
+ // observe so they recompute on any overlay change. The NUL prefix keeps the two special signals
84
+ // from ever colliding with a real data key.
85
+ const LOAD_SIGNAL = NULL + "load";
86
+ const OVERLAY_SIGNAL = NULL + "overlay";
87
+
88
+ let fileNameCounter = 0;
89
+
90
+ type BulkFileInfo = { fileName: string; level: number; timestamp: number };
91
+
92
+ let lastFileTime = 0;
93
+ // A strictly-increasing integer timestamp for newly written files, so the newest-first order is
94
+ // unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
95
+ // because it may return a fractional value, which wouldn't round-trip through the integer file name.)
96
+ function nextFileTime(): number {
97
+ lastFileTime = Math.max(Date.now(), lastFileTime + 1);
98
+ return lastFileTime;
99
+ }
100
+
101
+ // Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
102
+ // 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.
104
+ function newFileName(timestamp: number): string {
105
+ fileNameCounter++;
106
+ return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
107
+ }
108
+
109
+ type StreamFileInfo = { fileName: string; timestamp: number };
110
+
111
+ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
112
+ if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
113
+ const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
114
+ // stream_<timestamp>_<random>
115
+ if (parts.length !== 3 || parts[0] !== "stream") return undefined;
116
+ const timestamp = parseInt(parts[1], 10);
117
+ if (!Number.isFinite(timestamp)) return undefined;
118
+ return { fileName, timestamp };
119
+ }
120
+
121
+ function parseFileName(fileName: string): BulkFileInfo | undefined {
122
+ if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
123
+ const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
124
+ if (parts.length !== 3) return undefined;
125
+ const level = parseInt(parts[0], 10);
126
+ const timestamp = parseInt(parts[1], 10);
127
+ if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
128
+ return { fileName, level, timestamp };
129
+ }
130
+
131
+ // All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
132
+ // Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
133
+ export class BulkDatabaseBase<T extends { key: string }> {
134
+ constructor(
135
+ public readonly name: string,
136
+ protected deps: ReactiveDeps,
137
+ private storageFactory: StorageFactory,
138
+ ) { }
139
+
140
+ // Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
141
+ // (e.g. between an untimed prep step and the timed benchmark).
142
+ public static clearCache() {
143
+ blockCache.clear();
144
+ }
145
+
146
+ public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
147
+
148
+ // In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
149
+ // are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
150
+ //
151
+ // NOTE: we never bound or clear this in-memory state during normal operation (only on a structural
152
+ // rollover/reset, where the data has been persisted into bulk files). The whole database must be
153
+ // resident in memory anyway — file merging reads every row — so a database large enough to blow
154
+ // the in-memory cache would already fail at merge time. There is no partial-load mode.
155
+ private overlay = new Map<string, OverlayEntry>();
156
+ // Latest stream-on-disk timestamp per key (from the loaded stream files). Used together with the
157
+ // overlay to decide whether an incoming remote write is actually newer than what we have.
158
+ private streamTimes = new Map<string, number>();
159
+
160
+ // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
161
+ // so concurrent writers never touch the same file.
162
+ private streamFileName: string | undefined;
163
+ private streamRowsWritten = 0;
164
+ private getStreamFileName(): string {
165
+ if (!this.streamFileName) {
166
+ this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
167
+ }
168
+ return this.streamFileName;
169
+ }
170
+
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);
174
+ this.deps.invalidate(key);
175
+ this.deps.invalidate(OVERLAY_SIGNAL);
176
+ }
177
+
178
+ private reader = lazy(async (): Promise<BaseBulkDatabaseReader> => {
179
+ 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.
185
+ const [bulkReadersRaw, streamData] = await Promise.all([
186
+ Promise.all(bulkFiles.map(async f => {
187
+ try {
188
+ return await this.loadFileReader(f.fileName);
189
+ } catch (e) {
190
+ await this.handleUnreadableFile(f, (e as Error).message);
191
+ return undefined;
192
+ }
193
+ })),
194
+ this.loadStreamEntries(streamFiles),
195
+ ]);
196
+ 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).
199
+ const readers: BaseBulkDatabaseReader[] = [];
200
+ const ordered = this.orderStreamEntries(streamData.entries);
201
+ if (ordered.length) {
202
+ const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
203
+ readers.push(stream.reader);
204
+ this.streamTimes = stream.times;
205
+ } else {
206
+ this.streamTimes = new Map();
207
+ }
208
+ readers.push(...bulkReaders);
209
+ const joined = joinBulkDatabases(readers);
210
+
211
+ let time = Date.now() - start;
212
+ 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)`);
215
+ }
216
+ return joined;
217
+ });
218
+
219
+ // Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
220
+ // collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
221
+ // We wait for the reader (and thus streamTimes) first so conflict resolution can see disk
222
+ // timestamps, then peers reply to our hello with recent writes that may not be on disk yet (applied
223
+ // through the same applyRemote callback).
224
+ private syncSetup = lazy(async () => {
225
+ if (!isSyncSupported()) return;
226
+ await this.reader();
227
+ let recent = await syncConnect(this.name, write => this.applyRemote(write));
228
+ for (let write of recent) this.applyRemote(write);
229
+ });
230
+
231
+ // The timestamp of the value we currently hold for a key (overlay first, then disk stream).
232
+ private localTime(key: string): number {
233
+ let entry = this.overlay.get(key);
234
+ if (entry) return entry.time;
235
+ let streamTime = this.streamTimes.get(key);
236
+ if (streamTime !== undefined) return streamTime;
237
+ return -Infinity;
238
+ }
239
+
240
+ // Applies a write received from another tab. Only takes effect if it's newer than what we have,
241
+ // so it never clobbers our own (or disk's) more recent write for the same key.
242
+ private applyRemote(write: RemoteWrite) {
243
+ if (write.time <= this.localTime(write.key)) return;
244
+ 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> });
247
+ });
248
+ }
249
+
250
+ // Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
251
+ // changes (large direct-bulk write, rollover, compact) after the data has been persisted.
252
+ private resetReader() {
253
+ this.deps.batch(() => {
254
+ this.reader.reset();
255
+ this.baseColumns.clear();
256
+ this.baseColumnsLoading.clear();
257
+ this.baseFields.clear();
258
+ this.baseFieldsLoading.clear();
259
+ this.overlay.clear();
260
+ this.deps.invalidate(LOAD_SIGNAL);
261
+ this.deps.invalidate(OVERLAY_SIGNAL);
262
+ });
263
+ }
264
+
265
+ // ---- writes ----
266
+
267
+ public async write(entry: T): Promise<void> {
268
+ return this.writeBatch([entry]);
269
+ }
270
+
271
+ public async writeBatch(entries: T[]): Promise<void> {
272
+ if (!entries.length) return;
273
+ void this.syncSetup();
274
+ const rows = entries as unknown as Record<string, unknown>[];
275
+ // Stamp each row with a unique timestamp now, so the same time is used on disk, in the overlay,
276
+ // and in the cross-tab broadcast.
277
+ const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
278
+ const framed = frameRows(stamped);
279
+
280
+ // A batch that already exceeds the rollover limits skips tier-0 and writes a bulk file directly.
281
+ if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
282
+ await this.writeBulkFile(rows);
283
+ this.resetReader();
284
+ return;
285
+ }
286
+
287
+ // Otherwise append to this thread's stream file (one cheap append) and reflect it in the
288
+ // overlay immediately — no reader reset.
289
+ const storage = await this.storage();
290
+ await storage.append(this.getStreamFileName(), framed);
291
+ this.streamRowsWritten += entries.length;
292
+ this.deps.batch(() => {
293
+ for (const { time, row } of stamped) this.setOverlay(row.key as string, { time, value: row });
294
+ });
295
+ for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
296
+ await this.maybeRolloverStream();
297
+ }
298
+
299
+ public async delete(key: string): Promise<void> {
300
+ return this.deleteBatch([key]);
301
+ }
302
+
303
+ public async deleteBatch(keys: string[]): Promise<void> {
304
+ if (!keys.length) return;
305
+ void this.syncSetup();
306
+ const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
307
+ const storage = await this.storage();
308
+ await storage.append(this.getStreamFileName(), frameDeletes(stamped));
309
+ this.streamRowsWritten += keys.length;
310
+ this.deps.batch(() => {
311
+ for (const { time, key } of stamped) this.setOverlay(key, { time, value: DELETED });
312
+ });
313
+ for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
314
+ await this.maybeRolloverStream();
315
+ }
316
+
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 */ }
329
+ }
330
+ }
331
+
332
+ private async listStreamFiles(): Promise<StreamFileInfo[]> {
333
+ const storage = await this.storage();
334
+ const names = await storage.getKeys();
335
+ const files = names.flatMap(n => {
336
+ const parsed = parseStreamFileName(n);
337
+ return parsed && [parsed] || [];
338
+ });
339
+ sort(files, f => f.timestamp);
340
+ return files;
341
+ }
342
+
343
+ // 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 };
347
+ const storage = await this.storage();
348
+ const buffers = await Promise.all(streamFiles.map(f => storage.get(f.fileName)));
349
+ const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
350
+ let totalBytes = 0;
351
+ for (let i = 0; i < streamFiles.length; i++) {
352
+ const buffer = buffers[i];
353
+ if (!buffer) continue;
354
+ totalBytes += buffer.length;
355
+ const parsed = parseStream(buffer);
356
+ if (parsed.badBytes > 0) {
357
+ console.warn(`${this.name} stream file ${streamFiles[i].fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
358
+ }
359
+ for (const entry of parsed.entries) {
360
+ entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
361
+ }
362
+ }
363
+ return { entries, totalBytes };
364
+ }
365
+
366
+ // Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
367
+ private orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
368
+ entries.sort((a, b) => {
369
+ if (a.time !== b.time) return a.time - b.time;
370
+ return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
371
+ });
372
+ return entries.map(e => e.entry);
373
+ }
374
+
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
+ }
405
+ }
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
+ }
419
+
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.
424
+ public async compact(): Promise<void> {
425
+ let merged = false;
426
+ while (await this.mergeFiles() > 0) merged = true;
427
+ if (merged) this.resetReader();
428
+ }
429
+
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] || [];
436
+ });
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;
445
+ }
446
+
447
+ private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
448
+ const storage = await this.storage();
449
+ const info = await storage.getInfo(fileName);
450
+ if (!info) return undefined;
451
+ const rawGetRange: GetRange = async (start, end) => {
452
+ if (end <= start) return EMPTY_BUFFER;
453
+ 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
+ }
457
+ return buf;
458
+ };
459
+ return { rawGetRange, size: info.size };
460
+ }
461
+
462
+ private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
463
+ const raw = await this.makeRawGetRange(fileName);
464
+ if (!raw) {
465
+ throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
466
+ }
467
+ const fileId = nullJoin(this.name, fileName);
468
+ // Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
469
+ // decompressing version (same interface) and read the logical (uncompressed) size from its
470
+ // index. open() validates the file size against the index and throws if it's truncated/corrupt.
471
+ const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
472
+ return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
473
+ }
474
+
475
+ // Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
476
+ // Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
477
+ // missing or unreadable so the planner simply leaves it out of any merge.
478
+ private async fileLogicalSize(fileName: string): Promise<number | undefined> {
479
+ try {
480
+ const raw = await this.makeRawGetRange(fileName);
481
+ if (!raw) return undefined;
482
+ const fileId = nullJoin(this.name, fileName);
483
+ const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
484
+ return opened.uncompressedSize;
485
+ } catch {
486
+ return undefined;
487
+ }
488
+ }
489
+
490
+ // A bulk file that won't load is either a write still in progress (recent) or a stale partial write
491
+ // left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
492
+ // enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
493
+ // Deleting is safe — the write protocol always writes a new file before removing the files it
494
+ // supersedes, so an abandoned partial file's data still lives in another (older) file.
495
+ private async handleUnreadableFile(file: BulkFileInfo, message: string): Promise<void> {
496
+ let ageMs = Date.now() - file.timestamp;
497
+ if (ageMs > STALE_DELETE_MS) {
498
+ console.warn(`${this.name}: deleting stale unreadable bulk file ${file.fileName} (${Math.round(ageMs / 86400000)}d old): ${message}`);
499
+ try {
500
+ let storage = await this.storage();
501
+ await storage.remove(file.fileName);
502
+ } catch (removeError) {
503
+ console.warn(`${this.name}: failed to delete ${file.fileName}: ${(removeError as Error).message}`);
504
+ }
505
+ return;
506
+ }
507
+ console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
508
+ }
509
+
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[]> = {};
522
+ for (const col of reader.columns) {
523
+ colData[col.column] = (await reader.getColumn(col.column)).map(r => r.value);
524
+ }
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];
532
+ }
533
+ mergedRows.push(row);
534
+ }
535
+ }
536
+
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));
541
+ }
542
+ for (const f of files) {
543
+ await storage.remove(f.fileName);
544
+ }
545
+ }
546
+
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)));
556
+
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;
571
+ }
572
+ if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
573
+ batch.push(files[i]);
574
+ batchBytes += size;
575
+ }
576
+ flush();
577
+
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);
581
+ }
582
+ return batches.length;
583
+ }
584
+
585
+ private formatInfo(reader: BaseBulkDatabaseReader): string {
586
+ return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
587
+ }
588
+
589
+ // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
590
+ private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
591
+ if (this.overlay.size === 0) return base;
592
+ const map = new Map(base.map(e => [e.key, e.value]));
593
+ for (const [key, entry] of this.overlay) {
594
+ if (entry.value === DELETED) map.delete(key);
595
+ else map.set(key, entry.value[column]);
596
+ }
597
+ return [...map].map(([key, value]) => ({ key, value }));
598
+ }
599
+
600
+ // ---- async reads (overlay-aware) ----
601
+
602
+ public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
603
+ void this.syncSetup();
604
+ const entry = this.overlay.get(key);
605
+ if (entry !== undefined) {
606
+ if (entry.value === DELETED) return undefined;
607
+ return entry.value[String(column)] as T[Column];
608
+ }
609
+ let time = Date.now();
610
+ let reader = await this.reader();
611
+ let result = await reader.getSingleField(key, String(column)) as T[Column] | undefined;
612
+ time = Date.now() - time;
613
+ if (time > 50) {
614
+ console.log(`${blue(`${this.name}.getSingleField(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
615
+ }
616
+ return result;
617
+ }
618
+
619
+ public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column] }[]> {
620
+ void this.syncSetup();
621
+ let time = Date.now();
622
+ let reader = await this.reader();
623
+ let base = await reader.getColumn(String(column)) as { key: string; value: T[Column] }[];
624
+ let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column] }[];
625
+ time = Date.now() - time;
626
+ if (time > 50) {
627
+ console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
628
+ }
629
+ return result;
630
+ }
631
+
632
+ public async getKeys(): Promise<string[]> {
633
+ void this.syncSetup();
634
+ let reader = await this.reader();
635
+ if (this.overlay.size === 0) return reader.keys;
636
+ let set = new Set(reader.keys);
637
+ for (const [key, entry] of this.overlay) {
638
+ if (entry.value === DELETED) set.delete(key);
639
+ else set.add(key);
640
+ }
641
+ return [...set];
642
+ }
643
+
644
+ // ---- sync (reactive) reads ----
645
+ // These observe the overlay + load signals, so a reactive context that reads them re-runs when a
646
+ // write/delete happens or when a base value finishes loading. The immutable base column/field is
647
+ // loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
648
+ // because the overlay mutates).
649
+
650
+ private baseColumns = new Map<string, { key: string; value: unknown }[]>();
651
+ private baseColumnsLoading = new Set<string>();
652
+ private baseFields = new Map<string, unknown>();
653
+ private baseFieldsLoading = new Set<string>();
654
+
655
+ private ensureBaseColumn(column: string) {
656
+ if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
657
+ this.baseColumnsLoading.add(column);
658
+ void (async () => {
659
+ let reader = await this.reader();
660
+ let base = await reader.getColumn(column);
661
+ this.deps.batch(() => {
662
+ this.baseColumns.set(column, base);
663
+ this.baseColumnsLoading.delete(column);
664
+ this.deps.invalidate(LOAD_SIGNAL);
665
+ });
666
+ })();
667
+ }
668
+
669
+ private ensureBaseField(key: string, column: string) {
670
+ let cacheKey = nullJoin(column, key);
671
+ if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
672
+ this.baseFieldsLoading.add(cacheKey);
673
+ void (async () => {
674
+ let reader = await this.reader();
675
+ let value = await reader.getSingleField(key, column);
676
+ this.deps.batch(() => {
677
+ this.baseFields.set(cacheKey, value);
678
+ this.baseFieldsLoading.delete(cacheKey);
679
+ this.deps.invalidate(LOAD_SIGNAL);
680
+ });
681
+ })();
682
+ }
683
+
684
+ public getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined {
685
+ void this.syncSetup();
686
+ this.deps.observe(LOAD_SIGNAL);
687
+ this.deps.observe(key);
688
+ let col = String(column);
689
+ let entry = this.overlay.get(key);
690
+ if (entry !== undefined) {
691
+ if (entry.value === DELETED) return undefined;
692
+ return entry.value[col] as T[Column];
693
+ }
694
+ let cacheKey = nullJoin(col, key);
695
+ if (!this.baseFields.has(cacheKey)) {
696
+ this.ensureBaseField(key, col);
697
+ return undefined;
698
+ }
699
+ return this.baseFields.get(cacheKey) as T[Column] | undefined;
700
+ }
701
+
702
+ public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column] }[] | undefined {
703
+ void this.syncSetup();
704
+ this.deps.observe(LOAD_SIGNAL);
705
+ // Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
706
+ this.deps.observe(OVERLAY_SIGNAL);
707
+ let col = String(column);
708
+ let base = this.baseColumns.get(col);
709
+ if (!base) {
710
+ this.ensureBaseColumn(col);
711
+ return undefined;
712
+ }
713
+ return this.patchColumn(base, col) as { key: string; value: T[Column] }[];
714
+ }
715
+
716
+ public async getColumnInfo() {
717
+ let reader = await this.reader();
718
+ return reader.columns;
719
+ }
720
+
721
+ public async getReaderInfo() {
722
+ let reader = await this.reader();
723
+ return {
724
+ rowCount: reader.rowCount,
725
+ totalBytes: reader.totalBytes,
726
+ keyCount: reader.keys.length,
727
+ sampleKey: reader.keys[0] as string | undefined,
728
+ columns: reader.columns,
729
+ };
730
+ }
731
+ }
732
+
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>();
738
+ for (const db of databases) {
739
+ if (db.deletedKeys) for (const key of db.deletedKeys) deleted.add(key);
740
+ }
741
+
742
+ const keys: string[] = [];
743
+ const keySeen = new Set<string>();
744
+ 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
+ }
750
+ }
751
+ const columns: { column: string; byteSize: number }[] = [];
752
+ const columnByName = new Map<string, { column: string; byteSize: number }>();
753
+ for (const db of databases) {
754
+ for (const col of db.columns) {
755
+ let existing = columnByName.get(col.column);
756
+ if (!existing) {
757
+ existing = { column: col.column, byteSize: 0 };
758
+ columnByName.set(col.column, existing);
759
+ columns.push(existing);
760
+ }
761
+ existing.byteSize += col.byteSize;
762
+ }
763
+ }
764
+ return {
765
+ totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
766
+ rowCount: keys.length,
767
+ keys,
768
+ columns,
769
+ 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);
777
+ }
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;
786
+ },
787
+ 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);
793
+ }
794
+ return undefined;
795
+ },
796
+ };
797
+ }