sliftutils 1.4.44 → 1.4.46

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
@@ -845,6 +845,14 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
845
845
  value: T[Column];
846
846
  time: number;
847
847
  }[] | undefined;
848
+ /**
849
+ * Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
850
+ * deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
851
+ * and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
852
+ */
853
+ isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
854
+ /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
855
+ isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
848
856
  /** The columns present on disk and their byte sizes (no row data read). */
849
857
  getColumnInfo(): Promise<BulkColumnInfo[]>;
850
858
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -912,6 +920,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
912
920
  firstMergeTriggerRangeMs: number;
913
921
  streamFoldTriggerRows: number;
914
922
  streamFoldTriggerBytes: number;
923
+ streamFileMaxBytes: number;
924
+ streamFoldHardLimitBytes: number;
915
925
  writeFlushMaxDelayMs: number;
916
926
  fileSetPollIntervalMs: number;
917
927
  };
@@ -965,6 +975,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
965
975
  private streamRowsOnDisk;
966
976
  private streamBytesOnDisk;
967
977
  private streamFileName;
978
+ private currentStreamFileName;
979
+ private currentStreamFileBytes;
968
980
  private lastMergeCheck;
969
981
  private getStreamFileName;
970
982
  private invalidateOverlay;
@@ -991,6 +1003,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
991
1003
  flush(): Promise<void>;
992
1004
  private flushPending;
993
1005
  private doFlush;
1006
+ private foldOwnStream;
994
1007
  update(entry: Partial<T> & {
995
1008
  key: string;
996
1009
  }): Promise<void>;
@@ -1038,6 +1051,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1038
1051
  private baseColumnsLoading;
1039
1052
  private baseFields;
1040
1053
  private baseFieldsLoading;
1054
+ private staleBaseColumns;
1055
+ private staleBaseFields;
1041
1056
  private ensureBaseColumn;
1042
1057
  private ensureBaseField;
1043
1058
  getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
@@ -1051,6 +1066,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1051
1066
  value: T[Column];
1052
1067
  time: number;
1053
1068
  }[] | undefined;
1069
+ isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
1070
+ isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
1054
1071
  getColumnInfo(): Promise<{
1055
1072
  column: string;
1056
1073
  byteSize: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.44",
3
+ "version": "1.4.46",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -80,6 +80,14 @@ export interface IBulkDatabase2<T extends {
80
80
  value: T[Column];
81
81
  time: number;
82
82
  }[] | undefined;
83
+ /**
84
+ * Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
85
+ * deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
86
+ * and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
87
+ */
88
+ isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
89
+ /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
90
+ isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
83
91
  /** The columns present on disk and their byte sizes (no row data read). */
84
92
  getColumnInfo(): Promise<BulkColumnInfo[]>;
85
93
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -65,6 +65,15 @@ export interface IBulkDatabase2<T extends { key: string }> {
65
65
  /** Synchronous, reactive read of a whole column ({ key, value, time }). undefined while still loading. */
66
66
  getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column]; time: number }[] | undefined;
67
67
 
68
+ /**
69
+ * Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
70
+ * deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
71
+ * and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
72
+ */
73
+ isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
74
+ /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
75
+ isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
76
+
68
77
  /** The columns present on disk and their byte sizes (no row data read). */
69
78
  getColumnInfo(): Promise<BulkColumnInfo[]>;
70
79
  /** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
@@ -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;
@@ -147,6 +154,8 @@ export declare class BulkDatabaseBase<T extends {
147
154
  value: T[Column];
148
155
  time: number;
149
156
  }[] | undefined;
157
+ isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
158
+ isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
150
159
  getColumnInfo(): Promise<{
151
160
  column: string;
152
161
  byteSize: number;
@@ -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
@@ -1420,6 +1486,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1420
1486
  // (Map.has distinguishes "loaded" from "not loaded yet").
1421
1487
  private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
1422
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>();
1423
1494
 
1424
1495
  private ensureBaseColumn(column: string) {
1425
1496
  if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
@@ -1429,6 +1500,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1429
1500
  const base = await this.readWithReload(reader => reader.getColumn(column));
1430
1501
  this.deps.batch(() => {
1431
1502
  this.baseColumns.set(column, base);
1503
+ this.staleBaseColumns.delete(column); // fresh value in hand; stop serving the stale one
1432
1504
  this.baseColumnsLoading.delete(column);
1433
1505
  this.invalidateSignal(LOAD_SIGNAL);
1434
1506
  });
@@ -1450,6 +1522,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
1450
1522
  const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
1451
1523
  this.deps.batch(() => {
1452
1524
  this.baseFields.set(cacheKey, resolved);
1525
+ this.staleBaseFields.delete(cacheKey); // fresh value in hand; stop serving the stale one
1453
1526
  this.baseFieldsLoading.delete(cacheKey);
1454
1527
  this.invalidateSignal(LOAD_SIGNAL);
1455
1528
  });
@@ -1474,15 +1547,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
1474
1547
  let entry = this.overlay.get(key);
1475
1548
  if (entry !== undefined) {
1476
1549
  if (entry.value === DELETED) return undefined;
1477
- 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
+ }
1478
1557
  // column not set in the overlay entry — fall through to the base field cache for this column
1479
1558
  }
1480
1559
  let cacheKey = nullJoin(col, key);
1481
- 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 {
1482
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 };
1483
1571
  return undefined;
1484
1572
  }
1485
- const base = this.baseFields.get(cacheKey);
1573
+ const base = src.get(cacheKey);
1486
1574
  if (base === undefined) {
1487
1575
  // Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
1488
1576
  if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
@@ -1502,7 +1590,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1502
1590
  let base = this.baseColumns.get(col);
1503
1591
  if (!base) {
1504
1592
  this.ensureBaseColumn(col);
1505
- 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
1506
1598
  }
1507
1599
  // Synchronous (no awaits) → reads the current overlay and caches it atomically; an observer re-runs
1508
1600
  // (and the cache is cleared) on any later change, so the frozen result is safe to share.
@@ -1512,6 +1604,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
1512
1604
  return result;
1513
1605
  }
1514
1606
 
1607
+ // Reactive: whether (key, column) is available to read synchronously yet. true once it's loaded — we
1608
+ // know the answer, whether that's a value, absent, or deleted; false while it's still loading from disk.
1609
+ // Pairs with getSingleFieldObjSync, which returns undefined for BOTH "loading" and "absent" — use this
1610
+ // to tell them apart (e.g. show a spinner only when this is false). Triggers the load if not started,
1611
+ // and (like the sync reads) counts the last-known value served during a reload as loaded.
1612
+ public isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean {
1613
+ void this.syncSetup();
1614
+ this.deps.observe(LOAD_SIGNAL);
1615
+ this.deps.observe(key);
1616
+ const entry = this.overlay.get(key);
1617
+ if (entry !== undefined) {
1618
+ if (entry.value === DELETED) return true; // known: deleted
1619
+ if (String(column) in entry.value) return true; // known: overlay holds this column
1620
+ // else: this column falls through to disk — check the base caches below
1621
+ }
1622
+ const cacheKey = nullJoin(String(column), key);
1623
+ if (this.baseFields.has(cacheKey) || this.staleBaseFields.has(cacheKey)) return true;
1624
+ this.ensureBaseField(key, String(column));
1625
+ return false;
1626
+ }
1627
+
1628
+ // Reactive: whether a whole column is available to read synchronously yet (see isFieldLoadedSync).
1629
+ public isColumnLoadedSync<Column extends keyof T>(column: Column): boolean {
1630
+ void this.syncSetup();
1631
+ this.deps.observe(LOAD_SIGNAL);
1632
+ this.deps.observe(OVERLAY_SIGNAL);
1633
+ const col = String(column);
1634
+ if (this.columnCache.has(col) || this.baseColumns.has(col) || this.staleBaseColumns.has(col)) return true;
1635
+ this.ensureBaseColumn(col);
1636
+ return false;
1637
+ }
1638
+
1515
1639
  public async getColumnInfo() {
1516
1640
  let reader = await this.reader();
1517
1641
  return reader.columns;