sliftutils 1.4.46 → 1.4.48

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
@@ -715,7 +715,7 @@ declare module "sliftutils/render-utils/colors" {
715
715
  }
716
716
 
717
717
  declare module "sliftutils/render-utils/mobxTyped" {
718
- export { observable, runInAction, computed, autorun } from "mobx";
718
+ export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
719
719
  export declare function configureMobxNextFrameScheduler(): void;
720
720
 
721
721
  }
@@ -763,7 +763,7 @@ declare module "sliftutils/render-utils/observer" {
763
763
  }
764
764
 
765
765
  declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
766
- import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
766
+ import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
767
767
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
768
768
  export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
769
769
  /** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
@@ -853,6 +853,17 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
853
853
  isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
854
854
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
855
855
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
856
+ /**
857
+ * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
858
+ * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
859
+ * returns true if the backend can't tell.
860
+ */
861
+ isKeyWatched(key: string): boolean;
862
+ /**
863
+ * Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
864
+ * and reloads from disk. Pending un-flushed writes are kept. Per-collection.
865
+ */
866
+ reloadFromDisk(): void;
856
867
  /** The columns present on disk and their byte sizes (no row data read). */
857
868
  getColumnInfo(): Promise<BulkColumnInfo[]>;
858
869
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -901,6 +912,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
901
912
  * most callers want compact() or tryMergeNow(). */
902
913
  merge(timeLo: number, timeHi: number): Promise<void>;
903
914
  }
915
+ export declare class MobxReactiveDeps implements ReactiveDeps {
916
+ private boxes;
917
+ private observed;
918
+ private box;
919
+ observe(signal: string): void;
920
+ invalidate(signal: string): void;
921
+ batch(fn: () => void): void;
922
+ isObserved(signal: string): boolean;
923
+ }
904
924
  export declare class BulkDatabase2<T extends {
905
925
  key: string;
906
926
  }> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
@@ -924,11 +944,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
924
944
  streamFoldHardLimitBytes: number;
925
945
  writeFlushMaxDelayMs: number;
926
946
  fileSetPollIntervalMs: number;
947
+ memoryFlushHeapBytes: number;
948
+ memoryFlushMinCollectionBytes: number;
949
+ memoryFlushThrottleMs: number;
927
950
  };
928
951
  export interface ReactiveDeps {
929
952
  observe(signal: string): void;
930
953
  invalidate(signal: string): void;
931
954
  batch(fn: () => void): void;
955
+ isObserved?(signal: string): boolean;
932
956
  }
933
957
  export declare const noopReactiveDeps: ReactiveDeps;
934
958
  export type StorageFactory = (path: string) => Promise<FileStorage>;
@@ -943,6 +967,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
943
967
  private storageFactory;
944
968
  private config;
945
969
  constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
970
+ private static liveInstances;
971
+ private static memoryWatchdogStarted;
972
+ private static lastMemoryFlushMs;
973
+ private static startMemoryWatchdog;
974
+ static checkMemoryPressure(usedHeapBytes: number): void;
946
975
  private pendingAppends;
947
976
  private flushTimer;
948
977
  private flushChain;
@@ -963,6 +992,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
963
992
  private columnCache;
964
993
  private readerKeys;
965
994
  private loadedFileSet;
995
+ private loadedTotalBytes;
966
996
  private readerEpoch;
967
997
  private fileSetPollTimer;
968
998
  private bulkReaderCache;
@@ -980,6 +1010,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
980
1010
  private lastMergeCheck;
981
1011
  private getStreamFileName;
982
1012
  private invalidateOverlay;
1013
+ isKeyWatched(key: string): boolean;
983
1014
  private isLiveNow;
984
1015
  private invalidateSignal;
985
1016
  private flushSignals;
@@ -993,6 +1024,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
993
1024
  private clearReaderState;
994
1025
  private resetReader;
995
1026
  private reloadReader;
1027
+ reloadFromDisk(): void;
996
1028
  private readWithReload;
997
1029
  private pollFileSet;
998
1030
  write(entry: T): Promise<void>;
@@ -1109,7 +1141,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseFormat" {
1109
1141
  export declare const EMPTY_BUFFER: Buffer;
1110
1142
  export declare const ABSENT: unique symbol;
1111
1143
  export declare const TARGET_FILE_BYTES: number;
1112
- export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): Buffer[];
1144
+ export interface BuiltFile {
1145
+ buffer: Buffer;
1146
+ minKey: string;
1147
+ maxKey: string;
1148
+ rowCount: number;
1149
+ }
1150
+ export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): BuiltFile[];
1113
1151
  export type BaseBulkDatabaseReader = {
1114
1152
  rowCount: number;
1115
1153
  totalBytes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.46",
3
+ "version": "1.4.48",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1,2 +1,2 @@
1
- export { observable, runInAction, computed, autorun } from "mobx";
1
+ export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
2
2
  export declare function configureMobxNextFrameScheduler(): void;
@@ -3,7 +3,7 @@ import { batchFunction } from "socket-function/src/batching";
3
3
 
4
4
  // Re-export the core mobx primitives so downstream packages (which may have their own duplicate mobx
5
5
  // in node_modules) can share THIS mobx instance, otherwise reactivity doesn't cross package boundaries.
6
- export { observable, runInAction, computed, autorun } from "mobx";
6
+ export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
7
7
  export function configureMobxNextFrameScheduler() {
8
8
  // NOTE: This makes a big difference if we do await calls in a loop which mutates observable state. BUT... we should probably just do those await calls before the loop?
9
9
  let batchReactionScheduler = batchFunction({
@@ -1,4 +1,4 @@
1
- import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
1
+ import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
2
2
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
3
3
  export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
4
4
  /** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
@@ -88,6 +88,17 @@ export interface IBulkDatabase2<T extends {
88
88
  isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
89
89
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
90
90
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
91
+ /**
92
+ * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
93
+ * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
94
+ * returns true if the backend can't tell.
95
+ */
96
+ isKeyWatched(key: string): boolean;
97
+ /**
98
+ * Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
99
+ * and reloads from disk. Pending un-flushed writes are kept. Per-collection.
100
+ */
101
+ reloadFromDisk(): void;
91
102
  /** The columns present on disk and their byte sizes (no row data read). */
92
103
  getColumnInfo(): Promise<BulkColumnInfo[]>;
93
104
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -136,6 +147,15 @@ export interface IBulkDatabase2<T extends {
136
147
  * most callers want compact() or tryMergeNow(). */
137
148
  merge(timeLo: number, timeHi: number): Promise<void>;
138
149
  }
150
+ export declare class MobxReactiveDeps implements ReactiveDeps {
151
+ private boxes;
152
+ private observed;
153
+ private box;
154
+ observe(signal: string): void;
155
+ invalidate(signal: string): void;
156
+ batch(fn: () => void): void;
157
+ isObserved(signal: string): boolean;
158
+ }
139
159
  export declare class BulkDatabase2<T extends {
140
160
  key: string;
141
161
  }> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
@@ -1,5 +1,5 @@
1
1
  import { getFileStorageNested2 } from "../FileFolderAPI";
2
- import { observable, runInAction } from "../../render-utils/mobxTyped";
2
+ import { observable, runInAction, onBecomeObserved, onBecomeUnobserved } from "../../render-utils/mobxTyped";
3
3
  import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
4
4
 
5
5
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
@@ -74,6 +74,19 @@ export interface IBulkDatabase2<T extends { key: string }> {
74
74
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
75
75
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
76
76
 
77
+ /**
78
+ * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
79
+ * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
80
+ * returns true if the backend can't tell.
81
+ */
82
+ isKeyWatched(key: string): boolean;
83
+
84
+ /**
85
+ * Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
86
+ * and reloads from disk. Pending un-flushed writes are kept. Per-collection.
87
+ */
88
+ reloadFromDisk(): void;
89
+
77
90
  /** The columns present on disk and their byte sizes (no row data read). */
78
91
  getColumnInfo(): Promise<BulkColumnInfo[]>;
79
92
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -121,13 +134,18 @@ export interface IBulkDatabase2<T extends { key: string }> {
121
134
  // observer/autorun that calls it tracks that box) and invalidate() bumps it (so those reactions re-run).
122
135
  // This reproduces the fine-grained per-key + load-version reactivity the class had when it used
123
136
  // observable.map/observable.box directly, while keeping all that logic in the mobx-free base.
124
- class MobxReactiveDeps implements ReactiveDeps {
137
+ export class MobxReactiveDeps implements ReactiveDeps {
125
138
  private boxes = new Map<string, { get(): number; set(value: number): void }>();
139
+ // Signals that currently have at least one observer, kept current via mobx's onBecomeObserved/
140
+ // onBecomeUnobserved hooks. Lets isObserved() answer "is anything watching this row" cheaply.
141
+ private observed = new Set<string>();
126
142
  private box(signal: string) {
127
143
  let box = this.boxes.get(signal);
128
144
  if (!box) {
129
145
  box = observable.box(0);
130
146
  this.boxes.set(signal, box);
147
+ onBecomeObserved(box, () => this.observed.add(signal));
148
+ onBecomeUnobserved(box, () => this.observed.delete(signal));
131
149
  }
132
150
  return box;
133
151
  }
@@ -141,6 +159,9 @@ class MobxReactiveDeps implements ReactiveDeps {
141
159
  batch(fn: () => void) {
142
160
  runInAction(fn);
143
161
  }
162
+ isObserved(signal: string) {
163
+ return this.observed.has(signal);
164
+ }
144
165
  }
145
166
 
146
167
  // Backwards-compatible BulkDatabase2: the mobx-reactive flavor. All behavior lives in BulkDatabaseBase;
@@ -12,11 +12,15 @@ export declare const bulkDatabase2Timing: {
12
12
  streamFoldHardLimitBytes: number;
13
13
  writeFlushMaxDelayMs: number;
14
14
  fileSetPollIntervalMs: number;
15
+ memoryFlushHeapBytes: number;
16
+ memoryFlushMinCollectionBytes: number;
17
+ memoryFlushThrottleMs: number;
15
18
  };
16
19
  export interface ReactiveDeps {
17
20
  observe(signal: string): void;
18
21
  invalidate(signal: string): void;
19
22
  batch(fn: () => void): void;
23
+ isObserved?(signal: string): boolean;
20
24
  }
21
25
  export declare const noopReactiveDeps: ReactiveDeps;
22
26
  export type StorageFactory = (path: string) => Promise<FileStorage>;
@@ -31,6 +35,11 @@ export declare class BulkDatabaseBase<T extends {
31
35
  private storageFactory;
32
36
  private config;
33
37
  constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
38
+ private static liveInstances;
39
+ private static memoryWatchdogStarted;
40
+ private static lastMemoryFlushMs;
41
+ private static startMemoryWatchdog;
42
+ static checkMemoryPressure(usedHeapBytes: number): void;
34
43
  private pendingAppends;
35
44
  private flushTimer;
36
45
  private flushChain;
@@ -51,6 +60,7 @@ export declare class BulkDatabaseBase<T extends {
51
60
  private columnCache;
52
61
  private readerKeys;
53
62
  private loadedFileSet;
63
+ private loadedTotalBytes;
54
64
  private readerEpoch;
55
65
  private fileSetPollTimer;
56
66
  private bulkReaderCache;
@@ -68,6 +78,7 @@ export declare class BulkDatabaseBase<T extends {
68
78
  private lastMergeCheck;
69
79
  private getStreamFileName;
70
80
  private invalidateOverlay;
81
+ isKeyWatched(key: string): boolean;
71
82
  private isLiveNow;
72
83
  private invalidateSignal;
73
84
  private flushSignals;
@@ -81,6 +92,7 @@ export declare class BulkDatabaseBase<T extends {
81
92
  private clearReaderState;
82
93
  private resetReader;
83
94
  private reloadReader;
95
+ reloadFromDisk(): void;
84
96
  private readWithReload;
85
97
  private pollFileSet;
86
98
  write(entry: T): Promise<void>;
@@ -34,6 +34,10 @@ const FILE_EXTENSION = ".bulk";
34
34
  const ROLLOVER_ROWS = 5000;
35
35
  const ROLLOVER_BYTES = 5 * 1024 * 1024;
36
36
 
37
+ // How often the global memory-pressure watchdog samples the heap (the ACTION it may take is throttled
38
+ // separately, see bulkDatabase2Timing.memoryFlushThrottleMs).
39
+ const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
40
+
37
41
  // An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
38
42
  // can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
39
43
  // no writer is plausibly still working on it, so we delete it. Until then we just warn.
@@ -104,6 +108,13 @@ export const bulkDatabase2Timing = {
104
108
  // We proactively re-list files this often; if the set changed under us (another tab/process merged) we
105
109
  // reload the index, so reads pick up the change even without first hitting a missing-file error.
106
110
  fileSetPollIntervalMs: 30 * 60 * 1000,
111
+ // Memory-pressure auto-flush (browser only; needs performance.memory). When the JS heap exceeds
112
+ // memoryFlushHeapBytes, at most once per memoryFlushThrottleMs, drop the in-memory observable state of
113
+ // every collection whose loaded data exceeds memoryFlushMinCollectionBytes (they reload lazily). Tuned
114
+ // so it does nothing for a healthy app (heap fine) or small collections.
115
+ memoryFlushHeapBytes: 1500 * 1024 * 1024,
116
+ memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
117
+ memoryFlushThrottleMs: 15 * 60 * 1000,
107
118
  };
108
119
 
109
120
  // Marks a key as deleted in the in-memory overlay.
@@ -139,14 +150,19 @@ export interface ReactiveDeps {
139
150
  invalidate(signal: string): void;
140
151
  // Run a group of mutations + invalidations as one batch, so observers re-run at most once.
141
152
  batch(fn: () => void): void;
153
+ // Whether `signal` currently has any observer watching it. Optional — a backend that can't tell
154
+ // returns undefined (treated as "assume watched"). Lets writes skip per-key notification work for rows
155
+ // nothing is watching (see isWatchedSync).
156
+ isObserved?(signal: string): boolean;
142
157
  }
143
158
 
144
159
  // A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
145
- // re-renders. Use this when you don't need a UI to react to writes.
160
+ // re-renders. Use this when you don't need a UI to react to writes. Nothing is ever observed.
146
161
  export const noopReactiveDeps: ReactiveDeps = {
147
162
  observe() { },
148
163
  invalidate() { },
149
164
  batch(fn) { fn(); },
165
+ isObserved() { return false; },
150
166
  };
151
167
 
152
168
  // Provides the FileStorage for a given path (the caller decides where data physically lives, so this
@@ -264,6 +280,50 @@ export class BulkDatabaseBase<T extends { key: string }> {
264
280
  // stay correct even without first hitting a missing-file error.
265
281
  this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
266
282
  (this.fileSetPollTimer as { unref?: () => void }).unref?.();
283
+ // Register for the global memory-pressure watchdog (browser only — needs performance.memory).
284
+ BulkDatabaseBase.liveInstances.add(this);
285
+ BulkDatabaseBase.startMemoryWatchdog();
286
+ }
287
+
288
+ // ---- memory-pressure watchdog (global, browser-only) ----
289
+ // When the JS heap gets large, the biggest contributors are usually a few collections whose in-memory
290
+ // observable state (loaded reader + base column/field caches) has grown. Rather than cap every
291
+ // collection all the time, we watch the heap and, only when it's actually under pressure, drop the
292
+ // observable state of the large collections (they reload lazily on demand). Throttled hard so it never
293
+ // thrashes a healthy app.
294
+ private static liveInstances = new Set<BulkDatabaseBase<{ key: string }>>();
295
+ private static memoryWatchdogStarted = false;
296
+ private static lastMemoryFlushMs = 0;
297
+ private static startMemoryWatchdog() {
298
+ if (BulkDatabaseBase.memoryWatchdogStarted) return;
299
+ BulkDatabaseBase.memoryWatchdogStarted = true;
300
+ const usedHeap = (): number | undefined => {
301
+ try { return (performance as unknown as { memory?: { usedJSHeapSize?: number } })?.memory?.usedJSHeapSize; }
302
+ catch { return undefined; }
303
+ };
304
+ if (typeof performance === "undefined" || usedHeap() === undefined) return; // no heap API (Node / non-Chromium)
305
+ const timer = setInterval(() => {
306
+ const used = usedHeap();
307
+ if (used !== undefined) BulkDatabaseBase.checkMemoryPressure(used);
308
+ }, MEMORY_WATCHDOG_INTERVAL_MS);
309
+ (timer as { unref?: () => void }).unref?.();
310
+ }
311
+ // If the heap is over the limit (and we haven't flushed recently), drop the in-memory observable state
312
+ // of every collection whose loaded data exceeds the size threshold, so it reloads lazily and frees the
313
+ // rest. Exposed (with the heap value injected) so it's callable/testable without the real timer.
314
+ public static checkMemoryPressure(usedHeapBytes: number): void {
315
+ if (usedHeapBytes < bulkDatabase2Timing.memoryFlushHeapBytes) return;
316
+ const now = Date.now();
317
+ if (now - BulkDatabaseBase.lastMemoryFlushMs < bulkDatabase2Timing.memoryFlushThrottleMs) return;
318
+ BulkDatabaseBase.lastMemoryFlushMs = now;
319
+ const flushed: string[] = [];
320
+ for (const db of BulkDatabaseBase.liveInstances) {
321
+ if (db.loadedTotalBytes > bulkDatabase2Timing.memoryFlushMinCollectionBytes) {
322
+ flushed.push(`${db.name} (${fmtBytes(db.loadedTotalBytes)})`);
323
+ db.reloadFromDisk();
324
+ }
325
+ }
326
+ if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed observable state of ${flushed.length} large collection(s) to reclaim memory: ${flushed.join(", ")}`);
267
327
  }
268
328
 
269
329
  // ---- buffered stream writes (per collection) ----
@@ -341,6 +401,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
341
401
  // under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
342
402
  // (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
343
403
  private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
404
+ // Total (uncompressed) size of the data the current reader spans, as a proxy for this collection's
405
+ // in-memory footprint — used by the memory-pressure watchdog. 0 when no reader is loaded.
406
+ private loadedTotalBytes = 0;
344
407
  // Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
345
408
  // triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
346
409
  // (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
@@ -406,10 +469,20 @@ export class BulkDatabaseBase<T extends { key: string }> {
406
469
  this.dataGen++;
407
470
  if (columns === "all") this.columnCache.clear();
408
471
  else for (const c of columns) this.columnCache.delete(c);
409
- this.invalidateSignal(key);
472
+ // The per-key signal only re-runs getSingleFieldObjSync watchers of THIS key; skip it when nothing
473
+ // is watching the row (a new watcher reads the current overlay anyway). The overlay-wide signal +
474
+ // column-cache drop above still cover whole-column watchers and read correctness.
475
+ if (this.deps.isObserved?.(key) ?? true) this.invalidateSignal(key);
410
476
  this.invalidateSignal(OVERLAY_SIGNAL);
411
477
  }
412
478
 
479
+ // Whether a row (key) is currently being watched — i.e. some reactive observer is subscribed to it via
480
+ // getSingleFieldObjSync / getSingleFieldSync. Useful for skipping per-row work when nothing's watching.
481
+ // Returns true if the backend can't tell (conservative).
482
+ public isKeyWatched(key: string): boolean {
483
+ return this.deps.isObserved?.(key) ?? true;
484
+ }
485
+
413
486
  // Whether `key` is currently a live key in the resolved view (overlay wins over disk). A write to a key
414
487
  // that's already live only changes the columns it sets; a write to an absent key (or one currently
415
488
  // deleted in the overlay) makes it appear in every column, so every column's cache must drop.
@@ -526,6 +599,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
526
599
  this.readerKeys = new Set(joined.keys);
527
600
  // The files this index was built from, so the poll can detect a concurrent merge changing the set.
528
601
  this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
602
+ this.loadedTotalBytes = joined.totalBytes; // footprint proxy for the memory-pressure watchdog
603
+
529
604
  // Evict cached sub-readers for files a merge removed, so the caches track the live file set.
530
605
  this.pruneFileCaches(bulkFiles, streamFiles);
531
606
 
@@ -588,6 +663,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
588
663
  this.columnCache.clear();
589
664
  this.readerKeys = undefined; // the next build repopulates it
590
665
  this.loadedFileSet = undefined; // ditto
666
+ this.loadedTotalBytes = 0; // nothing loaded ⇒ no footprint for the memory watchdog
591
667
  // The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
592
668
  // looking "heavy" and re-trigger a fold before then.
593
669
  this.streamRowsOnDisk = 0;
@@ -620,6 +696,23 @@ export class BulkDatabaseBase<T extends { key: string }> {
620
696
  });
621
697
  }
622
698
 
699
+ // Drop ALL of this collection's in-memory loaded caches (the resolved reader, the sync base column /
700
+ // field caches and their stale fallbacks, the per-file decoded readers) and re-trigger every watcher,
701
+ // so they re-request and reload from disk. Unlike the automatic reloads, this is a genuine full clear
702
+ // (no stale-fallback served — watchers go through a real loading state). Pending un-flushed writes (the
703
+ // overlay) are KEPT, so nothing not-yet-on-disk is lost. Per-collection (this instance only).
704
+ public reloadFromDisk(): void {
705
+ this.deps.batch(() => {
706
+ this.clearReaderState(); // drops reader + base caches (→ stale) + column cache; bumps gens
707
+ this.staleBaseColumns.clear(); // and the stale fallbacks — a genuine full clear, not a swap
708
+ this.staleBaseFields.clear();
709
+ this.bulkReaderCache.clear(); // force re-decode from disk
710
+ this.streamReaderCache.clear();
711
+ this.invalidateSignal(LOAD_SIGNAL);
712
+ this.invalidateSignal(OVERLAY_SIGNAL);
713
+ });
714
+ }
715
+
623
716
  // Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
624
717
  // reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
625
718
  // Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
@@ -836,9 +929,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
836
929
  const timestamp = nextFileTime();
837
930
  const now = Date.now();
838
931
  const times = rows.map(() => now);
839
- for (const buffer of buildFileBuffer(rows, times)) {
932
+ for (const built of buildFileBuffer(rows, times)) {
840
933
  const name = newFileName(timestamp);
841
- await storage.set(name, encodeCompressedBlocks(buffer));
934
+ await storage.set(name, encodeCompressedBlocks(built.buffer));
842
935
  }
843
936
  this.resetReader();
844
937
  void this.maybeMerge();
@@ -1168,7 +1261,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
1168
1261
  ...streamFiles.map(f => ({ name: f.fileName, size: streamData.sizes.get(f.fileName) ?? 0 })),
1169
1262
  ];
1170
1263
  const inTotal = inputs.reduce((a, f) => a + f.size, 0);
1171
- console.log(`${blue(this.name)} merge: reading ${inputs.length} files (${fmtBytes(inTotal)})`);
1264
+ const mergeStartMs = Date.now();
1265
+ console.log(`${blue(this.name)} merge: reading ${inputs.length} files (${fmtBytes(inTotal)}) at ${new Date(mergeStartMs).toISOString()}`);
1172
1266
  for (const f of inputs) console.log(` in ${f.name} ${fmtBytes(f.size)}`);
1173
1267
 
1174
1268
  const { rows, times, deletes } = await this.resolveReaders(readers);
@@ -1176,10 +1270,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
1176
1270
  // Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
1177
1271
  const newNames: string[] = [];
1178
1272
  if (rows.length) {
1179
- for (const buffer of buildFileBuffer(rows, times)) {
1273
+ const built = buildFileBuffer(rows, times);
1274
+ // When the result is big enough to split across several files, log each sub-write (its key
1275
+ // range + start/finish), since each storage.set can be large/slow and we want to see progress.
1276
+ const split = built.length > 1;
1277
+ if (split) console.log(`${blue(this.name)} merge: output split into ${built.length} files (> ${fmtBytes(TARGET_FILE_BYTES)} each)`);
1278
+ for (let i = 0; i < built.length; i++) {
1279
+ const part = built[i];
1180
1280
  const name = newFileName(timestamp);
1181
- await storage.set(name, encodeCompressedBlocks(buffer));
1281
+ const subStart = Date.now();
1282
+ if (split) console.log(` [${i + 1}/${built.length}] writing ${formatNumber(part.rowCount)} rows [${part.minKey} .. ${part.maxKey}] → ${name} at ${new Date(subStart).toISOString()}`);
1283
+ await storage.set(name, encodeCompressedBlocks(part.buffer));
1182
1284
  newNames.push(name);
1285
+ if (split) console.log(` [${i + 1}/${built.length}] wrote ${name} (${fmtBytes((await storage.getInfo(name).catch(() => undefined))?.size ?? 0)}) in ${formatTime(Date.now() - subStart)}`);
1183
1286
  }
1184
1287
  }
1185
1288
  // Carry surviving tombstones forward only if older files exist outside this merge that they still
@@ -1195,7 +1298,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1195
1298
  // Log the result (files + on-disk sizes), so the before→after of the merge is visible.
1196
1299
  const outputs = await Promise.all(outNames.map(async n => ({ name: n, size: (await storage.getInfo(n).catch(() => undefined))?.size ?? 0 })));
1197
1300
  const outTotal = outputs.reduce((a, f) => a + f.size, 0);
1198
- console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""}`);
1301
+ console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""} at ${new Date().toISOString()} (took ${formatTime(Date.now() - mergeStartMs)})`);
1199
1302
  for (const f of outputs) console.log(` out ${f.name} ${fmtBytes(f.size)}`);
1200
1303
 
1201
1304
  const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
@@ -4,7 +4,13 @@ export declare const KEY_COLUMN = "key";
4
4
  export declare const EMPTY_BUFFER: Buffer;
5
5
  export declare const ABSENT: unique symbol;
6
6
  export declare const TARGET_FILE_BYTES: number;
7
- export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): Buffer[];
7
+ export interface BuiltFile {
8
+ buffer: Buffer;
9
+ minKey: string;
10
+ maxKey: string;
11
+ rowCount: number;
12
+ }
13
+ export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): BuiltFile[];
8
14
  export type BaseBulkDatabaseReader = {
9
15
  rowCount: number;
10
16
  totalBytes: number;
@@ -226,14 +226,25 @@ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer
226
226
  return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
227
227
  }
228
228
 
229
- // Returns one complete, independent file buffer per chunk of rows. Rows are first sorted by key, then
229
+ // One complete, independent file: the encoded buffer plus its key range + row count (the caller logs the
230
+ // range when a merge splits across several files).
231
+ export interface BuiltFile { buffer: Buffer; minKey: string; maxKey: string; rowCount: number; }
232
+
233
+ // Returns one complete, independent file per chunk of rows. Rows are first sorted by key, then
230
234
  // partitioned into key-contiguous chunks of ~targetBytes each — so every returned file is key-sorted
231
235
  // (tight minKey/maxKey for the read-skip + merge planner) and stays near the target size, and no
232
236
  // single column blob / Buffer.concat approaches the ~2GB limit. The chunks have disjoint key ranges,
233
- // so the caller just writes each as its own file. A normal-sized write returns a single buffer.
237
+ // so the caller just writes each as its own file. A normal-sized write returns a single file.
234
238
  // `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
235
- export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): Buffer[] {
236
- if (rows.length === 0) return [buildOneFile([], [])];
239
+ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): BuiltFile[] {
240
+ // A chunk is already key-sorted, so its first/last row are its min/max key.
241
+ const make = (rs: Record<string, unknown>[], ts: number[]): BuiltFile => ({
242
+ buffer: buildOneFile(rs, ts),
243
+ minKey: rs.length ? (rs[0][KEY_COLUMN] as string) : "",
244
+ maxKey: rs.length ? (rs[rs.length - 1][KEY_COLUMN] as string) : "",
245
+ rowCount: rs.length,
246
+ });
247
+ if (rows.length === 0) return [make([], [])];
237
248
  // Sort rows + their times together by key so each output file is key-contiguous.
238
249
  const order = rows.map((_, i) => i).sort((a, b) => {
239
250
  const ka = rows[a][KEY_COLUMN] as string;
@@ -242,19 +253,19 @@ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[]
242
253
  });
243
254
  const sortedRows = order.map(i => rows[i]);
244
255
  const sortedTimes = order.map(i => times[i]);
245
- const result: Buffer[] = [];
256
+ const result: BuiltFile[] = [];
246
257
  let chunkStart = 0;
247
258
  let chunkBytes = 0;
248
259
  for (let i = 0; i < sortedRows.length; i++) {
249
260
  const rowBytes = estimateRowBytes(sortedRows[i]);
250
261
  if (i > chunkStart && chunkBytes + rowBytes > targetBytes) {
251
- result.push(buildOneFile(sortedRows.slice(chunkStart, i), sortedTimes.slice(chunkStart, i)));
262
+ result.push(make(sortedRows.slice(chunkStart, i), sortedTimes.slice(chunkStart, i)));
252
263
  chunkStart = i;
253
264
  chunkBytes = 0;
254
265
  }
255
266
  chunkBytes += rowBytes;
256
267
  }
257
- result.push(buildOneFile(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
268
+ result.push(make(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
258
269
  return result;
259
270
  }
260
271