sliftutils 1.4.16 → 1.4.17

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
@@ -763,9 +763,9 @@ declare module "sliftutils/render-utils/observer" {
763
763
  }
764
764
 
765
765
  declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
766
- import { BulkDatabaseBase } from "./BulkDatabaseBase";
766
+ import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
767
767
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
768
- export type { ReactiveDeps, StorageFactory } from "./BulkDatabaseBase";
768
+ export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
769
769
  /** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
770
770
  export type BulkColumnInfo = {
771
771
  column: string;
@@ -896,7 +896,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
896
896
  export declare class BulkDatabase2<T extends {
897
897
  key: string;
898
898
  }> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
899
- constructor(name: string);
899
+ constructor(name: string, config?: BulkDatabase2Config);
900
900
  }
901
901
 
902
902
  }
@@ -920,13 +920,17 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
920
920
  }
921
921
  export declare const noopReactiveDeps: ReactiveDeps;
922
922
  export type StorageFactory = (path: string) => Promise<FileStorage>;
923
+ export type BulkDatabase2Config = {
924
+ triggerThrottleMs?: number;
925
+ };
923
926
  export declare class BulkDatabaseBase<T extends {
924
927
  key: string;
925
928
  }> {
926
929
  readonly name: string;
927
930
  protected deps: ReactiveDeps;
928
931
  private storageFactory;
929
- constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory);
932
+ private config;
933
+ constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
930
934
  private pendingAppends;
931
935
  private flushTimer;
932
936
  private flushChain;
@@ -945,13 +949,21 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
945
949
  private overlay;
946
950
  private streamTimes;
947
951
  private columnCache;
952
+ private readerKeys;
948
953
  private dataGen;
954
+ private pendingSignals;
955
+ private triggerTimer;
956
+ private currentTriggerDelay;
957
+ private lastTriggerTime;
949
958
  private streamRowsOnDisk;
950
959
  private streamBytesOnDisk;
951
960
  private streamFileName;
952
961
  private lastMergeCheck;
953
962
  private getStreamFileName;
954
963
  private invalidateOverlay;
964
+ private isLiveNow;
965
+ private invalidateSignal;
966
+ private flushSignals;
955
967
  private setOverlayRow;
956
968
  private setOverlayDeleted;
957
969
  private reader;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.16",
3
+ "version": "1.4.17",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
- import { BulkDatabaseBase } from "./BulkDatabaseBase";
1
+ import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
2
2
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
3
- export type { ReactiveDeps, StorageFactory } from "./BulkDatabaseBase";
3
+ export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
4
4
  /** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
5
5
  export type BulkColumnInfo = {
6
6
  column: string;
@@ -131,5 +131,5 @@ export interface IBulkDatabase2<T extends {
131
131
  export declare class BulkDatabase2<T extends {
132
132
  key: string;
133
133
  }> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
134
- constructor(name: string);
134
+ constructor(name: string, config?: BulkDatabase2Config);
135
135
  }
@@ -1,9 +1,9 @@
1
1
  import { getFileStorageNested2 } from "../FileFolderAPI";
2
2
  import { observable, runInAction } from "../../render-utils/mobxTyped";
3
- import { BulkDatabaseBase, ReactiveDeps } from "./BulkDatabaseBase";
3
+ import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
4
4
 
5
5
  export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
6
- export type { ReactiveDeps, StorageFactory } from "./BulkDatabaseBase";
6
+ export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
7
7
 
8
8
  /** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
9
9
  export type BulkColumnInfo = { column: string; byteSize: number };
@@ -138,7 +138,7 @@ class MobxReactiveDeps implements ReactiveDeps {
138
138
  // this just supplies mobx reactivity and the default (getFileStorageNested2) storage backend, so the
139
139
  // sync reads (getSingleFieldSync / getColumnSync) stay observable for mobx components.
140
140
  export class BulkDatabase2<T extends { key: string }> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
141
- constructor(name: string) {
142
- super(name, new MobxReactiveDeps(), getFileStorageNested2);
141
+ constructor(name: string, config?: BulkDatabase2Config) {
142
+ super(name, new MobxReactiveDeps(), getFileStorageNested2, config);
143
143
  }
144
144
  }
@@ -16,13 +16,17 @@ export interface ReactiveDeps {
16
16
  }
17
17
  export declare const noopReactiveDeps: ReactiveDeps;
18
18
  export type StorageFactory = (path: string) => Promise<FileStorage>;
19
+ export type BulkDatabase2Config = {
20
+ triggerThrottleMs?: number;
21
+ };
19
22
  export declare class BulkDatabaseBase<T extends {
20
23
  key: string;
21
24
  }> {
22
25
  readonly name: string;
23
26
  protected deps: ReactiveDeps;
24
27
  private storageFactory;
25
- constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory);
28
+ private config;
29
+ constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
26
30
  private pendingAppends;
27
31
  private flushTimer;
28
32
  private flushChain;
@@ -41,13 +45,21 @@ export declare class BulkDatabaseBase<T extends {
41
45
  private overlay;
42
46
  private streamTimes;
43
47
  private columnCache;
48
+ private readerKeys;
44
49
  private dataGen;
50
+ private pendingSignals;
51
+ private triggerTimer;
52
+ private currentTriggerDelay;
53
+ private lastTriggerTime;
45
54
  private streamRowsOnDisk;
46
55
  private streamBytesOnDisk;
47
56
  private streamFileName;
48
57
  private lastMergeCheck;
49
58
  private getStreamFileName;
50
59
  private invalidateOverlay;
60
+ private isLiveNow;
61
+ private invalidateSignal;
62
+ private flushSignals;
51
63
  private setOverlayRow;
52
64
  private setOverlayDeleted;
53
65
  private reader;
@@ -129,6 +129,22 @@ export const noopReactiveDeps: ReactiveDeps = {
129
129
  // file needn't know about getFileStorageNested2 / the browser-vs-node storage details).
130
130
  export type StorageFactory = (path: string) => Promise<FileStorage>;
131
131
 
132
+ // Optional per-collection configuration.
133
+ export type BulkDatabase2Config = {
134
+ // When set (> 0), the reactive change notifications writes/loads emit are throttled and BATCHED
135
+ // globally (across all keys), so a high-frequency write source doesn't re-run watchers on every single
136
+ // change. The throttle RAMPS like the write-flush one: the first change after a lull notifies
137
+ // immediately, then under sustained changes the delay doubles up to triggerThrottleMs, coalescing the
138
+ // burst into one notification. In-memory/async reads are always current — only the OBSERVABLE
139
+ // notification (the mobx re-render trigger) is delayed and merged.
140
+ triggerThrottleMs?: number;
141
+ };
142
+
143
+ // Trigger-throttle ramp: the first deferred notification waits this long, then the delay doubles on each
144
+ // further change up to BulkDatabase2Config.triggerThrottleMs. ~16ms ≈ one animation frame, so an isolated
145
+ // burst still notifies within a frame.
146
+ const TRIGGER_THROTTLE_FIRST_STEP_MS = 16;
147
+
132
148
  // The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
133
149
  // reader resets or a base column/field finishes loading. The overlay's per-key signal is the key
134
150
  // itself (a point read observes just its key), plus one overlay-wide signal that whole-column reads
@@ -205,6 +221,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
205
221
  public readonly name: string,
206
222
  protected deps: ReactiveDeps,
207
223
  private storageFactory: StorageFactory,
224
+ private config: BulkDatabase2Config = {},
208
225
  ) {
209
226
  // Best-effort: persist buffered stream writes when the tab is hidden/closing. visibilitychange →
210
227
  // hidden fires early enough that the (async) flush usually completes; pagehide is the backstop.
@@ -284,11 +301,23 @@ export class BulkDatabaseBase<T extends { key: string }> {
284
301
  // SHALLOWLY: Object.freeze locks the array itself (callers can't mutate a shared result) but never the
285
302
  // element objects or their values, so typed-array column values keep their fast representation.
286
303
  private columnCache = new Map<string, { key: string; value: unknown; time: number }[]>();
304
+ // Live keys of the currently-loaded reader (disk only, no overlay), so a write can tell a partial
305
+ // update of an existing key (only the written columns change) from adding/removing a key (which appears
306
+ // in / disappears from EVERY column). Set on reader build, cleared on reset.
307
+ private readerKeys: Set<string> | undefined;
287
308
  // Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
288
309
  // and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
289
310
  // the cache and may swap the reader) can never leave a stale entry behind.
290
311
  private dataGen = 0;
291
312
 
313
+ // ---- trigger throttle (see BulkDatabase2Config.triggerThrottleMs) ----
314
+ // Signals whose notification is deferred, the pending flush timer, and the ramping delay. Data state is
315
+ // already updated synchronously; only these observable notifications are batched/delayed.
316
+ private pendingSignals = new Set<string>();
317
+ private triggerTimer: ReturnType<typeof setTimeout> | undefined;
318
+ private currentTriggerDelay = 0;
319
+ private lastTriggerTime = 0;
320
+
292
321
  // Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
293
322
  // kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
294
323
  // reset on resetReader, since after a merge/reset the next reader build re-measures it.
@@ -314,26 +343,71 @@ export class BulkDatabaseBase<T extends { key: string }> {
314
343
  return this.streamFileName;
315
344
  }
316
345
 
317
- private invalidateOverlay(key: string) {
346
+ // Invalidate after an overlay change. `columns` is the set of columns whose resolved result actually
347
+ // changed — only those cached columns are dropped — or "all" when the key set itself changed (a key
348
+ // added or removed appears in / disappears from every column).
349
+ private invalidateOverlay(key: string, columns: Iterable<string> | "all") {
318
350
  this.dataGen++;
319
- this.columnCache.clear();
320
- this.deps.invalidate(key);
321
- this.deps.invalidate(OVERLAY_SIGNAL);
351
+ if (columns === "all") this.columnCache.clear();
352
+ else for (const c of columns) this.columnCache.delete(c);
353
+ this.invalidateSignal(key);
354
+ this.invalidateSignal(OVERLAY_SIGNAL);
355
+ }
356
+
357
+ // Whether `key` is currently a live key in the resolved view (overlay wins over disk). A write to a key
358
+ // that's already live only changes the columns it sets; a write to an absent key (or one currently
359
+ // deleted in the overlay) makes it appear in every column, so every column's cache must drop.
360
+ private isLiveNow(key: string): boolean {
361
+ const e = this.overlay.get(key);
362
+ if (e) return e.value !== DELETED;
363
+ return this.readerKeys?.has(key) ?? false;
364
+ }
365
+
366
+ // Notify observers of `signal`. With triggerThrottleMs set, notifications are batched and delayed on a
367
+ // ramping schedule so a high-frequency source can't re-run watchers on every change: a change after a
368
+ // lull notifies on the next tick (no real delay, but all of one change's signals batch together);
369
+ // under sustained changes the delay doubles up to the max, coalescing the burst into one notification.
370
+ // Only the OBSERVABLE notification is delayed — the underlying data was already updated, so a read in
371
+ // the meantime still sees current values.
372
+ private invalidateSignal(signal: string) {
373
+ const maxMs = this.config.triggerThrottleMs ?? 0;
374
+ if (maxMs <= 0) { this.deps.invalidate(signal); return; }
375
+ this.pendingSignals.add(signal);
376
+ const now = Date.now();
377
+ const lull = now - this.lastTriggerTime > maxMs;
378
+ this.lastTriggerTime = now;
379
+ if (this.triggerTimer !== undefined) return; // a flush is already scheduled; it will pick this up
380
+ // Lull resets the ramp (notify next tick); an active burst ramps the delay toward the max.
381
+ this.currentTriggerDelay = lull ? 0 : Math.min(maxMs, Math.max(TRIGGER_THROTTLE_FIRST_STEP_MS, this.currentTriggerDelay * 2));
382
+ this.triggerTimer = setTimeout(() => { this.triggerTimer = undefined; this.flushSignals(); }, this.currentTriggerDelay);
383
+ (this.triggerTimer as { unref?: () => void }).unref?.();
384
+ }
385
+
386
+ private flushSignals() {
387
+ if (this.pendingSignals.size === 0) return;
388
+ const signals = [...this.pendingSignals];
389
+ this.pendingSignals.clear();
390
+ this.deps.batch(() => { for (const s of signals) this.deps.invalidate(s); });
322
391
  }
323
392
 
324
393
  // Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
325
394
  // only changes the columns it includes — the rest fall through to disk on read. A prior delete is
326
395
  // reset (the key is being re-created).
327
396
  private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
397
+ // Decide BEFORE mutating: if the key is already live, only the columns this write sets change;
398
+ // otherwise it's a new/re-created key that joins every column.
399
+ const wasLive = this.isLiveNow(key);
328
400
  const existing = this.overlay.get(key);
329
401
  const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
330
402
  this.overlay.set(key, { time, value });
331
- this.invalidateOverlay(key);
403
+ this.invalidateOverlay(key, wasLive ? Object.keys(row) : "all");
332
404
  }
333
405
 
334
406
  private setOverlayDeleted(key: string, time: number) {
407
+ // Deleting a live key removes it from every column; deleting an absent key changes no column.
408
+ const columns = this.isLiveNow(key) ? "all" : [];
335
409
  this.overlay.set(key, { time, value: DELETED });
336
- this.invalidateOverlay(key);
410
+ this.invalidateOverlay(key, columns);
337
411
  }
338
412
 
339
413
  private reader = lazy(async (): Promise<ResolvedReader> => {
@@ -391,6 +465,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
391
465
  }
392
466
  readers.push(...bulkReaders);
393
467
  const joined = await joinBulkDatabases(readers);
468
+ // Live keys of this reader, so per-column cache invalidation can tell an update of an existing key
469
+ // (only its set columns change) from a key add/remove (which touches every column).
470
+ this.readerKeys = new Set(joined.keys);
394
471
 
395
472
  let time = Date.now() - start;
396
473
  if (time > 50) {
@@ -450,13 +527,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
450
527
  this.overlay.clear();
451
528
  this.dataGen++;
452
529
  this.columnCache.clear();
530
+ this.readerKeys = undefined; // the next reader build repopulates it
453
531
  // The next reader build re-measures the stream; clear the estimate so a just-folded stream
454
532
  // doesn't keep looking "heavy" and re-trigger a fold before then.
455
533
  this.streamRowsOnDisk = 0;
456
534
  this.streamBytesOnDisk = 0;
457
535
  for (const p of this.pendingAppends) p.apply();
458
- this.deps.invalidate(LOAD_SIGNAL);
459
- this.deps.invalidate(OVERLAY_SIGNAL);
536
+ this.invalidateSignal(LOAD_SIGNAL);
537
+ this.invalidateSignal(OVERLAY_SIGNAL);
460
538
  });
461
539
  }
462
540
 
@@ -1201,7 +1279,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1201
1279
  this.deps.batch(() => {
1202
1280
  this.baseColumns.set(column, base);
1203
1281
  this.baseColumnsLoading.delete(column);
1204
- this.deps.invalidate(LOAD_SIGNAL);
1282
+ this.invalidateSignal(LOAD_SIGNAL);
1205
1283
  });
1206
1284
  })();
1207
1285
  }
@@ -1216,7 +1294,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1216
1294
  this.deps.batch(() => {
1217
1295
  this.baseFields.set(cacheKey, resolved);
1218
1296
  this.baseFieldsLoading.delete(cacheKey);
1219
- this.deps.invalidate(LOAD_SIGNAL);
1297
+ this.invalidateSignal(LOAD_SIGNAL);
1220
1298
  });
1221
1299
  })();
1222
1300
  }