@firtoz/collection-sync 1.0.0
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/package.json +82 -0
- package/src/cache-manager.ts +208 -0
- package/src/connect-partial-sync.ts +349 -0
- package/src/connect-sync.ts +82 -0
- package/src/create-partial-synced-collection.ts +32 -0
- package/src/create-synced-collection.ts +32 -0
- package/src/index.ts +119 -0
- package/src/partial-sync-client-bridge.ts +1025 -0
- package/src/partial-sync-interest.ts +200 -0
- package/src/partial-sync-mutation-handler.ts +152 -0
- package/src/partial-sync-predicate-match.ts +65 -0
- package/src/partial-sync-row-key.ts +57 -0
- package/src/partial-sync-server-bridge.ts +859 -0
- package/src/react/constants.ts +11 -0
- package/src/react/index.ts +50 -0
- package/src/react/partial-sync-adapter.ts +73 -0
- package/src/react/partial-sync-utils.ts +115 -0
- package/src/react/range-conditions-expression.ts +70 -0
- package/src/react/types.ts +232 -0
- package/src/react/usePartialSyncCollection.ts +140 -0
- package/src/react/usePartialSyncViewport.ts +230 -0
- package/src/react/usePartialSyncWindow.ts +807 -0
- package/src/react/usePredicateFilteredRows.ts +169 -0
- package/src/sync-client-bridge.ts +458 -0
- package/src/sync-protocol.ts +362 -0
- package/src/sync-server-bridge.ts +267 -0
- package/src/with-sync.ts +368 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { CacheManager } from "../cache-manager";
|
|
11
|
+
import { connectPartialSync } from "../connect-partial-sync";
|
|
12
|
+
import {
|
|
13
|
+
PartialSyncClientBridge,
|
|
14
|
+
type PartialSyncRangePatchAppliedEvent,
|
|
15
|
+
type PartialSyncState,
|
|
16
|
+
} from "../partial-sync-client-bridge";
|
|
17
|
+
import type { RangeFingerprint, SyncClientMessage } from "../sync-protocol";
|
|
18
|
+
import { DEFAULT_PAGE_LIMIT, DEFAULT_SEEK_COOLDOWN_MS } from "./constants";
|
|
19
|
+
import { partialSyncRowKey } from "../partial-sync-row-key";
|
|
20
|
+
import {
|
|
21
|
+
assertSyncUtils,
|
|
22
|
+
computeFingerprintForIndexWindow,
|
|
23
|
+
defaultPartialSyncVersionMs,
|
|
24
|
+
getPartialSyncRowByMapId,
|
|
25
|
+
tryIdsForIndexWindow,
|
|
26
|
+
} from "./partial-sync-utils";
|
|
27
|
+
import type {
|
|
28
|
+
PartialSyncItem,
|
|
29
|
+
PartialSyncRowSlotView,
|
|
30
|
+
UsePartialSyncWindowOptions,
|
|
31
|
+
UsePartialSyncWindowResult,
|
|
32
|
+
ViewportInfo,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
DEFAULT_PAGE_LIMIT,
|
|
37
|
+
DEFAULT_SEEK_COOLDOWN_MS,
|
|
38
|
+
DEFAULT_SEEK_ROW_GAP,
|
|
39
|
+
DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS,
|
|
40
|
+
DEFAULT_VIEWPORT_RANGE_QUIET_MS,
|
|
41
|
+
} from "./constants";
|
|
42
|
+
|
|
43
|
+
export function usePartialSyncWindow<
|
|
44
|
+
TItem extends PartialSyncItem,
|
|
45
|
+
TSortColumn extends keyof TItem & string,
|
|
46
|
+
>({
|
|
47
|
+
collection,
|
|
48
|
+
sort,
|
|
49
|
+
getSortValue,
|
|
50
|
+
wsUrl,
|
|
51
|
+
wsTransport = "json",
|
|
52
|
+
serializeJson = JSON.stringify,
|
|
53
|
+
deserializeJson = JSON.parse,
|
|
54
|
+
getVersionMs = defaultPartialSyncVersionMs,
|
|
55
|
+
getSortPositions,
|
|
56
|
+
pageLimit = DEFAULT_PAGE_LIMIT,
|
|
57
|
+
seekCooldownMs = DEFAULT_SEEK_COOLDOWN_MS,
|
|
58
|
+
partialWindowResetKey,
|
|
59
|
+
mutationBridge,
|
|
60
|
+
mergeTransportSend,
|
|
61
|
+
collectionId,
|
|
62
|
+
cacheDisplayMode = "immediate",
|
|
63
|
+
}: UsePartialSyncWindowOptions<
|
|
64
|
+
TItem,
|
|
65
|
+
TSortColumn
|
|
66
|
+
>): UsePartialSyncWindowResult<TItem> {
|
|
67
|
+
const [windowStartIndex, setWindowStartIndex] = useState(0);
|
|
68
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
69
|
+
const [nextCursor, setNextCursor] = useState<unknown | null>(null);
|
|
70
|
+
const [hasMore, setHasMore] = useState(true);
|
|
71
|
+
/** True only while `requestRangeQuery` is in flight (server). Not set for cache-only window moves. */
|
|
72
|
+
const [rangeRequestInFlight, setRangeRequestInFlight] = useState(false);
|
|
73
|
+
const [pendingServerRange, setPendingServerRange] = useState<{
|
|
74
|
+
start: number;
|
|
75
|
+
endExclusive: number;
|
|
76
|
+
} | null>(null);
|
|
77
|
+
const [bridgeState, setBridgeState] = useState<PartialSyncState>({
|
|
78
|
+
status: "offline",
|
|
79
|
+
});
|
|
80
|
+
const [viewportInfo, setViewportInfo] = useState<ViewportInfo>({
|
|
81
|
+
firstVisibleIndex: 0,
|
|
82
|
+
lastVisibleIndex: 0,
|
|
83
|
+
});
|
|
84
|
+
const viewportInfoRef = useRef(viewportInfo);
|
|
85
|
+
viewportInfoRef.current = viewportInfo;
|
|
86
|
+
const [lastSeekMeta, setLastSeekMeta] = useState<{
|
|
87
|
+
offset: number;
|
|
88
|
+
reason: "scroll" | "scrollSettled";
|
|
89
|
+
} | null>(null);
|
|
90
|
+
const [collectionVersion, setCollectionVersion] = useState(0);
|
|
91
|
+
const [indexMapVersion, setIndexMapVersion] = useState(0);
|
|
92
|
+
|
|
93
|
+
const globalIndexMapRef = useRef(new Map<number, string | number>());
|
|
94
|
+
const denseRowsRef = useRef<TItem[]>([]);
|
|
95
|
+
const windowStartRef = useRef(windowStartIndex);
|
|
96
|
+
windowStartRef.current = windowStartIndex;
|
|
97
|
+
const sortRef = useRef(sort);
|
|
98
|
+
sortRef.current = sort;
|
|
99
|
+
const totalCountRef = useRef(totalCount);
|
|
100
|
+
totalCountRef.current = totalCount;
|
|
101
|
+
const fetchGenRef = useRef(0);
|
|
102
|
+
const seekCooldownUntilRef = useRef(0);
|
|
103
|
+
const seekToViewportRef = useRef<
|
|
104
|
+
(
|
|
105
|
+
firstVisibleIndex: number,
|
|
106
|
+
opts?: {
|
|
107
|
+
scrollSettled?: boolean;
|
|
108
|
+
lastVisibleIndex?: number;
|
|
109
|
+
force?: boolean;
|
|
110
|
+
},
|
|
111
|
+
) => void
|
|
112
|
+
>(() => {});
|
|
113
|
+
const invalidateSeekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
114
|
+
null,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const getSortValueRef = useRef(getSortValue);
|
|
118
|
+
getSortValueRef.current = getSortValue;
|
|
119
|
+
const getSortPositionsRef = useRef(getSortPositions);
|
|
120
|
+
getSortPositionsRef.current = getSortPositions;
|
|
121
|
+
const getVersionMsRef = useRef(getVersionMs);
|
|
122
|
+
getVersionMsRef.current = getVersionMs;
|
|
123
|
+
|
|
124
|
+
const serializeJsonRef = useRef(serializeJson);
|
|
125
|
+
serializeJsonRef.current = serializeJson;
|
|
126
|
+
const deserializeJsonRef = useRef(deserializeJson);
|
|
127
|
+
deserializeJsonRef.current = deserializeJson;
|
|
128
|
+
|
|
129
|
+
const bumpIndexMap = useCallback(() => {
|
|
130
|
+
setIndexMapVersion((v) => v + 1);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const syncUtils = useMemo(
|
|
134
|
+
() => assertSyncUtils<TItem>(collection.utils),
|
|
135
|
+
[collection],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const syncUtilsRef = useRef(syncUtils);
|
|
139
|
+
syncUtilsRef.current = syncUtils;
|
|
140
|
+
|
|
141
|
+
const collectionRef = useRef(collection);
|
|
142
|
+
collectionRef.current = collection;
|
|
143
|
+
|
|
144
|
+
const cacheManager = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
new CacheManager<TItem>({
|
|
147
|
+
getStorageEstimate: async () => {
|
|
148
|
+
if (
|
|
149
|
+
typeof navigator === "undefined" ||
|
|
150
|
+
!navigator.storage?.estimate
|
|
151
|
+
) {
|
|
152
|
+
const usageBytes = 0;
|
|
153
|
+
const quotaBytes = 50 * 1024 * 1024;
|
|
154
|
+
return {
|
|
155
|
+
usageBytes,
|
|
156
|
+
quotaBytes,
|
|
157
|
+
utilizationRatio: usageBytes / quotaBytes,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const estimate = await navigator.storage.estimate();
|
|
161
|
+
const usageBytes = estimate.usage ?? 0;
|
|
162
|
+
const quotaBytes = estimate.quota ?? 1;
|
|
163
|
+
return {
|
|
164
|
+
usageBytes,
|
|
165
|
+
quotaBytes,
|
|
166
|
+
utilizationRatio: usageBytes / quotaBytes,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
deleteRows: async (keys) => {
|
|
170
|
+
const keySet = new Set<string | number>();
|
|
171
|
+
for (const k of keys) {
|
|
172
|
+
keySet.add(k);
|
|
173
|
+
keySet.add(String(k));
|
|
174
|
+
}
|
|
175
|
+
for (const [idx, id] of globalIndexMapRef.current) {
|
|
176
|
+
if (
|
|
177
|
+
keySet.has(id) ||
|
|
178
|
+
keySet.has(String(id)) ||
|
|
179
|
+
(typeof id === "string" &&
|
|
180
|
+
/^-?\d+$/.test(id) &&
|
|
181
|
+
keySet.has(Number(id)))
|
|
182
|
+
) {
|
|
183
|
+
globalIndexMapRef.current.delete(idx);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
bumpIndexMap();
|
|
187
|
+
await syncUtilsRef.current.receiveSync(
|
|
188
|
+
keys.map((key) => ({ type: "delete", key }) as const),
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
[bumpIndexMap],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const cacheManagerRef = useRef(cacheManager);
|
|
196
|
+
cacheManagerRef.current = cacheManager;
|
|
197
|
+
|
|
198
|
+
// Bridge identity must stay stable: `connectPartialSync` runs in layout effect and
|
|
199
|
+
// calls `setConnecting` / `setConnected`, which updates React state. If `bridge`
|
|
200
|
+
// were recreated whenever `collection` (hence syncUtils/cacheManager) changed,
|
|
201
|
+
// that effect would re-run every render → infinite updates.
|
|
202
|
+
const partialClientId = mutationBridge?.clientId;
|
|
203
|
+
|
|
204
|
+
const bridge = useMemo(
|
|
205
|
+
() =>
|
|
206
|
+
new PartialSyncClientBridge<TItem>({
|
|
207
|
+
...(partialClientId !== undefined ? { clientId: partialClientId } : {}),
|
|
208
|
+
...(collectionId !== undefined ? { collectionId } : {}),
|
|
209
|
+
collection: {
|
|
210
|
+
get: (key) => getPartialSyncRowByMapId(collectionRef.current, key),
|
|
211
|
+
utils: {
|
|
212
|
+
receiveSync: (messages) =>
|
|
213
|
+
syncUtilsRef.current.receiveSync(messages),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
send: () => {},
|
|
217
|
+
onStateChange: (state) => setBridgeState(state),
|
|
218
|
+
beforeApplyRows: async (incomingRows) => {
|
|
219
|
+
const sortNow = sortRef.current;
|
|
220
|
+
const rowsNow = denseRowsRef.current;
|
|
221
|
+
const sortPos = getSortPositionsRef.current;
|
|
222
|
+
const gsv = getSortValueRef.current;
|
|
223
|
+
const cm = cacheManagerRef.current;
|
|
224
|
+
cm.recordFetchedRows(incomingRows, (row) =>
|
|
225
|
+
sortPos !== undefined
|
|
226
|
+
? sortPos(row)
|
|
227
|
+
: {
|
|
228
|
+
[sortNow.column]: gsv(row, sortNow.column),
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
const firstRow = rowsNow[0] ?? incomingRows[0];
|
|
232
|
+
const lastRow =
|
|
233
|
+
rowsNow[rowsNow.length - 1] ??
|
|
234
|
+
incomingRows[incomingRows.length - 1];
|
|
235
|
+
const result = await cm.evictIfNeeded({
|
|
236
|
+
sortColumn: sortNow.column as string,
|
|
237
|
+
sortDirection: sortNow.direction,
|
|
238
|
+
fromValue:
|
|
239
|
+
firstRow !== undefined ? gsv(firstRow, sortNow.column) : "",
|
|
240
|
+
toValue: lastRow !== undefined ? gsv(lastRow, sortNow.column) : "",
|
|
241
|
+
});
|
|
242
|
+
setBridgeState((previous) => {
|
|
243
|
+
if (
|
|
244
|
+
previous.status === "partial" ||
|
|
245
|
+
previous.status === "realtime"
|
|
246
|
+
) {
|
|
247
|
+
return {
|
|
248
|
+
...previous,
|
|
249
|
+
cacheUtilization: result.estimate.utilizationRatio,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return previous;
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
onViewTransition: (e) => {
|
|
256
|
+
if (e.type === "exitView" && e.change.type === "update") {
|
|
257
|
+
const key = partialSyncRowKey(e.change.value.id);
|
|
258
|
+
let removed = false;
|
|
259
|
+
for (const [idx, mappedId] of [
|
|
260
|
+
...globalIndexMapRef.current.entries(),
|
|
261
|
+
]) {
|
|
262
|
+
if (mappedId === key) {
|
|
263
|
+
globalIndexMapRef.current.delete(idx);
|
|
264
|
+
removed = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (removed) {
|
|
268
|
+
bumpIndexMap();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
onRangePatchApplied: ({
|
|
273
|
+
change,
|
|
274
|
+
viewTransition,
|
|
275
|
+
}: PartialSyncRangePatchAppliedEvent<TItem>) => {
|
|
276
|
+
if (change.type !== "update" || viewTransition !== undefined) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (change.previousValue === undefined) return;
|
|
280
|
+
const col = sortRef.current.column;
|
|
281
|
+
const gsv = getSortValueRef.current;
|
|
282
|
+
if (
|
|
283
|
+
gsv(change.previousValue as TItem, col) === gsv(change.value, col)
|
|
284
|
+
) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const rowKey = partialSyncRowKey(change.value.id);
|
|
288
|
+
const inDense = denseRowsRef.current.some(
|
|
289
|
+
(r) => partialSyncRowKey(r.id) === rowKey,
|
|
290
|
+
);
|
|
291
|
+
if (!inDense) return;
|
|
292
|
+
if (invalidateSeekTimerRef.current !== null) {
|
|
293
|
+
clearTimeout(invalidateSeekTimerRef.current);
|
|
294
|
+
}
|
|
295
|
+
invalidateSeekTimerRef.current = setTimeout(() => {
|
|
296
|
+
invalidateSeekTimerRef.current = null;
|
|
297
|
+
seekToViewportRef.current(
|
|
298
|
+
viewportInfoRef.current.firstVisibleIndex,
|
|
299
|
+
{
|
|
300
|
+
force: true,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
}, 80);
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
[partialClientId, collectionId, bumpIndexMap],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Do not list serializeJson / deserializeJson as effect deps: callers often pass
|
|
310
|
+
// inline lambdas (new reference every render). That would re-run this layout effect
|
|
311
|
+
// every time → cleanup calls setConnected(false) and the next run calls setConnecting,
|
|
312
|
+
// each firing onStateChange → maximum update depth exceeded.
|
|
313
|
+
const mutationBridgeRef = useRef(mutationBridge);
|
|
314
|
+
mutationBridgeRef.current = mutationBridge;
|
|
315
|
+
|
|
316
|
+
const mergeTransportSendRef = useRef(mergeTransportSend);
|
|
317
|
+
mergeTransportSendRef.current = mergeTransportSend;
|
|
318
|
+
|
|
319
|
+
useLayoutEffect(() => {
|
|
320
|
+
const disconnect = connectPartialSync(bridge, {
|
|
321
|
+
url: wsUrl,
|
|
322
|
+
transport: wsTransport,
|
|
323
|
+
setTransportSend: (send) => {
|
|
324
|
+
bridge.setSend((message: SyncClientMessage) => send(message));
|
|
325
|
+
mergeTransportSendRef.current?.(send);
|
|
326
|
+
},
|
|
327
|
+
serializeJson: (value: unknown) => serializeJsonRef.current(value),
|
|
328
|
+
deserializeJson: (raw: string) => deserializeJsonRef.current(raw),
|
|
329
|
+
mutationBridge: mutationBridgeRef.current,
|
|
330
|
+
});
|
|
331
|
+
return () => {
|
|
332
|
+
disconnect();
|
|
333
|
+
};
|
|
334
|
+
}, [bridge, wsTransport, wsUrl]);
|
|
335
|
+
|
|
336
|
+
const confirmedKeysRevision = useSyncExternalStore(
|
|
337
|
+
(onStoreChange) => bridge.subscribeConfirmedKeysRevision(onStoreChange),
|
|
338
|
+
() => bridge.serverConfirmedKeysRevision,
|
|
339
|
+
() => 0,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const indexRows = useMemo(() => {
|
|
343
|
+
void collectionVersion;
|
|
344
|
+
void indexMapVersion;
|
|
345
|
+
void confirmedKeysRevision;
|
|
346
|
+
const start = windowStartIndex;
|
|
347
|
+
const out: TItem[] = [];
|
|
348
|
+
for (let i = 0; ; i += 1) {
|
|
349
|
+
const id = globalIndexMapRef.current.get(start + i);
|
|
350
|
+
if (id === undefined) break;
|
|
351
|
+
const row = getPartialSyncRowByMapId(collection, id);
|
|
352
|
+
if (row === undefined) continue;
|
|
353
|
+
out.push(row);
|
|
354
|
+
}
|
|
355
|
+
if (cacheDisplayMode === "confirmed") {
|
|
356
|
+
return out.filter((row) =>
|
|
357
|
+
bridge.serverConfirmedKeys.has(partialSyncRowKey(row.id)),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}, [
|
|
362
|
+
bridge,
|
|
363
|
+
cacheDisplayMode,
|
|
364
|
+
collection,
|
|
365
|
+
collectionVersion,
|
|
366
|
+
confirmedKeysRevision,
|
|
367
|
+
indexMapVersion,
|
|
368
|
+
windowStartIndex,
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
const rows = indexRows;
|
|
372
|
+
|
|
373
|
+
useLayoutEffect(() => {
|
|
374
|
+
denseRowsRef.current = rows;
|
|
375
|
+
}, [rows]);
|
|
376
|
+
|
|
377
|
+
const getRowSlot = useCallback(
|
|
378
|
+
(globalIndex: number): PartialSyncRowSlotView<TItem> => {
|
|
379
|
+
void collectionVersion;
|
|
380
|
+
void indexMapVersion;
|
|
381
|
+
const id = globalIndexMapRef.current.get(globalIndex);
|
|
382
|
+
const row =
|
|
383
|
+
id !== undefined ? getPartialSyncRowByMapId(collection, id) : undefined;
|
|
384
|
+
if (row !== undefined) {
|
|
385
|
+
const ws = windowStartIndex;
|
|
386
|
+
const denseEnd = ws + rows.length;
|
|
387
|
+
const inDense = globalIndex >= ws && globalIndex < denseEnd;
|
|
388
|
+
return {
|
|
389
|
+
row,
|
|
390
|
+
slot: inDense ? "ready" : "ready_global",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (id !== undefined) {
|
|
394
|
+
return { row: undefined, slot: "stale_map" };
|
|
395
|
+
}
|
|
396
|
+
if (
|
|
397
|
+
rangeRequestInFlight &&
|
|
398
|
+
pendingServerRange !== null &&
|
|
399
|
+
globalIndex >= pendingServerRange.start &&
|
|
400
|
+
globalIndex < pendingServerRange.endExclusive
|
|
401
|
+
) {
|
|
402
|
+
return { row: undefined, slot: "server" };
|
|
403
|
+
}
|
|
404
|
+
return { row: undefined, slot: "none" };
|
|
405
|
+
},
|
|
406
|
+
[
|
|
407
|
+
collection,
|
|
408
|
+
collectionVersion,
|
|
409
|
+
indexMapVersion,
|
|
410
|
+
windowStartIndex,
|
|
411
|
+
rows.length,
|
|
412
|
+
rangeRequestInFlight,
|
|
413
|
+
pendingServerRange,
|
|
414
|
+
],
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const recordIdsAtOffset = useCallback(
|
|
418
|
+
(offset: number, fetchedRows: TItem[]) => {
|
|
419
|
+
for (let i = 0; i < fetchedRows.length; i += 1) {
|
|
420
|
+
globalIndexMapRef.current.set(
|
|
421
|
+
offset + i,
|
|
422
|
+
partialSyncRowKey(fetchedRows[i].id),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
bumpIndexMap();
|
|
426
|
+
},
|
|
427
|
+
[bumpIndexMap],
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const fetchNext = useCallback(async () => {
|
|
431
|
+
if (rangeRequestInFlight || !hasMore) return;
|
|
432
|
+
const gen = fetchGenRef.current;
|
|
433
|
+
const fetchStart = windowStartRef.current + denseRowsRef.current.length;
|
|
434
|
+
setPendingServerRange({
|
|
435
|
+
start: fetchStart,
|
|
436
|
+
endExclusive: fetchStart + pageLimit,
|
|
437
|
+
});
|
|
438
|
+
setRangeRequestInFlight(true);
|
|
439
|
+
try {
|
|
440
|
+
const result = await bridge.requestRangeQuery({
|
|
441
|
+
kind: "index",
|
|
442
|
+
mode: "cursor",
|
|
443
|
+
sort: sortRef.current as { column: string; direction: "asc" | "desc" },
|
|
444
|
+
limit: pageLimit,
|
|
445
|
+
afterCursor: nextCursor,
|
|
446
|
+
});
|
|
447
|
+
if (gen !== fetchGenRef.current) return;
|
|
448
|
+
|
|
449
|
+
if (result.invalidateWindow && result.rows.length === 0) {
|
|
450
|
+
const again = await bridge.requestRangeQuery({
|
|
451
|
+
kind: "index",
|
|
452
|
+
mode: "cursor",
|
|
453
|
+
sort: sortRef.current as {
|
|
454
|
+
column: string;
|
|
455
|
+
direction: "asc" | "desc";
|
|
456
|
+
},
|
|
457
|
+
limit: pageLimit,
|
|
458
|
+
afterCursor: nextCursor,
|
|
459
|
+
});
|
|
460
|
+
if (gen !== fetchGenRef.current) return;
|
|
461
|
+
recordIdsAtOffset(
|
|
462
|
+
windowStartRef.current + denseRowsRef.current.length,
|
|
463
|
+
again.rows,
|
|
464
|
+
);
|
|
465
|
+
setTotalCount(again.totalCount);
|
|
466
|
+
setNextCursor(again.lastCursor);
|
|
467
|
+
setHasMore(again.rows.length === pageLimit);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (result.upToDate) {
|
|
472
|
+
setTotalCount(result.totalCount);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
recordIdsAtOffset(
|
|
477
|
+
windowStartRef.current + denseRowsRef.current.length,
|
|
478
|
+
result.rows,
|
|
479
|
+
);
|
|
480
|
+
setTotalCount(result.totalCount);
|
|
481
|
+
setNextCursor(result.lastCursor);
|
|
482
|
+
setHasMore(result.rows.length === pageLimit);
|
|
483
|
+
} catch (error: unknown) {
|
|
484
|
+
if (
|
|
485
|
+
error !== null &&
|
|
486
|
+
typeof error === "object" &&
|
|
487
|
+
"name" in error &&
|
|
488
|
+
error.name === "AbortError"
|
|
489
|
+
) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
} finally {
|
|
494
|
+
if (gen === fetchGenRef.current) {
|
|
495
|
+
setPendingServerRange(null);
|
|
496
|
+
setRangeRequestInFlight(false);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}, [
|
|
500
|
+
bridge,
|
|
501
|
+
hasMore,
|
|
502
|
+
rangeRequestInFlight,
|
|
503
|
+
nextCursor,
|
|
504
|
+
pageLimit,
|
|
505
|
+
recordIdsAtOffset,
|
|
506
|
+
]);
|
|
507
|
+
|
|
508
|
+
const seekToViewport = useCallback(
|
|
509
|
+
(
|
|
510
|
+
firstVisibleIndex: number,
|
|
511
|
+
options?: {
|
|
512
|
+
scrollSettled?: boolean;
|
|
513
|
+
lastVisibleIndex?: number;
|
|
514
|
+
force?: boolean;
|
|
515
|
+
},
|
|
516
|
+
) => {
|
|
517
|
+
const offset = Math.max(0, firstVisibleIndex);
|
|
518
|
+
const loadedEndExclusive =
|
|
519
|
+
windowStartRef.current + denseRowsRef.current.length;
|
|
520
|
+
// For scrollSettled, `lastVisibleIndex` comes from scroll geometry (viewport height), not
|
|
521
|
+
// TanStack overscan — safe to require the whole visible span to fit in the dense window.
|
|
522
|
+
// Without last, keep first-only (e.g. non-settled callers).
|
|
523
|
+
const lastForDense =
|
|
524
|
+
options?.scrollSettled === true &&
|
|
525
|
+
typeof options.lastVisibleIndex === "number"
|
|
526
|
+
? options.lastVisibleIndex
|
|
527
|
+
: firstVisibleIndex;
|
|
528
|
+
const inDenseWindow =
|
|
529
|
+
denseRowsRef.current.length > 0 &&
|
|
530
|
+
firstVisibleIndex >= windowStartRef.current &&
|
|
531
|
+
lastForDense < loadedEndExclusive;
|
|
532
|
+
|
|
533
|
+
if (options?.scrollSettled === true && inDenseWindow && !options?.force) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
if (!options?.force && now < seekCooldownUntilRef.current) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!options?.scrollSettled) {
|
|
542
|
+
// Live scroll: virtualizer updates indices before `windowStartIndex` catches up (seek was
|
|
543
|
+
// scrollSettled-only). If the first visible row is outside [windowStart, loadedEnd), reconcile
|
|
544
|
+
// immediately (throttled) so we do not paint "not cached yet" until scrollend.
|
|
545
|
+
const ws = windowStartRef.current;
|
|
546
|
+
const f = firstVisibleIndex;
|
|
547
|
+
const firstInsideDense =
|
|
548
|
+
denseRowsRef.current.length > 0 && f >= ws && f < loadedEndExclusive;
|
|
549
|
+
if (firstInsideDense && !options?.force) return;
|
|
550
|
+
}
|
|
551
|
+
seekCooldownUntilRef.current = now + seekCooldownMs;
|
|
552
|
+
|
|
553
|
+
setLastSeekMeta({
|
|
554
|
+
offset,
|
|
555
|
+
reason: options?.scrollSettled === true ? "scrollSettled" : "scroll",
|
|
556
|
+
});
|
|
557
|
+
fetchGenRef.current += 1;
|
|
558
|
+
const gen = fetchGenRef.current;
|
|
559
|
+
bridge.abortRangeRequests();
|
|
560
|
+
|
|
561
|
+
const want = Math.min(
|
|
562
|
+
pageLimit,
|
|
563
|
+
Math.max(0, totalCountRef.current - offset),
|
|
564
|
+
);
|
|
565
|
+
if (totalCountRef.current > 0 && want === 0) {
|
|
566
|
+
setWindowStartIndex(offset);
|
|
567
|
+
windowStartRef.current = offset;
|
|
568
|
+
setNextCursor(null);
|
|
569
|
+
setHasMore(false);
|
|
570
|
+
setPendingServerRange(null);
|
|
571
|
+
setRangeRequestInFlight(false);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const ids = tryIdsForIndexWindow(
|
|
576
|
+
globalIndexMapRef.current,
|
|
577
|
+
offset,
|
|
578
|
+
want,
|
|
579
|
+
totalCountRef.current,
|
|
580
|
+
);
|
|
581
|
+
if (ids !== null) {
|
|
582
|
+
setWindowStartIndex(offset);
|
|
583
|
+
const lastId = ids[ids.length - 1];
|
|
584
|
+
const gsv = getSortValueRef.current;
|
|
585
|
+
const lastRow =
|
|
586
|
+
lastId !== undefined
|
|
587
|
+
? getPartialSyncRowByMapId(collection, lastId)
|
|
588
|
+
: undefined;
|
|
589
|
+
setNextCursor(
|
|
590
|
+
lastRow !== undefined ? gsv(lastRow, sortRef.current.column) : null,
|
|
591
|
+
);
|
|
592
|
+
setHasMore(offset + ids.length < totalCountRef.current);
|
|
593
|
+
setPendingServerRange(null);
|
|
594
|
+
bumpIndexMap();
|
|
595
|
+
windowStartRef.current = offset;
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let fingerprint: RangeFingerprint | undefined;
|
|
600
|
+
if (totalCountRef.current > 0 && want > 0) {
|
|
601
|
+
const fp = computeFingerprintForIndexWindow(
|
|
602
|
+
collection,
|
|
603
|
+
globalIndexMapRef.current,
|
|
604
|
+
offset,
|
|
605
|
+
want,
|
|
606
|
+
(row) => getVersionMsRef.current(row),
|
|
607
|
+
);
|
|
608
|
+
if (fp !== undefined) {
|
|
609
|
+
fingerprint = fp;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
setWindowStartIndex(offset);
|
|
614
|
+
windowStartRef.current = offset;
|
|
615
|
+
setNextCursor(null);
|
|
616
|
+
setHasMore(true);
|
|
617
|
+
setPendingServerRange({ start: offset, endExclusive: offset + want });
|
|
618
|
+
setRangeRequestInFlight(true);
|
|
619
|
+
void (async () => {
|
|
620
|
+
try {
|
|
621
|
+
let result = await bridge.requestRangeQuery(
|
|
622
|
+
{
|
|
623
|
+
kind: "index",
|
|
624
|
+
mode: "offset",
|
|
625
|
+
sort: sortRef.current as {
|
|
626
|
+
column: string;
|
|
627
|
+
direction: "asc" | "desc";
|
|
628
|
+
},
|
|
629
|
+
limit: pageLimit,
|
|
630
|
+
offset,
|
|
631
|
+
},
|
|
632
|
+
fingerprint,
|
|
633
|
+
);
|
|
634
|
+
if (gen !== fetchGenRef.current) return;
|
|
635
|
+
|
|
636
|
+
if (result.upToDate) {
|
|
637
|
+
setTotalCount(result.totalCount);
|
|
638
|
+
setHasMore(offset + pageLimit < result.totalCount);
|
|
639
|
+
const gsv = getSortValueRef.current;
|
|
640
|
+
const lastId =
|
|
641
|
+
globalIndexMapRef.current.get(offset + want - 1) ??
|
|
642
|
+
globalIndexMapRef.current.get(offset + pageLimit - 1);
|
|
643
|
+
const lastRow =
|
|
644
|
+
lastId !== undefined
|
|
645
|
+
? getPartialSyncRowByMapId(collection, lastId)
|
|
646
|
+
: undefined;
|
|
647
|
+
setNextCursor(
|
|
648
|
+
lastRow !== undefined
|
|
649
|
+
? gsv(lastRow, sortRef.current.column)
|
|
650
|
+
: null,
|
|
651
|
+
);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (result.invalidateWindow && result.rows.length === 0) {
|
|
656
|
+
result = await bridge.requestRangeQuery({
|
|
657
|
+
kind: "index",
|
|
658
|
+
mode: "offset",
|
|
659
|
+
sort: sortRef.current as {
|
|
660
|
+
column: string;
|
|
661
|
+
direction: "asc" | "desc";
|
|
662
|
+
},
|
|
663
|
+
limit: pageLimit,
|
|
664
|
+
offset,
|
|
665
|
+
});
|
|
666
|
+
if (gen !== fetchGenRef.current) return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
recordIdsAtOffset(offset, result.rows);
|
|
670
|
+
setTotalCount(result.totalCount);
|
|
671
|
+
setNextCursor(result.lastCursor);
|
|
672
|
+
setHasMore(result.rows.length === pageLimit);
|
|
673
|
+
} catch (error: unknown) {
|
|
674
|
+
if (
|
|
675
|
+
error !== null &&
|
|
676
|
+
typeof error === "object" &&
|
|
677
|
+
"name" in error &&
|
|
678
|
+
error.name === "AbortError"
|
|
679
|
+
) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
throw error;
|
|
683
|
+
} finally {
|
|
684
|
+
if (gen === fetchGenRef.current) {
|
|
685
|
+
setPendingServerRange(null);
|
|
686
|
+
setRangeRequestInFlight(false);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
})();
|
|
690
|
+
},
|
|
691
|
+
[
|
|
692
|
+
bridge,
|
|
693
|
+
bumpIndexMap,
|
|
694
|
+
collection,
|
|
695
|
+
pageLimit,
|
|
696
|
+
recordIdsAtOffset,
|
|
697
|
+
seekCooldownMs,
|
|
698
|
+
],
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
seekToViewportRef.current = seekToViewport;
|
|
702
|
+
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
return () => {
|
|
705
|
+
if (invalidateSeekTimerRef.current !== null) {
|
|
706
|
+
clearTimeout(invalidateSeekTimerRef.current);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}, []);
|
|
710
|
+
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
const sub = collection.subscribeChanges(() => {
|
|
713
|
+
// Remove individual stale map entries (row deleted from collection, but map still
|
|
714
|
+
// references it). This is lightweight: no wholesale clear, no force-seek, no abort.
|
|
715
|
+
// `indexRows` already skips stale entries so the UI stays populated.
|
|
716
|
+
let removedStaleMapEntry = false;
|
|
717
|
+
if (globalIndexMapRef.current.size > 0) {
|
|
718
|
+
for (const [idx, id] of globalIndexMapRef.current) {
|
|
719
|
+
if (getPartialSyncRowByMapId(collection, id) === undefined) {
|
|
720
|
+
globalIndexMapRef.current.delete(idx);
|
|
721
|
+
removedStaleMapEntry = true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (removedStaleMapEntry) {
|
|
726
|
+
bumpIndexMap();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
setCollectionVersion((v) => v + 1);
|
|
730
|
+
const sortNow = sortRef.current;
|
|
731
|
+
const sortPos = getSortPositionsRef.current;
|
|
732
|
+
const gsv = getSortValueRef.current;
|
|
733
|
+
cacheManagerRef.current.resyncSortPositionsForTrackedRows(
|
|
734
|
+
(key) => getPartialSyncRowByMapId(collection, key),
|
|
735
|
+
(row) =>
|
|
736
|
+
sortPos !== undefined
|
|
737
|
+
? sortPos(row)
|
|
738
|
+
: {
|
|
739
|
+
[sortNow.column]: gsv(row, sortNow.column),
|
|
740
|
+
},
|
|
741
|
+
);
|
|
742
|
+
});
|
|
743
|
+
return () => {
|
|
744
|
+
sub.unsubscribe();
|
|
745
|
+
};
|
|
746
|
+
}, [bumpIndexMap, collection]);
|
|
747
|
+
|
|
748
|
+
const seekAfterScrollSettled = useCallback(
|
|
749
|
+
(firstVisibleIndex: number, lastVisibleIndex?: number) => {
|
|
750
|
+
seekToViewport(firstVisibleIndex, {
|
|
751
|
+
scrollSettled: true,
|
|
752
|
+
lastVisibleIndex,
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
[seekToViewport],
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Re-run when sort or logical collection identity changes — not on TanStack `collection` ref churn.
|
|
759
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional narrow deps; truncate uses syncUtilsRef
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
fetchGenRef.current += 1;
|
|
762
|
+
bridge.abortRangeRequests();
|
|
763
|
+
bridge.clearTrackedRowIds();
|
|
764
|
+
setWindowStartIndex(0);
|
|
765
|
+
setTotalCount(0);
|
|
766
|
+
setNextCursor(null);
|
|
767
|
+
setHasMore(true);
|
|
768
|
+
setLastSeekMeta(null);
|
|
769
|
+
setRangeRequestInFlight(false);
|
|
770
|
+
setPendingServerRange(null);
|
|
771
|
+
globalIndexMapRef.current.clear();
|
|
772
|
+
bumpIndexMap();
|
|
773
|
+
void syncUtilsRef.current.truncate();
|
|
774
|
+
cacheManager.clear();
|
|
775
|
+
}, [
|
|
776
|
+
bridge,
|
|
777
|
+
bumpIndexMap,
|
|
778
|
+
cacheManager,
|
|
779
|
+
sort.column,
|
|
780
|
+
sort.direction,
|
|
781
|
+
partialWindowResetKey ?? "",
|
|
782
|
+
]);
|
|
783
|
+
|
|
784
|
+
useEffect(() => {
|
|
785
|
+
if (rows.length === 0 && !rangeRequestInFlight && hasMore) {
|
|
786
|
+
void fetchNext();
|
|
787
|
+
}
|
|
788
|
+
}, [fetchNext, hasMore, rangeRequestInFlight, rows.length]);
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
bridge,
|
|
792
|
+
cacheManager,
|
|
793
|
+
rows,
|
|
794
|
+
windowStartIndex,
|
|
795
|
+
totalCount,
|
|
796
|
+
rangeRequestInFlight,
|
|
797
|
+
hasMore,
|
|
798
|
+
fetchNext,
|
|
799
|
+
seekToViewport,
|
|
800
|
+
seekAfterScrollSettled,
|
|
801
|
+
bridgeState,
|
|
802
|
+
viewportInfo,
|
|
803
|
+
setViewportInfo,
|
|
804
|
+
lastSeekMeta,
|
|
805
|
+
getRowSlot,
|
|
806
|
+
};
|
|
807
|
+
}
|