sliftutils 1.1.42 → 1.1.43

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,667 @@
1
+ import { getFileStorageNested } from "../FileFolderAPI";
2
+ import { sort } from "socket-function/src/misc";
3
+ import { getTimeUnique } from "socket-function/src/bits";
4
+ import { BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, loadBulkDatabase } from "./BulkDatabaseFormat";
5
+ import { lazy } from "socket-function/src/caching";
6
+ import { observable, runInAction } from "../../render-utils/mobxTyped";
7
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
8
+ import { blue, green, red } from "socket-function/src/formatting/logColors";
9
+ import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
10
+ import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
11
+ import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, RemoteWrite } from "./syncClient";
12
+
13
+ // BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
14
+ // folder rather than sharing bulkDatabases/.
15
+ const BULK_ROOT_FOLDER = "bulkDatabases2";
16
+ const FILE_EXTENSION = ".bulk";
17
+ // When a level accumulates this many files they are merged into one file at the next level up, cascading, so write count stays O(log n) files.
18
+ const MERGE_FILE_COUNT = 8;
19
+
20
+ // Tier-0 streaming rolls over into a columnar bulk file once it gets big enough — by row count,
21
+ // byte size, or file count (many threads each stream to their own file). A single writeBatch that
22
+ // already exceeds the row/byte limits skips streaming and writes a bulk file directly.
23
+ const ROLLOVER_ROWS = 5000;
24
+ const ROLLOVER_BYTES = 5 * 1024 * 1024;
25
+ const ROLLOVER_FILES = 100;
26
+
27
+ // An unreadable file might be a write that is still in progress (another thread), so we can't delete
28
+ // it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
29
+ // writer is plausibly still working on it, so we delete it. Until then we just warn.
30
+ const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
31
+
32
+ // Marks a key as deleted in the in-memory overlay.
33
+ const DELETED = Symbol("deleted");
34
+ // Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
35
+ // remote write only overrides a key if it's newer than what we already have.
36
+ type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
37
+
38
+ let fileNameCounter = 0;
39
+
40
+ type BulkFileInfo = { fileName: string; level: number; timestamp: number };
41
+
42
+ function newFileName(level: number): string {
43
+ fileNameCounter++;
44
+ return `${level}_${Date.now()}_${fileNameCounter}${FILE_EXTENSION}`;
45
+ }
46
+
47
+ type StreamFileInfo = { fileName: string; timestamp: number };
48
+
49
+ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
50
+ if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
51
+ const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
52
+ // stream_<timestamp>_<random>
53
+ if (parts.length !== 3 || parts[0] !== "stream") return undefined;
54
+ const timestamp = parseInt(parts[1], 10);
55
+ if (!Number.isFinite(timestamp)) return undefined;
56
+ return { fileName, timestamp };
57
+ }
58
+
59
+ function parseFileName(fileName: string): BulkFileInfo | undefined {
60
+ if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
61
+ const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
62
+ if (parts.length !== 3) return undefined;
63
+ const level = parseInt(parts[0], 10);
64
+ const timestamp = parseInt(parts[1], 10);
65
+ if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
66
+ return { fileName, level, timestamp };
67
+ }
68
+
69
+ export class BulkDatabase2<T extends { key: string }> {
70
+ constructor(public readonly name: string) { }
71
+
72
+ // Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
73
+ // (e.g. between an untimed prep step and the timed benchmark).
74
+ public static clearCache() {
75
+ blockCache.clear();
76
+ }
77
+
78
+ private storage = lazy(async () => getFileStorageNested(`${BULK_ROOT_FOLDER}/${this.name}`));
79
+
80
+ // In-memory overlay of pending writes/deletes, observable so reads re-render when it changes. It
81
+ // takes priority over the loaded readers, so writes are reflected in reads without reloading.
82
+ //
83
+ // NOTE: we never bound or clear this in-memory state during normal operation (only on a structural
84
+ // rollover/reset, where the data has been persisted into bulk files). The whole database must be
85
+ // resident in memory anyway — file merging reads every row — so a database large enough to blow
86
+ // the in-memory cache would already fail at merge time. There is no partial-load mode.
87
+ private overlay = observable.map<string, OverlayEntry>();
88
+ // Latest stream-on-disk timestamp per key (from the loaded stream files). Used together with the
89
+ // overlay to decide whether an incoming remote write is actually newer than what we have.
90
+ private streamTimes = new Map<string, number>();
91
+ // Bumped whenever a base column/field finishes loading or the reader resets, so sync reads that
92
+ // returned "still loading" re-render once data is available.
93
+ private loadVersion = observable.box(0);
94
+
95
+ // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
96
+ // so concurrent writers never touch the same file.
97
+ private streamFileName: string | undefined;
98
+ private streamRowsWritten = 0;
99
+ private getStreamFileName(): string {
100
+ if (!this.streamFileName) {
101
+ this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
102
+ }
103
+ return this.streamFileName;
104
+ }
105
+
106
+ private reader = lazy(async (): Promise<BaseBulkDatabaseReader> => {
107
+ let start = Date.now();
108
+ const [bulkFiles, streamFiles] = await Promise.all([this.listFiles(), this.listStreamFiles()]);
109
+ // Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
110
+ // A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
111
+ // returning bad values: the write protocol always writes a new file before removing the old
112
+ // ones it supersedes, so a partially-written file's data still exists in another file.
113
+ const [bulkReadersRaw, streamData] = await Promise.all([
114
+ Promise.all(bulkFiles.map(async f => {
115
+ try {
116
+ return await this.loadFileReader(f.fileName);
117
+ } catch (e) {
118
+ await this.handleUnreadableFile(f, (e as Error).message);
119
+ return undefined;
120
+ }
121
+ })),
122
+ this.loadStreamEntries(streamFiles),
123
+ ]);
124
+ const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
125
+ // Streamed entries are the newest writes, so their reader goes first (the join is newest-wins
126
+ // and lets the stream's deletes tombstone keys in the older bulk readers).
127
+ const readers: BaseBulkDatabaseReader[] = [];
128
+ const ordered = this.orderStreamEntries(streamData.entries);
129
+ if (ordered.length) {
130
+ const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
131
+ readers.push(stream.reader);
132
+ this.streamTimes = stream.times;
133
+ } else {
134
+ this.streamTimes = new Map();
135
+ }
136
+ readers.push(...bulkReaders);
137
+ const joined = joinBulkDatabases(readers);
138
+
139
+ let time = Date.now() - start;
140
+ if (time > 50) {
141
+ console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
142
+ }
143
+ return joined;
144
+ });
145
+
146
+ // Connects to the SharedWorker (browser only) so writes in other tabs of this collection update
147
+ // our observable overlay. Runs once; no-op in Node / where SharedWorker is unavailable. We wait
148
+ // for the reader (and thus streamTimes) first so conflict resolution can see disk timestamps, then
149
+ // apply the worker's buffered recent writes (which may not be on disk yet).
150
+ private syncSetup = lazy(async () => {
151
+ if (!isSyncSupported()) return;
152
+ await this.reader();
153
+ let recent = await syncConnect(this.name, write => this.applyRemote(write));
154
+ for (let write of recent) this.applyRemote(write);
155
+ });
156
+
157
+ // The timestamp of the value we currently hold for a key (overlay first, then disk stream).
158
+ private localTime(key: string): number {
159
+ let entry = this.overlay.get(key);
160
+ if (entry) return entry.time;
161
+ let streamTime = this.streamTimes.get(key);
162
+ if (streamTime !== undefined) return streamTime;
163
+ return -Infinity;
164
+ }
165
+
166
+ // Applies a write received from another tab. Only takes effect if it's newer than what we have,
167
+ // so it never clobbers our own (or disk's) more recent write for the same key.
168
+ private applyRemote(write: RemoteWrite) {
169
+ if (write.time <= this.localTime(write.key)) return;
170
+ runInAction(() => {
171
+ if (write.deleted) this.overlay.set(write.key, { time: write.time, value: DELETED });
172
+ else this.overlay.set(write.key, { time: write.time, value: write.value as Record<string, unknown> });
173
+ });
174
+ }
175
+
176
+ // Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
177
+ // changes (large direct-bulk write, rollover, compact) after the data has been persisted.
178
+ private resetReader() {
179
+ runInAction(() => {
180
+ this.reader.reset();
181
+ this.baseColumns.clear();
182
+ this.baseColumnsLoading.clear();
183
+ this.baseFields.clear();
184
+ this.baseFieldsLoading.clear();
185
+ this.overlay.clear();
186
+ this.loadVersion.set(this.loadVersion.get() + 1);
187
+ });
188
+ }
189
+
190
+ // ---- writes ----
191
+
192
+ public async write(entry: T): Promise<void> {
193
+ return this.writeBatch([entry]);
194
+ }
195
+
196
+ public async writeBatch(entries: T[]): Promise<void> {
197
+ if (!entries.length) return;
198
+ void this.syncSetup();
199
+ const rows = entries as unknown as Record<string, unknown>[];
200
+ // Stamp each row with a unique timestamp now, so the same time is used on disk, in the overlay,
201
+ // and in the cross-tab broadcast.
202
+ const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
203
+ const framed = frameRows(stamped);
204
+
205
+ // A batch that already exceeds the rollover limits skips tier-0 and writes a bulk file directly.
206
+ if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
207
+ await this.writeBulkFile(rows);
208
+ this.resetReader();
209
+ return;
210
+ }
211
+
212
+ // Otherwise append to this thread's stream file (one cheap append) and reflect it in the
213
+ // observable overlay immediately — no reader reset.
214
+ const storage = await this.storage();
215
+ await storage.append(this.getStreamFileName(), framed);
216
+ this.streamRowsWritten += entries.length;
217
+ runInAction(() => {
218
+ for (const { time, row } of stamped) this.overlay.set(row.key as string, { time, value: row });
219
+ });
220
+ for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
221
+ await this.maybeRolloverStream();
222
+ }
223
+
224
+ public async delete(key: string): Promise<void> {
225
+ return this.deleteBatch([key]);
226
+ }
227
+
228
+ public async deleteBatch(keys: string[]): Promise<void> {
229
+ if (!keys.length) return;
230
+ void this.syncSetup();
231
+ const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
232
+ const storage = await this.storage();
233
+ await storage.append(this.getStreamFileName(), frameDeletes(stamped));
234
+ this.streamRowsWritten += keys.length;
235
+ runInAction(() => {
236
+ for (const { time, key } of stamped) this.overlay.set(key, { time, value: DELETED });
237
+ });
238
+ for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
239
+ await this.maybeRolloverStream();
240
+ }
241
+
242
+ // Writes one columnar bulk file at level 0, then cascades level merges.
243
+ private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
244
+ const storage = await this.storage();
245
+ await storage.set(newFileName(0), encodeCompressedBlocks(buildFileBuffer(rows)));
246
+ // Cascade merge: any level at >= MERGE_FILE_COUNT files merges into one file at level+1, repeating up the tree.
247
+ while (true) {
248
+ const files = await this.listFiles();
249
+ const byLevel = new Map<number, BulkFileInfo[]>();
250
+ for (const f of files) {
251
+ let list = byLevel.get(f.level);
252
+ if (!list) {
253
+ list = [];
254
+ byLevel.set(f.level, list);
255
+ }
256
+ list.push(f);
257
+ }
258
+ const levels = [...byLevel.keys()];
259
+ sort(levels, l => l);
260
+ const mergeLevel = levels.find(l => {
261
+ const list = byLevel.get(l);
262
+ return list && list.length >= MERGE_FILE_COUNT;
263
+ });
264
+ if (mergeLevel === undefined) break;
265
+ const levelFiles = byLevel.get(mergeLevel);
266
+ if (!levelFiles) {
267
+ throw new Error(`Expected files at level ${mergeLevel}, was empty`);
268
+ }
269
+ await this.mergeFiles(levelFiles, mergeLevel + 1);
270
+ }
271
+ }
272
+
273
+ private async listStreamFiles(): Promise<StreamFileInfo[]> {
274
+ const storage = await this.storage();
275
+ const names = await storage.getKeys();
276
+ const files = names.flatMap(n => {
277
+ const parsed = parseStreamFileName(n);
278
+ return parsed && [parsed] || [];
279
+ });
280
+ sort(files, f => f.timestamp);
281
+ return files;
282
+ }
283
+
284
+ // Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
285
+ // unique timestamp + originating file) so callers can order writes globally across files.
286
+ private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
287
+ if (!streamFiles.length) return { entries: [], totalBytes: 0 };
288
+ const storage = await this.storage();
289
+ const buffers = await Promise.all(streamFiles.map(f => storage.get(f.fileName)));
290
+ const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
291
+ let totalBytes = 0;
292
+ for (let i = 0; i < streamFiles.length; i++) {
293
+ const buffer = buffers[i];
294
+ if (!buffer) continue;
295
+ totalBytes += buffer.length;
296
+ const parsed = parseStream(buffer);
297
+ if (parsed.badBytes > 0) {
298
+ console.warn(`${this.name} stream file ${streamFiles[i].fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
299
+ }
300
+ for (const entry of parsed.entries) {
301
+ entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
302
+ }
303
+ }
304
+ return { entries, totalBytes };
305
+ }
306
+
307
+ // Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
308
+ private orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
309
+ entries.sort((a, b) => {
310
+ if (a.time !== b.time) return a.time - b.time;
311
+ return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
312
+ });
313
+ return entries.map(e => e.entry);
314
+ }
315
+
316
+ private async maybeRolloverStream(): Promise<void> {
317
+ const streamFiles = await this.listStreamFiles();
318
+ const storage = await this.storage();
319
+ let totalBytes = 0;
320
+ for (const f of streamFiles) {
321
+ const info = await storage.getInfo(f.fileName);
322
+ totalBytes += info?.size || 0;
323
+ }
324
+ if (streamFiles.length > ROLLOVER_FILES || totalBytes > ROLLOVER_BYTES || this.streamRowsWritten > ROLLOVER_ROWS) {
325
+ await this.rolloverStream(streamFiles);
326
+ }
327
+ }
328
+
329
+ // Combine all tier-0 stream files into a single columnar bulk file (newest-wins per key, deletes
330
+ // applied), delete the consumed stream files, and re-persist surviving tombstones to a fresh
331
+ // stream file so deletes of keys that live in older bulk files are not lost.
332
+ private async rolloverStream(streamFiles: StreamFileInfo[]): Promise<void> {
333
+ const { entries } = await this.loadStreamEntries(streamFiles);
334
+ const ordered = this.orderStreamEntries(entries);
335
+ const byKey = new Map<string, Record<string, unknown>>();
336
+ const deleted = new Map<string, number>();
337
+ for (const e of ordered) {
338
+ if (e.deletedKey !== undefined) {
339
+ byKey.delete(e.deletedKey);
340
+ deleted.set(e.deletedKey, e.time);
341
+ } else if (e.row) {
342
+ let key = e.row.key as string;
343
+ byKey.set(key, e.row);
344
+ deleted.delete(key);
345
+ }
346
+ }
347
+ if (byKey.size) await this.writeBulkFile([...byKey.values()]);
348
+ const storage = await this.storage();
349
+ // Persist surviving tombstones (keeping their original timestamps) to a FRESH stream file
350
+ // before removing the consumed files, so a crash in between can't drop the deletes. The window
351
+ // is at worst redundant (deletes present in both old and new files), never missing.
352
+ this.streamFileName = undefined;
353
+ this.streamRowsWritten = 0;
354
+ if (deleted.size) {
355
+ await storage.append(this.getStreamFileName(), frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
356
+ }
357
+ for (const f of streamFiles) await storage.remove(f.fileName);
358
+ this.resetReader();
359
+ }
360
+
361
+ // Force-merge every on-disk file into a single new file regardless of how many there are.
362
+ public async compact(): Promise<void> {
363
+ const files = await this.listFiles();
364
+ if (files.length < 2) return;
365
+ const maxLevel = files.reduce((m, f) => Math.max(m, f.level), 0);
366
+ await this.mergeFiles(files, maxLevel + 1);
367
+ this.resetReader();
368
+ }
369
+
370
+ private async listFiles(): Promise<BulkFileInfo[]> {
371
+ const storage = await this.storage();
372
+ const names = await storage.getKeys();
373
+ const files = names.flatMap(n => {
374
+ const parsed = parseFileName(n);
375
+ return parsed && [parsed] || [];
376
+ });
377
+ // Sort is stable, so timestamps stay newest-first within each level. Lower levels are always newer than higher levels (a merge consumes every file at its level, so survivors postdate it).
378
+ sort(files, f => -f.timestamp);
379
+ sort(files, f => f.level);
380
+ return files;
381
+ }
382
+
383
+ private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
384
+ const storage = await this.storage();
385
+ const info = await storage.getInfo(fileName);
386
+ if (!info) {
387
+ throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
388
+ }
389
+ const fileId = `${this.name}\u0000${fileName}`;
390
+ let getRange: GetRange = async (start, end) => {
391
+ if (end <= start) return EMPTY_BUFFER;
392
+ const buf = await storage.getRange(fileName, { start, end });
393
+ if (!buf) {
394
+ throw new Error(`Expected range [${start}, ${end}) of ${fileName}, file was missing`);
395
+ }
396
+ return buf;
397
+ };
398
+ // Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
399
+ // decompressing version (same interface) and read the logical (uncompressed) size from its
400
+ // index. open() validates the file size against the index and throws if it's truncated/corrupt.
401
+ const opened = await blockCache.open(fileId, info.size, getRange);
402
+ return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
403
+ }
404
+
405
+ // A bulk file that won't load is either a write still in progress (recent) or a stale partial write
406
+ // left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
407
+ // enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
408
+ // Deleting is safe — the write protocol always writes a new file before removing the files it
409
+ // supersedes, so an abandoned partial file's data still lives in another (older) file.
410
+ private async handleUnreadableFile(file: BulkFileInfo, message: string): Promise<void> {
411
+ let ageMs = Date.now() - file.timestamp;
412
+ if (ageMs > STALE_DELETE_MS) {
413
+ console.warn(`${this.name}: deleting stale unreadable bulk file ${file.fileName} (${Math.round(ageMs / 86400000)}d old): ${message}`);
414
+ try {
415
+ let storage = await this.storage();
416
+ await storage.remove(file.fileName);
417
+ } catch (removeError) {
418
+ console.warn(`${this.name}: failed to delete ${file.fileName}: ${(removeError as Error).message}`);
419
+ }
420
+ return;
421
+ }
422
+ console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
423
+ }
424
+
425
+ // Read every row from `files` (already in newest-first order), pick newest-wins per key, write the merged set as a single new file at `newLevel`, then delete the consumed files.
426
+ private async mergeFiles(files: BulkFileInfo[], newLevel: number): Promise<void> {
427
+ const storage = await this.storage();
428
+ const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
429
+
430
+ const seen = new Set<string>();
431
+ const mergedRows: Record<string, unknown>[] = [];
432
+ for (const reader of readers) {
433
+ const colData: Record<string, unknown[]> = {};
434
+ for (const col of reader.columns) {
435
+ colData[col.column] = (await reader.getColumn(col.column)).map(r => r.value);
436
+ }
437
+ for (let i = 0; i < reader.keys.length; i++) {
438
+ const key = reader.keys[i];
439
+ if (seen.has(key)) continue;
440
+ seen.add(key);
441
+ const row: Record<string, unknown> = {};
442
+ for (const col of reader.columns) {
443
+ row[col.column] = colData[col.column][i];
444
+ }
445
+ mergedRows.push(row);
446
+ }
447
+ }
448
+
449
+ await storage.set(newFileName(newLevel), encodeCompressedBlocks(buildFileBuffer(mergedRows)));
450
+ for (const f of files) {
451
+ await storage.remove(f.fileName);
452
+ }
453
+ }
454
+
455
+ private formatInfo(reader: BaseBulkDatabaseReader): string {
456
+ return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
457
+ }
458
+
459
+ // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
460
+ private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
461
+ if (this.overlay.size === 0) return base;
462
+ const map = new Map(base.map(e => [e.key, e.value]));
463
+ for (const [key, entry] of this.overlay) {
464
+ if (entry.value === DELETED) map.delete(key);
465
+ else map.set(key, entry.value[column]);
466
+ }
467
+ return [...map].map(([key, value]) => ({ key, value }));
468
+ }
469
+
470
+ // ---- async reads (overlay-aware) ----
471
+
472
+ public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
473
+ void this.syncSetup();
474
+ const entry = this.overlay.get(key);
475
+ if (entry !== undefined) {
476
+ if (entry.value === DELETED) return undefined;
477
+ return entry.value[String(column)] as T[Column];
478
+ }
479
+ let time = Date.now();
480
+ let reader = await this.reader();
481
+ let result = await reader.getSingleField(key, String(column)) as T[Column] | undefined;
482
+ time = Date.now() - time;
483
+ if (time > 50) {
484
+ console.log(`${blue(`${this.name}.getSingleField(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
485
+ }
486
+ return result;
487
+ }
488
+
489
+ public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column] }[]> {
490
+ void this.syncSetup();
491
+ let time = Date.now();
492
+ let reader = await this.reader();
493
+ let base = await reader.getColumn(String(column)) as { key: string; value: T[Column] }[];
494
+ let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column] }[];
495
+ time = Date.now() - time;
496
+ if (time > 50) {
497
+ console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
498
+ }
499
+ return result;
500
+ }
501
+
502
+ public async getKeys(): Promise<string[]> {
503
+ void this.syncSetup();
504
+ let reader = await this.reader();
505
+ if (this.overlay.size === 0) return reader.keys;
506
+ let set = new Set(reader.keys);
507
+ for (const [key, entry] of this.overlay) {
508
+ if (entry.value === DELETED) set.delete(key);
509
+ else set.add(key);
510
+ }
511
+ return [...set];
512
+ }
513
+
514
+ // ---- sync (observable) reads ----
515
+ // These read the observable overlay + loadVersion, so a component that reads them re-renders when
516
+ // a write/delete happens or when a base value finishes loading. The immutable base column/field is
517
+ // loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
518
+ // because the overlay mutates).
519
+
520
+ private baseColumns = new Map<string, { key: string; value: unknown }[]>();
521
+ private baseColumnsLoading = new Set<string>();
522
+ private baseFields = new Map<string, unknown>();
523
+ private baseFieldsLoading = new Set<string>();
524
+
525
+ private ensureBaseColumn(column: string) {
526
+ if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
527
+ this.baseColumnsLoading.add(column);
528
+ void (async () => {
529
+ let reader = await this.reader();
530
+ let base = await reader.getColumn(column);
531
+ runInAction(() => {
532
+ this.baseColumns.set(column, base);
533
+ this.baseColumnsLoading.delete(column);
534
+ this.loadVersion.set(this.loadVersion.get() + 1);
535
+ });
536
+ })();
537
+ }
538
+
539
+ private ensureBaseField(key: string, column: string) {
540
+ let cacheKey = `${column}\u0000${key}`;
541
+ if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
542
+ this.baseFieldsLoading.add(cacheKey);
543
+ void (async () => {
544
+ let reader = await this.reader();
545
+ let value = await reader.getSingleField(key, column);
546
+ runInAction(() => {
547
+ this.baseFields.set(cacheKey, value);
548
+ this.baseFieldsLoading.delete(cacheKey);
549
+ this.loadVersion.set(this.loadVersion.get() + 1);
550
+ });
551
+ })();
552
+ }
553
+
554
+ public getSingleFieldSync<Column extends keyof T>(config: { key: string; column: Column }): T[Column] | undefined {
555
+ void this.syncSetup();
556
+ this.loadVersion.get();
557
+ let { key, column } = config;
558
+ let col = String(column);
559
+ let entry = this.overlay.get(key);
560
+ if (entry !== undefined) {
561
+ if (entry.value === DELETED) return undefined;
562
+ return entry.value[col] as T[Column];
563
+ }
564
+ let cacheKey = `${col}\u0000${key}`;
565
+ if (!this.baseFields.has(cacheKey)) {
566
+ this.ensureBaseField(key, col);
567
+ return undefined;
568
+ }
569
+ return this.baseFields.get(cacheKey) as T[Column] | undefined;
570
+ }
571
+
572
+ public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column] }[] | undefined {
573
+ void this.syncSetup();
574
+ this.loadVersion.get();
575
+ let col = String(column);
576
+ let base = this.baseColumns.get(col);
577
+ if (!base) {
578
+ this.ensureBaseColumn(col);
579
+ // Track the overlay so we recompute once the base arrives or the overlay changes.
580
+ void this.overlay.size;
581
+ return undefined;
582
+ }
583
+ return this.patchColumn(base, col) as { key: string; value: T[Column] }[];
584
+ }
585
+
586
+ public async getColumnInfo() {
587
+ let reader = await this.reader();
588
+ return reader.columns;
589
+ }
590
+
591
+ public async getReaderInfo() {
592
+ let reader = await this.reader();
593
+ return {
594
+ rowCount: reader.rowCount,
595
+ totalBytes: reader.totalBytes,
596
+ keyCount: reader.keys.length,
597
+ sampleKey: reader.keys[0] as string | undefined,
598
+ columns: reader.columns,
599
+ };
600
+ }
601
+ }
602
+
603
+ // Lowest indexes are read first (newest-wins). A reader's deletedKeys tombstone a key in all older
604
+ // readers; the newest reader that has a key live wins.
605
+ function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabaseReader {
606
+ const keySets = databases.map(db => new Set(db.keys));
607
+ const deleted = new Set<string>();
608
+ for (const db of databases) {
609
+ if (db.deletedKeys) for (const key of db.deletedKeys) deleted.add(key);
610
+ }
611
+
612
+ const keys: string[] = [];
613
+ const keySeen = new Set<string>();
614
+ for (const db of databases) {
615
+ for (const key of db.keys) {
616
+ if (keySeen.has(key) || deleted.has(key)) continue;
617
+ keySeen.add(key);
618
+ keys.push(key);
619
+ }
620
+ }
621
+ const columns: { column: string; byteSize: number }[] = [];
622
+ const columnByName = new Map<string, { column: string; byteSize: number }>();
623
+ for (const db of databases) {
624
+ for (const col of db.columns) {
625
+ let existing = columnByName.get(col.column);
626
+ if (!existing) {
627
+ existing = { column: col.column, byteSize: 0 };
628
+ columnByName.set(col.column, existing);
629
+ columns.push(existing);
630
+ }
631
+ existing.byteSize += col.byteSize;
632
+ }
633
+ }
634
+ return {
635
+ totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
636
+ rowCount: keys.length,
637
+ keys,
638
+ columns,
639
+ async getColumn(column) {
640
+ const result: { key: string; value: unknown }[] = [];
641
+ const taken = new Set<string>();
642
+ for (const db of databases) {
643
+ // 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...
644
+ let values: unknown[] | undefined;
645
+ if (db.columns.some(c => c.column === column)) {
646
+ values = (await db.getColumn(column)).map(r => r.value);
647
+ }
648
+ for (let i = 0; i < db.keys.length; i++) {
649
+ const key = db.keys[i];
650
+ if (taken.has(key) || deleted.has(key)) continue;
651
+ taken.add(key);
652
+ result.push({ key, value: values && values[i] });
653
+ }
654
+ }
655
+ return result;
656
+ },
657
+ async getSingleField(key, column) {
658
+ if (deleted.has(key)) return undefined;
659
+ for (let i = 0; i < databases.length; i++) {
660
+ if (!keySets[i].has(key)) continue;
661
+ if (!databases[i].columns.some(c => c.column === column)) return undefined;
662
+ return await databases[i].getSingleField(key, column);
663
+ }
664
+ return undefined;
665
+ },
666
+ };
667
+ }