sliftutils 1.4.43 → 1.4.45

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.
package/index.d.ts CHANGED
@@ -912,6 +912,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
912
912
  firstMergeTriggerRangeMs: number;
913
913
  streamFoldTriggerRows: number;
914
914
  streamFoldTriggerBytes: number;
915
+ streamFileMaxBytes: number;
916
+ streamFoldHardLimitBytes: number;
915
917
  writeFlushMaxDelayMs: number;
916
918
  fileSetPollIntervalMs: number;
917
919
  };
@@ -965,6 +967,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
965
967
  private streamRowsOnDisk;
966
968
  private streamBytesOnDisk;
967
969
  private streamFileName;
970
+ private currentStreamFileName;
971
+ private currentStreamFileBytes;
968
972
  private lastMergeCheck;
969
973
  private getStreamFileName;
970
974
  private invalidateOverlay;
@@ -991,6 +995,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
991
995
  flush(): Promise<void>;
992
996
  private flushPending;
993
997
  private doFlush;
998
+ private foldOwnStream;
994
999
  update(entry: Partial<T> & {
995
1000
  key: string;
996
1001
  }): Promise<void>;
@@ -1038,6 +1043,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1038
1043
  private baseColumnsLoading;
1039
1044
  private baseFields;
1040
1045
  private baseFieldsLoading;
1046
+ private staleBaseColumns;
1047
+ private staleBaseFields;
1041
1048
  private ensureBaseColumn;
1042
1049
  private ensureBaseField;
1043
1050
  getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.43",
3
+ "version": "1.4.45",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -8,6 +8,8 @@ export declare const bulkDatabase2Timing: {
8
8
  firstMergeTriggerRangeMs: number;
9
9
  streamFoldTriggerRows: number;
10
10
  streamFoldTriggerBytes: number;
11
+ streamFileMaxBytes: number;
12
+ streamFoldHardLimitBytes: number;
11
13
  writeFlushMaxDelayMs: number;
12
14
  fileSetPollIntervalMs: number;
13
15
  };
@@ -61,6 +63,8 @@ export declare class BulkDatabaseBase<T extends {
61
63
  private streamRowsOnDisk;
62
64
  private streamBytesOnDisk;
63
65
  private streamFileName;
66
+ private currentStreamFileName;
67
+ private currentStreamFileBytes;
64
68
  private lastMergeCheck;
65
69
  private getStreamFileName;
66
70
  private invalidateOverlay;
@@ -87,6 +91,7 @@ export declare class BulkDatabaseBase<T extends {
87
91
  flush(): Promise<void>;
88
92
  private flushPending;
89
93
  private doFlush;
94
+ private foldOwnStream;
90
95
  update(entry: Partial<T> & {
91
96
  key: string;
92
97
  }): Promise<void>;
@@ -134,6 +139,8 @@ export declare class BulkDatabaseBase<T extends {
134
139
  private baseColumnsLoading;
135
140
  private baseFields;
136
141
  private baseFieldsLoading;
142
+ private staleBaseColumns;
143
+ private staleBaseFields;
137
144
  private ensureBaseColumn;
138
145
  private ensureBaseField;
139
146
  getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
@@ -86,6 +86,16 @@ export const bulkDatabase2Timing = {
86
86
  // throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
87
87
  streamFoldTriggerRows: 100,
88
88
  streamFoldTriggerBytes: 64 * 1024 * 1024,
89
+ // Rolling cap on OUR OWN current stream file: once we've appended this much to it, we seal it (stop
90
+ // writing) and fold it into bulk immediately. Without this, many small appends grow a single stream
91
+ // file without bound (the per-batch ROLLOVER only catches one huge batch). We can fold our own file at
92
+ // once — the 10h seal-age rule is only for other writers' files; we know we're done with ours on seal.
93
+ streamFileMaxBytes: 50 * 1024 * 1024,
94
+ // HARD limit: once the stream tier passes this, fold it NOW regardless of age (even un-sealed, recent
95
+ // streams) — a stream this large makes every read pull a huge file, i.e. the collection becomes
96
+ // essentially unreadable. We still only delete a stream whose size didn't change while we read it, so
97
+ // a writer mid-append never loses data (it just gets re-folded next pass).
98
+ streamFoldHardLimitBytes: 768 * 1024 * 1024,
89
99
  // Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
90
100
  // this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
91
101
  // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
@@ -369,6 +379,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
369
379
  // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
370
380
  // so concurrent writers never touch the same file.
371
381
  private streamFileName: string | undefined;
382
+ // Rolling size of the current stream file, so we can seal+fold it once it passes STREAM_FILE_MAX_BYTES
383
+ // (reset whenever the file rotates). currentStreamFileName tracks which file the count belongs to.
384
+ private currentStreamFileName: string | undefined;
385
+ private currentStreamFileBytes = 0;
372
386
  // Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
373
387
  // first write — the first background merge check waits a full interval after construction.
374
388
  private lastMergeCheck = Date.now();
@@ -563,6 +577,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
563
577
  // buildReader. Does NOT touch the overlay — callers decide what to do with pending writes.
564
578
  private clearReaderState() {
565
579
  this.reader.reset();
580
+ // Preserve the last-known sync base values so a reload/compact serves them (not empty) while the
581
+ // fresh ones reload — observers transition old → new, never flashing through nothing.
582
+ for (const [k, v] of this.baseColumns) this.staleBaseColumns.set(k, v);
583
+ for (const [k, v] of this.baseFields) this.staleBaseFields.set(k, v);
566
584
  this.baseColumns.clear();
567
585
  this.baseColumnsLoading.clear();
568
586
  this.baseFields.clear();
@@ -726,13 +744,41 @@ export class BulkDatabaseBase<T extends { key: string }> {
726
744
  const batch = this.pendingAppends.slice();
727
745
  const combined = Buffer.concat(batch.map(p => p.framed));
728
746
  const storage = await this.storage();
747
+ const fileName = this.getStreamFileName();
748
+ if (fileName !== this.currentStreamFileName) { // rotated (age-seal / first write) → reset the count
749
+ this.currentStreamFileName = fileName;
750
+ this.currentStreamFileBytes = 0;
751
+ }
729
752
  // Throws on failure -> we don't splice, so the data stays buffered and a later flush retries it.
730
- await storage.append(this.getStreamFileName(), combined);
753
+ await storage.append(fileName, combined);
731
754
  // New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
732
755
  this.pendingAppends.splice(0, batch.length);
733
756
  // Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
734
757
  this.streamBytesOnDisk += combined.length;
735
758
  for (const p of batch) this.streamRowsOnDisk += p.rows;
759
+ this.currentStreamFileBytes += combined.length;
760
+ // Cap our own stream file: once it's grown past the limit, seal it (next write starts a fresh file)
761
+ // and fold this now-complete file into bulk in the background. New writes go to the new file, so the
762
+ // sealed one is stable and safe to fold+delete immediately — no 10h wait for our own files.
763
+ if (this.currentStreamFileBytes >= bulkDatabase2Timing.streamFileMaxBytes) {
764
+ this.streamFileName = undefined;
765
+ this.currentStreamFileName = undefined;
766
+ this.currentStreamFileBytes = 0;
767
+ void this.foldOwnStream(fileName);
768
+ }
769
+ }
770
+
771
+ // Fold one of OUR OWN sealed stream files into bulk and delete it. Safe to delete immediately (force):
772
+ // we've stopped appending to it (a new file is current now), and canDeleteStream still only deletes it
773
+ // if its size is unchanged since we read it, so an in-flight flush can never lose data.
774
+ private async foldOwnStream(fileName: string): Promise<void> {
775
+ const info = parseStreamFileName(fileName);
776
+ if (!info) return;
777
+ try {
778
+ await this.mergeFileSet([], [info], false, true);
779
+ } catch (e) {
780
+ console.warn(`${this.name}: folding own stream ${fileName} failed: ${(e as Error).message}`);
781
+ }
736
782
  }
737
783
 
738
784
  public async update(entry: Partial<T> & { key: string }): Promise<void> {
@@ -1093,7 +1139,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1093
1139
  // outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
1094
1140
  // nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
1095
1141
  // merge that reaches time 0 are the cases where this holds.)
1096
- private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
1142
+ private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
1097
1143
  const storage = await this.storage();
1098
1144
  const timestamp = nextFileTime();
1099
1145
  const now = Date.now();
@@ -1155,7 +1201,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1155
1201
  const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
1156
1202
  for (const f of consumedBulk) await remove(f.fileName);
1157
1203
  for (const f of streamFiles) {
1158
- if (await this.canDeleteStream(f, now, streamData.sizes)) await remove(f.fileName);
1204
+ if (await this.canDeleteStream(f, now, streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
1159
1205
  }
1160
1206
 
1161
1207
  this.resetReader();
@@ -1168,9 +1214,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
1168
1214
  // the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
1169
1215
  // and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
1170
1216
  // data, but the aged check also rules out the rare sparse-offset append race.)
1171
- private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>): Promise<boolean> {
1217
+ private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>, force = false): Promise<boolean> {
1172
1218
  if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
1173
- if (!isSyncSupported()) return false;
1219
+ // Without cross-tab sync we normally can't tell a writer is done before the seal age — UNLESS the
1220
+ // caller forces it (the hard stream limit). Even then we only delete a stream whose size didn't
1221
+ // change while we read it, so a writer mid-append never loses data (it's re-folded next pass).
1222
+ if (!isSyncSupported() && !force) return false;
1174
1223
  const readSize = sizes.get(f.fileName);
1175
1224
  if (readSize === undefined) return false;
1176
1225
  let info;
@@ -1217,6 +1266,23 @@ export class BulkDatabaseBase<T extends { key: string }> {
1217
1266
  return true;
1218
1267
  };
1219
1268
 
1269
+ // ── Hard stream limit: once the tier-0 stream has grown past the hard cap, fold ALL of it into bulk
1270
+ // NOW regardless of age — a stream this big makes every read pull a huge file, i.e. the collection
1271
+ // is essentially unreadable. Force-delete the folded streams (canDeleteStream still only deletes
1272
+ // size-stable ones, so an active writer never loses data; it's just re-folded next pass). ──
1273
+ {
1274
+ const { streamFiles } = await this.listFiles();
1275
+ if (streamFiles.length) {
1276
+ const storage = await this.storage();
1277
+ const sizes = await Promise.all(streamFiles.map(async f => { try { return (await storage.getInfo(f.fileName))?.size ?? 0; } catch { return 0; } }));
1278
+ const totalStreamBytes = sizes.reduce((a, b) => a + b, 0);
1279
+ if (totalStreamBytes > bulkDatabase2Timing.streamFoldHardLimitBytes) {
1280
+ console.log(`${blue(this.name)} stream tier ${fmtBytes(totalStreamBytes)} over hard limit ${fmtBytes(bulkDatabase2Timing.streamFoldHardLimitBytes)} — folding all streams now`);
1281
+ if (await this.mergeFileSet([], streamFiles, false, true)) merged = true;
1282
+ }
1283
+ }
1284
+ }
1285
+
1220
1286
  // ── Pass 1: consolidate recent files. ──
1221
1287
  // Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
1222
1288
  // actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
@@ -1249,12 +1315,16 @@ export class BulkDatabaseBase<T extends { key: string }> {
1249
1315
  if (recentBytes >= FIRST_MERGE_BYTES) break;
1250
1316
  }
1251
1317
  const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
1252
- // Fold when there's enough fragmentation to consolidate, OR when a foldable stream has grown too
1253
- // big to keep reading whole (even if it's the only recent file) see streamNeedsFold.
1254
- const heavyStreamRecent = this.streamNeedsFold() && recent.some(i => i.kind === "stream");
1318
+ // Fold when there's enough fragmentation to consolidate, OR when the foldable stream data here
1319
+ // has grown past the byte threshold (stream data can't be read per-cell, so a big stream is
1320
+ // costly to pull whole). The stream size is derived from THIS fresh listing — not a cached
1321
+ // counter — so it's correct even for an instance that never built a reader (e.g. the host's
1322
+ // autocompactor) and for streams that grew in place since the index was last loaded.
1323
+ const recentStreamBytes = recent.reduce((a, it) => a + (it.kind === "stream" ? it.bytes : 0), 0);
1324
+ const heavyStream = recentStreamBytes > bulkDatabase2Timing.streamFoldTriggerBytes;
1255
1325
  const triggered =
1256
1326
  recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
1257
- || heavyStreamRecent && recent.length >= 1;
1327
+ || heavyStream;
1258
1328
  if (triggered) {
1259
1329
  const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
1260
1330
  const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
@@ -1416,6 +1486,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1416
1486
  // (Map.has distinguishes "loaded" from "not loaded yet").
1417
1487
  private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
1418
1488
  private baseFieldsLoading = new Set<string>();
1489
+ // Last-known sync base values, kept across a reader reset so a reload/compact serves the previous data
1490
+ // instead of flashing empty while the fresh value reloads in the background. Each entry is dropped once
1491
+ // ensureBase* has the corresponding fresh value (so reads transition old → new, never old → empty → new).
1492
+ private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
1493
+ private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
1419
1494
 
1420
1495
  private ensureBaseColumn(column: string) {
1421
1496
  if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
@@ -1425,6 +1500,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1425
1500
  const base = await this.readWithReload(reader => reader.getColumn(column));
1426
1501
  this.deps.batch(() => {
1427
1502
  this.baseColumns.set(column, base);
1503
+ this.staleBaseColumns.delete(column); // fresh value in hand; stop serving the stale one
1428
1504
  this.baseColumnsLoading.delete(column);
1429
1505
  this.invalidateSignal(LOAD_SIGNAL);
1430
1506
  });
@@ -1446,6 +1522,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1446
1522
  const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
1447
1523
  this.deps.batch(() => {
1448
1524
  this.baseFields.set(cacheKey, resolved);
1525
+ this.staleBaseFields.delete(cacheKey); // fresh value in hand; stop serving the stale one
1449
1526
  this.baseFieldsLoading.delete(cacheKey);
1450
1527
  this.invalidateSignal(LOAD_SIGNAL);
1451
1528
  });
@@ -1470,15 +1547,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
1470
1547
  let entry = this.overlay.get(key);
1471
1548
  if (entry !== undefined) {
1472
1549
  if (entry.value === DELETED) return undefined;
1473
- if (col in entry.value) return { key, value: entry.value[col] as T[Column], time: entry.time };
1550
+ if (col in entry.value) {
1551
+ // Warm the disk-backed base in the background even though the overlay serves this now — so
1552
+ // when the overlay is later cleared (e.g. compaction persists it), there's a value to serve
1553
+ // and the read doesn't flash empty.
1554
+ this.ensureBaseField(key, col);
1555
+ return { key, value: entry.value[col] as T[Column], time: entry.time };
1556
+ }
1474
1557
  // column not set in the overlay entry — fall through to the base field cache for this column
1475
1558
  }
1476
1559
  let cacheKey = nullJoin(col, key);
1477
- if (!this.baseFields.has(cacheKey)) {
1560
+ // Use the fresh value if loaded; mid-reload fall back to the last-known one so we don't flash empty.
1561
+ let src: Map<string, { value: unknown; time: number } | undefined> | undefined;
1562
+ if (this.baseFields.has(cacheKey)) {
1563
+ src = this.baseFields;
1564
+ } else {
1478
1565
  this.ensureBaseField(key, col);
1566
+ src = this.staleBaseFields.has(cacheKey) ? this.staleBaseFields : undefined;
1567
+ }
1568
+ if (!src) {
1569
+ // Genuine first load (nothing known); but an overlay entry makes the key live with this column unset.
1570
+ if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
1479
1571
  return undefined;
1480
1572
  }
1481
- const base = this.baseFields.get(cacheKey);
1573
+ const base = src.get(cacheKey);
1482
1574
  if (base === undefined) {
1483
1575
  // Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
1484
1576
  if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
@@ -1498,7 +1590,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1498
1590
  let base = this.baseColumns.get(col);
1499
1591
  if (!base) {
1500
1592
  this.ensureBaseColumn(col);
1501
- return undefined;
1593
+ // Mid-reload: serve the last-known value (patched with the current overlay) so we don't flash
1594
+ // empty. Don't cache it — it's stale until ensureBaseColumn swaps in the fresh value.
1595
+ const stale = this.staleBaseColumns.get(col);
1596
+ if (stale) return this.patchColumn(stale, col) as { key: string; value: T[Column]; time: number }[];
1597
+ return undefined; // genuine first load — nothing known yet
1502
1598
  }
1503
1599
  // Synchronous (no awaits) → reads the current overlay and caches it atomically; an observer re-runs
1504
1600
  // (and the cache is cleared) on any later change, so the frozen result is safe to share.