sliftutils 1.3.0 → 1.3.1

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,12 +1,14 @@
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, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase } 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
9
  import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, RemoteWrite } from "./syncClient";
10
+ import { Manifest, chooseManifest, isManifestName, manifestFileName, parseManifestStartTime } from "./manifest";
11
+ import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
10
12
  import type { FileStorage } from "../FileFolderAPI";
11
13
 
12
14
  // BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
@@ -25,18 +27,38 @@ const MERGE_FILE_COUNT = 8;
25
27
  const MERGE_MIN_BYTES = 400 * 1024 * 1024;
26
28
  const MERGE_MAX_BYTES = 800 * 1024 * 1024;
27
29
 
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.
30
+ // A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
31
+ // into a bulk file (streaming thousands of rows one frame at a time would be pointless).
31
32
  const ROLLOVER_ROWS = 5000;
32
33
  const ROLLOVER_BYTES = 5 * 1024 * 1024;
33
- const ROLLOVER_FILES = 100;
34
34
 
35
35
  // An unreadable file might be a write that is still in progress (another thread), so we can't delete
36
36
  // it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
37
37
  // writer is plausibly still working on it, so we delete it. Until then we just warn.
38
38
  const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
39
39
 
40
+ // All the time thresholds, mutable so tests can shrink them from hours to milliseconds. The ordering
41
+ // invariant relies on: streamSealAgeMs < foldDataAgeMs (a file is sealed well before it's foldable),
42
+ // and foldTriggerAgeMs >= foldDataAgeMs.
43
+ export const bulkDatabase2Timing = {
44
+ // A writer stops appending to its current stream file once it's this old (starts a fresh one), so no
45
+ // file is ever appended to past this age.
46
+ streamSealAgeMs: 10 * 60 * 60 * 1000,
47
+ // A fold reads every stream file CREATED longer ago than this (all sealed, with margin), and moves
48
+ // only the entries whose WRITE-TIME is older than this into bulk; newer entries are re-streamed.
49
+ // This single cutoff is what keeps every bulk write strictly older than every stream write.
50
+ foldDataAgeMs: 12 * 60 * 60 * 1000,
51
+ // We don't bother folding until some stream file is older than this (fold in big infrequent batches).
52
+ foldTriggerAgeMs: 36 * 60 * 60 * 1000,
53
+ // Per-instance throttle on how often we scan to see whether a fold is due.
54
+ foldCheckIntervalMs: 5 * 1000,
55
+ // How long a superseded/orphaned/old-manifest file must sit before cleanup deletes it (by its name
56
+ // timestamp) — long enough that any reader still on an older manifest has finished.
57
+ cleanupAgeMs: 60 * 1000,
58
+ // Per-instance throttle on cleanup scans.
59
+ cleanupIntervalMs: 10 * 1000,
60
+ };
61
+
40
62
  // Marks a key as deleted in the in-memory overlay.
41
63
  const DELETED = Symbol("deleted");
42
64
  // Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
@@ -86,6 +108,12 @@ const LOAD_SIGNAL = NULL + "load";
86
108
  const OVERLAY_SIGNAL = NULL + "overlay";
87
109
 
88
110
  let fileNameCounter = 0;
111
+ // Random per-process id baked into file names so two processes (tabs) writing the same collection
112
+ // never collide on a name when they pick the same timestamp/counter in the same millisecond.
113
+ const writerId = Math.random().toString(36).slice(2, 10);
114
+ function nextCounter(): number {
115
+ return ++fileNameCounter;
116
+ }
89
117
 
90
118
  type BulkFileInfo = { fileName: string; level: number; timestamp: number };
91
119
 
@@ -100,10 +128,9 @@ function nextFileTime(): number {
100
128
 
101
129
  // Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
102
130
  // 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.
131
+ // field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
104
132
  function newFileName(timestamp: number): string {
105
- fileNameCounter++;
106
- return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
133
+ return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
107
134
  }
108
135
 
109
136
  type StreamFileInfo = { fileName: string; timestamp: number };
@@ -121,7 +148,9 @@ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
121
148
  function parseFileName(fileName: string): BulkFileInfo | undefined {
122
149
  if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
123
150
  const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
124
- if (parts.length !== 3) return undefined;
151
+ // Accept both the old 3-part (level_timestamp_counter) and new 4-part
152
+ // (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
153
+ if (parts.length < 3) return undefined;
125
154
  const level = parseInt(parts[0], 10);
126
155
  const timestamp = parseInt(parts[1], 10);
127
156
  if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
@@ -160,24 +189,44 @@ export class BulkDatabaseBase<T extends { key: string }> {
160
189
  // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
161
190
  // so concurrent writers never touch the same file.
162
191
  private streamFileName: string | undefined;
163
- private streamRowsWritten = 0;
192
+ private lastCleanup = 0;
193
+ private lastFoldCheck = 0;
164
194
  private getStreamFileName(): string {
195
+ // Seal (stop appending to) our current file once it's old enough, so no file is ever appended
196
+ // to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
197
+ if (this.streamFileName) {
198
+ const info = parseStreamFileName(this.streamFileName);
199
+ if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
200
+ }
165
201
  if (!this.streamFileName) {
166
202
  this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
167
203
  }
168
204
  return this.streamFileName;
169
205
  }
170
206
 
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);
207
+ private invalidateOverlay(key: string) {
174
208
  this.deps.invalidate(key);
175
209
  this.deps.invalidate(OVERLAY_SIGNAL);
176
210
  }
177
211
 
178
- private reader = lazy(async (): Promise<BaseBulkDatabaseReader> => {
212
+ // Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
213
+ // only changes the columns it includes — the rest fall through to disk on read. A prior delete is
214
+ // reset (the key is being re-created).
215
+ private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
216
+ const existing = this.overlay.get(key);
217
+ const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
218
+ this.overlay.set(key, { time, value });
219
+ this.invalidateOverlay(key);
220
+ }
221
+
222
+ private setOverlayDeleted(key: string, time: number) {
223
+ this.overlay.set(key, { time, value: DELETED });
224
+ this.invalidateOverlay(key);
225
+ }
226
+
227
+ private reader = lazy(async (): Promise<ResolvedReader> => {
179
228
  let start = Date.now();
180
- const [bulkFiles, streamFiles] = await Promise.all([this.listFiles(), this.listStreamFiles()]);
229
+ const { bulkFiles, streamFiles } = await this.getValidFiles();
181
230
  // Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
182
231
  // A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
183
232
  // returning bad values: the write protocol always writes a new file before removing the old
@@ -194,8 +243,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
194
243
  this.loadStreamEntries(streamFiles),
195
244
  ]);
196
245
  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).
246
+ // The join resolves purely by write-time, so reader order doesn't matter.
199
247
  const readers: BaseBulkDatabaseReader[] = [];
200
248
  const ordered = this.orderStreamEntries(streamData.entries);
201
249
  if (ordered.length) {
@@ -206,13 +254,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
206
254
  this.streamTimes = new Map();
207
255
  }
208
256
  readers.push(...bulkReaders);
209
- const joined = joinBulkDatabases(readers);
257
+ const joined = await joinBulkDatabases(readers);
210
258
 
211
259
  let time = Date.now() - start;
212
260
  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)`);
261
+ console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
215
262
  }
263
+ void this.cleanup(); // opportunistic, throttled, fire-and-forget — reads help GC orphans too
216
264
  return joined;
217
265
  });
218
266
 
@@ -242,8 +290,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
242
290
  private applyRemote(write: RemoteWrite) {
243
291
  if (write.time <= this.localTime(write.key)) return;
244
292
  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> });
293
+ if (write.deleted) this.setOverlayDeleted(write.key, write.time);
294
+ else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
247
295
  });
248
296
  }
249
297
 
@@ -277,10 +325,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
277
325
  const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
278
326
  const framed = frameRows(stamped);
279
327
 
280
- // A batch that already exceeds the rollover limits skips tier-0 and writes a bulk file directly.
328
+ // A batch that already exceeds the limits skips the tier-0 stream and writes a bulk file directly
329
+ // (streaming thousands of rows one frame at a time would be pointless).
281
330
  if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
282
331
  await this.writeBulkFile(rows);
283
- this.resetReader();
284
332
  return;
285
333
  }
286
334
 
@@ -288,9 +336,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
288
336
  // overlay immediately — no reader reset.
289
337
  const storage = await this.storage();
290
338
  await storage.append(this.getStreamFileName(), framed);
291
- this.streamRowsWritten += entries.length;
292
339
  this.deps.batch(() => {
293
- for (const { time, row } of stamped) this.setOverlay(row.key as string, { time, value: row });
340
+ for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
294
341
  });
295
342
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
296
343
  await this.maybeRolloverStream();
@@ -306,38 +353,187 @@ export class BulkDatabaseBase<T extends { key: string }> {
306
353
  const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
307
354
  const storage = await this.storage();
308
355
  await storage.append(this.getStreamFileName(), frameDeletes(stamped));
309
- this.streamRowsWritten += keys.length;
310
356
  this.deps.batch(() => {
311
- for (const { time, key } of stamped) this.setOverlay(key, { time, value: DELETED });
357
+ for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
312
358
  });
313
359
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
314
360
  await this.maybeRolloverStream();
315
361
  }
316
362
 
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.
363
+ public async update(entry: Partial<T> & { key: string }): Promise<void> {
364
+ return this.updateBatch([entry]);
365
+ }
366
+
367
+ // Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
368
+ // key. Partial fields merge onto the existing row (unset columns fall through to the current value);
369
+ // an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
370
+ public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
371
+ if (!entries.length) return;
372
+ void this.syncSetup();
373
+ const reader = await this.reader();
374
+ const diskKeys = new Set(reader.keys);
375
+ const present: T[] = [];
376
+ for (const entry of entries) {
377
+ const overlayEntry = this.overlay.get(entry.key);
378
+ const exists = overlayEntry ? overlayEntry.value !== DELETED : diskKeys.has(entry.key);
379
+ if (!exists) {
380
+ console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
381
+ continue;
382
+ }
383
+ present.push(entry as unknown as T);
384
+ }
385
+ if (present.length) await this.writeBatch(present);
386
+ }
387
+
388
+ // Resolves the authoritative on-disk state via manifests (see manifest.ts): valid bulk files
389
+ // (newest-first) + valid stream files, plus the chosen manifest and the raw name lists the
390
+ // commit/cleanup paths need. No manifest at all => every bulk file is valid (back-compat). Stream
391
+ // files are valid unless the chosen manifest lists them as already folded into a bulk file.
392
+ private async getValidFiles(): Promise<{
393
+ bulkFiles: BulkFileInfo[];
394
+ streamFiles: StreamFileInfo[];
395
+ manifest: Manifest | undefined;
396
+ manifestName: string | undefined;
397
+ allBulkNames: string[];
398
+ allStreamNames: string[];
399
+ manifestNames: string[];
400
+ }> {
401
+ const storage = await this.storage();
402
+ const names = await storage.getKeys();
403
+ const manifestNames: string[] = [];
404
+ const allBulkNames: string[] = [];
405
+ const allStreamNames: string[] = [];
406
+ for (const n of names) {
407
+ if (isManifestName(n)) manifestNames.push(n);
408
+ else if (n.endsWith(FILE_EXTENSION)) allBulkNames.push(n);
409
+ else if (n.endsWith(STREAM_EXTENSION)) allStreamNames.push(n);
410
+ }
411
+ const parsed = (await Promise.all(manifestNames.map(async name => {
412
+ try {
413
+ const buf = await storage.get(name);
414
+ if (!buf) return undefined;
415
+ return { name, manifest: JSON.parse(buf.toString("utf8")) as Manifest };
416
+ } catch {
417
+ return undefined; // torn/corrupt/half-written manifest — ignore it
418
+ }
419
+ }))).filter((m): m is { name: string; manifest: Manifest } => !!m);
420
+ const chosen = chooseManifest(parsed);
421
+
422
+ let validBulkNames: string[];
423
+ if (!chosen) {
424
+ validBulkNames = allBulkNames;
425
+ } else {
426
+ const valid = new Set(chosen.manifest.validBulkFiles);
427
+ validBulkNames = allBulkNames.filter(n => valid.has(n));
428
+ }
429
+ const ignored = new Set(chosen?.manifest.ignoredStreamFiles || []);
430
+ const validStreamNames = allStreamNames.filter(n => !ignored.has(n));
431
+
432
+ const bulkFiles = validBulkNames.flatMap(n => { const p = parseFileName(n); return p ? [p] : []; });
433
+ // Newest-first by timestamp; ties broken by file name for determinism.
434
+ bulkFiles.sort((a, b) => {
435
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
436
+ return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
437
+ });
438
+ const streamFiles = validStreamNames.flatMap(n => { const p = parseStreamFileName(n); return p ? [p] : []; });
439
+ sort(streamFiles, f => f.timestamp);
440
+
441
+ return { bulkFiles, streamFiles, manifest: chosen?.manifest, manifestName: chosen?.name, allBulkNames, allStreamNames, manifestNames };
442
+ }
443
+
444
+ // Writes a brand-new manifest (never clobbering an existing one) capturing the full valid state.
445
+ private async commitManifest(startTime: number, readFiles: string[], validBulkFiles: string[], ignoredStreamFiles: string[]): Promise<void> {
446
+ const storage = await this.storage();
447
+ const manifest: Manifest = { startTime, validBulkFiles, ignoredStreamFiles, readFiles };
448
+ await storage.set(manifestFileName(startTime, writerId, nextCounter()), Buffer.from(JSON.stringify(manifest), "utf8"));
449
+ }
450
+
451
+ // Writes `rows` directly as bulk file(s), stamped with the current time as their data range (the rows
452
+ // are being written now). Used by the large-batch write path. Commits a new manifest adding them to
453
+ // the valid set. The rows carry time=now, so the join orders them correctly against any older stream
454
+ // entry for the same key (newer time wins) — no clobber.
321
455
  private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
322
456
  const storage = await this.storage();
323
- const timestamp = nextFileTime();
324
- for (const buffer of buildFileBuffer(rows)) {
325
- await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
457
+ const startTime = nextFileTime();
458
+ const view = await this.getValidFiles();
459
+ const now = Date.now();
460
+ const times = rows.map(() => now);
461
+ const newBulkNames: string[] = [];
462
+ for (const buffer of buildFileBuffer(rows, times)) {
463
+ const name = newFileName(startTime);
464
+ await storage.set(name, encodeCompressedBlocks(buffer));
465
+ newBulkNames.push(name);
326
466
  }
327
- if ((await this.listFiles()).length >= MERGE_FILE_COUNT) {
328
- while (await this.mergeFiles() > 0) { /* keep merging until no run can be consolidated */ }
467
+ const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
468
+ const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
469
+ await this.commitManifest(startTime, readFiles, validBulkFiles, view.manifest?.ignoredStreamFiles || []);
470
+ this.resetReader();
471
+ if (validBulkFiles.length >= MERGE_FILE_COUNT) {
472
+ while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
329
473
  }
474
+ await this.cleanup();
330
475
  }
331
476
 
332
- private async listStreamFiles(): Promise<StreamFileInfo[]> {
477
+ // Fold. Reads every stream file CREATED before the cutoff (all sealed, since seal age < fold age),
478
+ // resolves them into one bulk file carrying each key's latest write-time per row, and re-persists
479
+ // surviving tombstones to a fresh stream file (so deletes of keys living in older bulk files keep
480
+ // suppressing them). Because reads resolve by write-time, the folded data needn't be older than the
481
+ // stream — the join sorts it out — so we just fold whole files, no cutoff split. Folded files are
482
+ // marked ignored (cleanup deletes them later); the new manifest is the atomic swap.
483
+ private async consolidate(): Promise<void> {
333
484
  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;
485
+ const startTime = nextFileTime();
486
+ const view = await this.getValidFiles();
487
+ const cutoff = Date.now() - bulkDatabase2Timing.foldDataAgeMs;
488
+ const selected = view.streamFiles.filter(f => f.timestamp < cutoff);
489
+ if (!selected.length) return;
490
+ const { entries } = await this.loadStreamEntries(selected);
491
+ const ordered = this.orderStreamEntries(entries);
492
+
493
+ const byKey = new Map<string, Record<string, unknown>>();
494
+ const byKeyTime = new Map<string, number>();
495
+ const deleted = new Map<string, number>();
496
+ for (const e of ordered) {
497
+ if (e.deletedKey !== undefined) {
498
+ byKey.delete(e.deletedKey);
499
+ byKeyTime.delete(e.deletedKey);
500
+ deleted.set(e.deletedKey, e.time);
501
+ } else if (e.row) {
502
+ const key = e.row.key as string;
503
+ byKey.set(key, { ...byKey.get(key), ...e.row });
504
+ byKeyTime.set(key, e.time); // ordered ascending, so this ends up the latest write
505
+ deleted.delete(key);
506
+ }
507
+ }
508
+
509
+ const newBulkNames: string[] = [];
510
+ if (byKey.size) {
511
+ const rows = [...byKey.values()];
512
+ const times = rows.map(r => byKeyTime.get(r.key as string)!);
513
+ for (const buffer of buildFileBuffer(rows, times)) {
514
+ const name = newFileName(startTime);
515
+ await storage.set(name, encodeCompressedBlocks(buffer));
516
+ newBulkNames.push(name);
517
+ }
518
+ }
519
+
520
+ // Surviving tombstones -> a fresh stream file (always valid). Written BEFORE the manifest so the
521
+ // folded sources are never ignored while their deletes are missing.
522
+ if (deleted.size) {
523
+ const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
524
+ await storage.set(carryName, frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
525
+ }
526
+
527
+ const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
528
+ const ignoredStreamFiles = [...new Set([...(view.manifest?.ignoredStreamFiles || []), ...selected.map(f => f.fileName)])];
529
+ const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
530
+ await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
531
+ this.resetReader();
532
+
533
+ if (validBulkFiles.length >= MERGE_FILE_COUNT) {
534
+ while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
535
+ }
536
+ await this.cleanup();
341
537
  }
342
538
 
343
539
  // Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
@@ -345,7 +541,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
345
541
  private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
346
542
  if (!streamFiles.length) return { entries: [], totalBytes: 0 };
347
543
  const storage = await this.storage();
348
- const buffers = await Promise.all(streamFiles.map(f => storage.get(f.fileName)));
544
+ // Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
545
+ // concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
546
+ // prefix is tolerant — parseStream stops at the last complete frame, the file stays valid, and a
547
+ // later read picks up the rest. A file removed out from under us (cleanup) just yields undefined.
548
+ const buffers = await Promise.all(streamFiles.map(async f => {
549
+ try {
550
+ const info = await storage.getInfo(f.fileName);
551
+ if (!info || info.size === 0) return undefined;
552
+ return await storage.getRange(f.fileName, { start: 0, end: info.size });
553
+ } catch {
554
+ return undefined;
555
+ }
556
+ }));
349
557
  const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
350
558
  let totalBytes = 0;
351
559
  for (let i = 0; i < streamFiles.length; i++) {
@@ -372,51 +580,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
372
580
  return entries.map(e => e.entry);
373
581
  }
374
582
 
583
+ // Folding is purely age-driven and done in big infrequent batches: once some stream file is older
584
+ // than the trigger age, fold everything past the data cutoff. Throttled so we don't scan on every write.
375
585
  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);
586
+ const now = Date.now();
587
+ if (now - this.lastFoldCheck < bulkDatabase2Timing.foldCheckIntervalMs) return;
588
+ this.lastFoldCheck = now;
589
+ const { streamFiles } = await this.getValidFiles();
590
+ if (streamFiles.some(f => now - f.timestamp >= bulkDatabase2Timing.foldTriggerAgeMs)) {
591
+ await this.consolidate();
385
592
  }
386
593
  }
387
594
 
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
595
  // Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
421
596
  // more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
422
597
  // so it never loads the whole collection into memory — a multi-GB collection settles into several
@@ -427,23 +602,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
427
602
  if (merged) this.resetReader();
428
603
  }
429
604
 
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
605
  private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
448
606
  const storage = await this.storage();
449
607
  const info = await storage.getInfo(fileName);
@@ -507,51 +665,84 @@ export class BulkDatabaseBase<T extends { key: string }> {
507
665
  console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
508
666
  }
509
667
 
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> {
668
+ // Merges exactly the files it's given, returning the new file name(s). It does NOT delete the inputs
669
+ // or write a manifest the caller (mergeFiles) commits one manifest for the whole pass. The merge is
670
+ // per-COLUMN by write-TIME: for each key, each column takes the value with the newest write-time
671
+ // across the merged files (non-ABSENT), and the output row's time is the newest of those — so the
672
+ // merge preserves correct time-resolution (only collapsing per-column times within a single output
673
+ // row, the accepted per-row corner). The caller keeps the input under MERGE_MAX_BYTES.
674
+ private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<string[]> {
515
675
  const storage = await this.storage();
516
676
  const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
517
677
 
518
- const seen = new Set<string>();
519
- const mergedRows: Record<string, unknown>[] = [];
520
- for (const reader of readers) {
521
- const colData: Record<string, unknown[]> = {};
678
+ const loaded = await Promise.all(readers.map(async reader => {
679
+ const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
522
680
  for (const col of reader.columns) {
523
- colData[col.column] = (await reader.getColumn(col.column)).map(r => r.value);
681
+ if (col.column === KEY_COLUMN) continue;
682
+ const entries = await reader.getColumn(col.column);
683
+ cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
524
684
  }
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];
685
+ return { keyTimes: reader.keyTimes, cols };
686
+ }));
687
+
688
+ const allKeys = new Set<string>();
689
+ const allCols = new Set<string>();
690
+ for (const l of loaded) {
691
+ for (const k of l.keyTimes.keys()) allKeys.add(k);
692
+ for (const c of l.cols.keys()) allCols.add(c);
693
+ }
694
+
695
+ const mergedRows: Record<string, unknown>[] = [];
696
+ const mergedTimes: number[] = [];
697
+ for (const key of allKeys) {
698
+ const row: Record<string, unknown> = { [KEY_COLUMN]: key };
699
+ let rowTime = -Infinity;
700
+ for (const col of allCols) {
701
+ let bestTime = -Infinity;
702
+ let bestVal: unknown;
703
+ let found = false;
704
+ for (const l of loaded) {
705
+ const cell = l.cols.get(col)?.get(key);
706
+ if (!cell || cell.value === ABSENT) continue;
707
+ if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
532
708
  }
533
- mergedRows.push(row);
709
+ if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
534
710
  }
711
+ for (const l of loaded) { const t = l.keyTimes.get(key); if (t !== undefined && t > rowTime) rowTime = t; }
712
+ mergedRows.push(row);
713
+ mergedTimes.push(rowTime === -Infinity ? 0 : rowTime);
535
714
  }
536
715
 
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);
716
+ const names: string[] = [];
717
+ for (const buffer of buildFileBuffer(mergedRows, mergedTimes)) {
718
+ const name = newFileName(timestamp);
719
+ await storage.set(name, encodeCompressedBlocks(buffer));
720
+ names.push(name);
544
721
  }
722
+ return names;
545
723
  }
546
724
 
547
- // The cap-aware merge planner. Walks the files newest-first and merges contiguous runs of
725
+ // The cap-aware merge planner. Walks the valid files newest-first and merges contiguous runs of
548
726
  // non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
549
727
  // 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).
728
+ // breaks the run, as does an unreadable file. The whole pass is committed as ONE new manifest:
729
+ // valid bulk = (old valid - consumed) + merged outputs; the consumed files are left on disk for
730
+ // cleanup. Returns the number of runs merged (0 means nothing left to consolidate).
553
731
  private async mergeFiles(): Promise<number> {
554
- const files = await this.listFiles();
732
+ // Best-effort cross-tab lock: if another tab is already merging, skip — the manifest backstop
733
+ // keeps us correct, and we'd only be racing to orphan each other's output. No-op in Node.
734
+ if (!tryAcquireMergeLock(this.name, writerId)) return 0;
735
+ try {
736
+ return await this.mergeFilesLocked();
737
+ } finally {
738
+ releaseMergeLock(this.name, writerId);
739
+ }
740
+ }
741
+
742
+ private async mergeFilesLocked(): Promise<number> {
743
+ const startTime = nextFileTime();
744
+ const view = await this.getValidFiles();
745
+ const files = view.bulkFiles;
555
746
  const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
556
747
 
557
748
  const batches: BulkFileInfo[][] = [];
@@ -574,25 +765,80 @@ export class BulkDatabaseBase<T extends { key: string }> {
574
765
  batchBytes += size;
575
766
  }
576
767
  flush();
768
+ if (!batches.length) return 0;
577
769
 
578
- // batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
770
+ const removed = new Set<string>();
771
+ const newBulkNames: string[] = [];
579
772
  for (const runFiles of batches) {
580
- await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
773
+ // batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
774
+ const produced = await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
775
+ for (const f of runFiles) removed.add(f.fileName);
776
+ newBulkNames.push(...produced);
581
777
  }
778
+ const validBulkFiles = files.map(f => f.fileName).filter(n => !removed.has(n)).concat(newBulkNames);
779
+ const ignoredStreamFiles = view.manifest?.ignoredStreamFiles || [];
780
+ const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
781
+ await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
782
+ this.resetReader();
783
+ await this.cleanup();
582
784
  return batches.length;
583
785
  }
584
786
 
585
- private formatInfo(reader: BaseBulkDatabaseReader): string {
787
+ // Deletes files no longer referenced by the authoritative manifest that have sat long enough that
788
+ // no reader still resolving an older manifest needs them: superseded/orphaned bulk files,
789
+ // folded-away (ignored) stream files, and every manifest but the newest. Age-gated by each file's
790
+ // own name timestamp and throttled per instance. Best-effort — a failed remove (another writer beat
791
+ // us to it) is ignored. We never delete a file whose name we can't parse for an age.
792
+ private async cleanup(): Promise<void> {
793
+ const now = Date.now();
794
+ if (now - this.lastCleanup < bulkDatabase2Timing.cleanupIntervalMs) return;
795
+ this.lastCleanup = now;
796
+ // Best-effort and must never throw: it runs fire-and-forget from reads, and the directory could
797
+ // even be removed out from under us (e.g. the collection is being deleted) mid-scan.
798
+ try {
799
+ const storage = await this.storage();
800
+ const view = await this.getValidFiles();
801
+ const validBulk = new Set(view.bulkFiles.map(f => f.fileName));
802
+ const validStream = new Set(view.streamFiles.map(f => f.fileName));
803
+ const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
804
+
805
+ for (const name of view.allBulkNames) {
806
+ if (validBulk.has(name)) continue;
807
+ const info = parseFileName(name);
808
+ if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
809
+ await remove(name);
810
+ }
811
+ for (const name of view.allStreamNames) {
812
+ if (validStream.has(name)) continue;
813
+ const info = parseStreamFileName(name);
814
+ if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
815
+ await remove(name);
816
+ }
817
+ for (const name of view.manifestNames) {
818
+ if (name === view.manifestName) continue;
819
+ const startTime = parseManifestStartTime(name);
820
+ if (startTime === undefined || now - startTime < bulkDatabase2Timing.cleanupAgeMs) continue;
821
+ await remove(name);
822
+ }
823
+ } catch {
824
+ // ignore — cleanup is opportunistic; the next pass will catch up
825
+ }
826
+ }
827
+
828
+ private formatInfo(reader: ResolvedReader): string {
586
829
  return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
587
830
  }
588
831
 
589
- // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
832
+ // Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
833
+ // overlay entry that doesn't include this column leaves the base (disk) value in place — a partial
834
+ // write/update only overrides the columns it set; everything else falls through.
590
835
  private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
591
836
  if (this.overlay.size === 0) return base;
592
837
  const map = new Map(base.map(e => [e.key, e.value]));
593
838
  for (const [key, entry] of this.overlay) {
594
- if (entry.value === DELETED) map.delete(key);
595
- else map.set(key, entry.value[column]);
839
+ if (entry.value === DELETED) { map.delete(key); continue; }
840
+ if (column in entry.value) map.set(key, entry.value[column]);
841
+ else if (!map.has(key)) map.set(key, undefined);
596
842
  }
597
843
  return [...map].map(([key, value]) => ({ key, value }));
598
844
  }
@@ -604,7 +850,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
604
850
  const entry = this.overlay.get(key);
605
851
  if (entry !== undefined) {
606
852
  if (entry.value === DELETED) return undefined;
607
- return entry.value[String(column)] as T[Column];
853
+ if (String(column) in entry.value) return entry.value[String(column)] as T[Column];
854
+ // column not set in the overlay entry — fall through to disk
608
855
  }
609
856
  let time = Date.now();
610
857
  let reader = await this.reader();
@@ -689,7 +936,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
689
936
  let entry = this.overlay.get(key);
690
937
  if (entry !== undefined) {
691
938
  if (entry.value === DELETED) return undefined;
692
- return entry.value[col] as T[Column];
939
+ if (col in entry.value) return entry.value[col] as T[Column];
940
+ // column not set in the overlay entry — fall through to the base field cache
693
941
  }
694
942
  let cacheKey = nullJoin(col, key);
695
943
  if (!this.baseFields.has(cacheKey)) {
@@ -730,24 +978,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
730
978
  }
731
979
  }
732
980
 
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>();
981
+ // The merged, time-resolved view over all readers. getColumn/getSingleField return plain resolved
982
+ // values (not {value,time}); the base layers the overlay on top of these.
983
+ type ResolvedReader = {
984
+ rowCount: number;
985
+ totalBytes: number;
986
+ keys: string[];
987
+ columns: { column: string; byteSize: number }[];
988
+ getColumn: (column: string) => Promise<{ key: string; value: unknown }[]>;
989
+ getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
990
+ };
991
+
992
+ // Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
993
+ // - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
994
+ // a reader that never set the column for that key falls through to an older reader.
995
+ // - a key is live iff its newest write is newer than its newest delete; per column, the value is
996
+ // suppressed if a delete is newer than that column's newest set.
997
+ // No reliance on file order or partitioning — time is the only thing that decides.
998
+ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
999
+ const deleteTime = new Map<string, number>();
738
1000
  for (const db of databases) {
739
- if (db.deletedKeys) for (const key of db.deletedKeys) deleted.add(key);
1001
+ if (!db.deleteTimes) continue;
1002
+ for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
740
1003
  }
741
-
742
- const keys: string[] = [];
743
- const keySeen = new Set<string>();
1004
+ const keyTime = new Map<string, number>();
744
1005
  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
- }
1006
+ for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
750
1007
  }
1008
+ const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
1009
+ // Live keys: newest write strictly newer than newest delete.
1010
+ const keys: string[] = [];
1011
+ for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
1012
+
751
1013
  const columns: { column: string; byteSize: number }[] = [];
752
1014
  const columnByName = new Map<string, { column: string; byteSize: number }>();
753
1015
  for (const db of databases) {
@@ -761,37 +1023,41 @@ function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabas
761
1023
  existing.byteSize += col.byteSize;
762
1024
  }
763
1025
  }
1026
+
764
1027
  return {
765
1028
  totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
766
1029
  rowCount: keys.length,
767
1030
  keys,
768
1031
  columns,
769
1032
  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);
1033
+ const perReader = await Promise.all(databases.map(async db => {
1034
+ if (!db.columns.some(c => c.column === column)) return undefined;
1035
+ const entries = await db.getColumn(column);
1036
+ return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
1037
+ }));
1038
+ return keys.map(key => {
1039
+ let bestTime = -Infinity;
1040
+ let bestVal: unknown;
1041
+ let found = false;
1042
+ for (const m of perReader) {
1043
+ const cell = m && m.get(key);
1044
+ if (!cell || cell.value === ABSENT) continue;
1045
+ if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
777
1046
  }
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;
1047
+ return { key, value: (found && bestTime > delOf(key)) ? bestVal : undefined };
1048
+ });
786
1049
  },
787
1050
  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);
1051
+ let bestTime = -Infinity;
1052
+ let bestVal: unknown;
1053
+ let found = false;
1054
+ for (const db of databases) {
1055
+ if (!db.columns.some(c => c.column === column)) continue;
1056
+ const r = await db.getSingleField(key, column);
1057
+ if (r === ABSENT) continue;
1058
+ if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
793
1059
  }
794
- return undefined;
1060
+ return (found && bestTime > delOf(key)) ? bestVal : undefined;
795
1061
  },
796
1062
  };
797
1063
  }