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
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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;
|