@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,11 @@
|
|
|
1
|
+
export const DEFAULT_PAGE_LIMIT = 50;
|
|
2
|
+
export const DEFAULT_SEEK_ROW_GAP = 80;
|
|
3
|
+
export const DEFAULT_SEEK_COOLDOWN_MS = 200;
|
|
4
|
+
|
|
5
|
+
/** Default quiet period before coalesced predicate `rangeQuery` after viewport motion. */
|
|
6
|
+
export const DEFAULT_VIEWPORT_RANGE_QUIET_MS = 72;
|
|
7
|
+
/**
|
|
8
|
+
* Default max time between predicate `rangeQuery` calls while the viewport keeps changing
|
|
9
|
+
* (still issues a fetch even during continuous motion).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS = 200;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_PAGE_LIMIT,
|
|
3
|
+
DEFAULT_SEEK_COOLDOWN_MS,
|
|
4
|
+
DEFAULT_SEEK_ROW_GAP,
|
|
5
|
+
DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS,
|
|
6
|
+
DEFAULT_VIEWPORT_RANGE_QUIET_MS,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
export type {
|
|
9
|
+
CacheDisplayMode,
|
|
10
|
+
PartialSyncCollection,
|
|
11
|
+
PartialSyncItem,
|
|
12
|
+
PartialSyncLiveCollection,
|
|
13
|
+
PartialSyncRowRef,
|
|
14
|
+
PartialSyncRowShape,
|
|
15
|
+
PartialSyncRowSlot,
|
|
16
|
+
PartialSyncRowSlotView,
|
|
17
|
+
PartialSyncRowVersion,
|
|
18
|
+
UsePartialSyncCollectionOptions,
|
|
19
|
+
UsePartialSyncCollectionResult,
|
|
20
|
+
UsePartialSyncViewportOptions,
|
|
21
|
+
UsePartialSyncViewportResult,
|
|
22
|
+
UsePartialSyncWindowOptions,
|
|
23
|
+
UsePartialSyncWindowResult,
|
|
24
|
+
UsePredicateFilteredRowsOptions,
|
|
25
|
+
ViewportInfo,
|
|
26
|
+
} from "./types";
|
|
27
|
+
export type {
|
|
28
|
+
CreatePartialSyncAdapterConfig,
|
|
29
|
+
NumericAxisSpec,
|
|
30
|
+
PartialSyncViewportAdapter,
|
|
31
|
+
PartialSyncViewportItem,
|
|
32
|
+
PredicateSortSpec,
|
|
33
|
+
} from "./partial-sync-adapter";
|
|
34
|
+
export {
|
|
35
|
+
betweenConditionsForNumericAxes,
|
|
36
|
+
createPartialSyncAdapter,
|
|
37
|
+
} from "./partial-sync-adapter";
|
|
38
|
+
export {
|
|
39
|
+
assertSyncUtils,
|
|
40
|
+
computeFingerprintForIndexWindow,
|
|
41
|
+
defaultPartialSyncVersionMs,
|
|
42
|
+
defaultPredicateColumnValue,
|
|
43
|
+
getPartialSyncRowByMapId,
|
|
44
|
+
matchesPredicate,
|
|
45
|
+
tryIdsForIndexWindow,
|
|
46
|
+
} from "./partial-sync-utils";
|
|
47
|
+
export { usePartialSyncWindow } from "./usePartialSyncWindow";
|
|
48
|
+
export { usePartialSyncCollection } from "./usePartialSyncCollection";
|
|
49
|
+
export { usePartialSyncViewport } from "./usePartialSyncViewport";
|
|
50
|
+
export { usePredicateFilteredRows } from "./usePredicateFilteredRows";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PartialSyncRowShape } from "../partial-sync-row-key";
|
|
2
|
+
import type { RangeCondition, SyncRangeSort } from "../sync-protocol";
|
|
3
|
+
|
|
4
|
+
/** Row shape compatible with partial-sync viewport hooks (matches {@link PartialSyncItem}). */
|
|
5
|
+
export type PartialSyncViewportItem = PartialSyncRowShape;
|
|
6
|
+
|
|
7
|
+
export type PredicateSortSpec<TSortColumn extends string> = {
|
|
8
|
+
column: TSortColumn;
|
|
9
|
+
direction: SyncRangeSort["direction"];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bundles predicate ↔ viewport mapping, optional prefetch expansion, and sort accessors for
|
|
14
|
+
* {@link usePartialSyncViewport} + {@link usePredicateFilteredRows}.
|
|
15
|
+
*/
|
|
16
|
+
export type PartialSyncViewportAdapter<
|
|
17
|
+
TItem extends PartialSyncViewportItem,
|
|
18
|
+
TViewport,
|
|
19
|
+
TSortColumn extends keyof TItem & string,
|
|
20
|
+
> = {
|
|
21
|
+
toConditions: (viewport: TViewport) => RangeCondition[];
|
|
22
|
+
expandViewport: (viewport: TViewport, pad: number) => TViewport;
|
|
23
|
+
sort: PredicateSortSpec<TSortColumn>;
|
|
24
|
+
getSortValue: (row: TItem, column: TSortColumn) => unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type CreatePartialSyncAdapterConfig<
|
|
28
|
+
TItem extends PartialSyncViewportItem,
|
|
29
|
+
TViewport,
|
|
30
|
+
TSortColumn extends keyof TItem & string,
|
|
31
|
+
> = {
|
|
32
|
+
toConditions: (viewport: TViewport) => RangeCondition[];
|
|
33
|
+
/** Widen viewport before server `rangeQuery`. Default: identity (no prefetch). */
|
|
34
|
+
expandViewport?: (viewport: TViewport, pad: number) => TViewport;
|
|
35
|
+
sort: PredicateSortSpec<TSortColumn>;
|
|
36
|
+
getSortValue: (row: TItem, column: TSortColumn) => unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createPartialSyncAdapter<
|
|
40
|
+
TItem extends PartialSyncViewportItem,
|
|
41
|
+
TViewport,
|
|
42
|
+
TSortColumn extends keyof TItem & string,
|
|
43
|
+
>(
|
|
44
|
+
config: CreatePartialSyncAdapterConfig<TItem, TViewport, TSortColumn>,
|
|
45
|
+
): PartialSyncViewportAdapter<TItem, TViewport, TSortColumn> {
|
|
46
|
+
return {
|
|
47
|
+
toConditions: config.toConditions,
|
|
48
|
+
expandViewport: config.expandViewport ?? ((v) => v),
|
|
49
|
+
sort: config.sort,
|
|
50
|
+
getSortValue: config.getSortValue,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type NumericAxisSpec<TViewport> = {
|
|
55
|
+
readonly column: string;
|
|
56
|
+
readonly min: (viewport: TViewport) => number;
|
|
57
|
+
readonly max: (viewport: TViewport) => number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds `between` {@link RangeCondition}s from numeric axis accessors (N-D box / interval per column).
|
|
62
|
+
*/
|
|
63
|
+
export function betweenConditionsForNumericAxes<TViewport>(
|
|
64
|
+
viewport: TViewport,
|
|
65
|
+
axes: readonly NumericAxisSpec<TViewport>[],
|
|
66
|
+
): RangeCondition[] {
|
|
67
|
+
return axes.map((axis) => ({
|
|
68
|
+
column: axis.column,
|
|
69
|
+
op: "between" as const,
|
|
70
|
+
value: axis.min(viewport),
|
|
71
|
+
valueTo: axis.max(viewport),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CollectionUtils } from "@firtoz/db-helpers";
|
|
2
|
+
import type { UtilsRecord } from "@tanstack/db";
|
|
3
|
+
import type { RangeFingerprint } from "../sync-protocol";
|
|
4
|
+
import type { PartialSyncCollection, PartialSyncItem } from "./types";
|
|
5
|
+
|
|
6
|
+
/** Merge every row currently in the collection into the bridge cache (id set only). Idempotent. */
|
|
7
|
+
export function primePartialSyncBridgeCachedIdsFromCollection<
|
|
8
|
+
TItem extends PartialSyncItem,
|
|
9
|
+
>(
|
|
10
|
+
bridge: { seedHydratedLocalRows: (rows: readonly TItem[]) => void },
|
|
11
|
+
collection: Pick<PartialSyncCollection<TItem>, "entries">,
|
|
12
|
+
): void {
|
|
13
|
+
bridge.seedHydratedLocalRows(Array.from(collection.entries(), ([, v]) => v));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TanStack `Collection` types `utils` as {@link UtilsRecord}. This narrows to sync helpers when
|
|
18
|
+
* present (e.g. after `memoryCollectionOptions` / Drizzle sync config).
|
|
19
|
+
*/
|
|
20
|
+
export function assertSyncUtils<TItem>(
|
|
21
|
+
utils: UtilsRecord,
|
|
22
|
+
): CollectionUtils<TItem> {
|
|
23
|
+
const receiveSync = utils.receiveSync;
|
|
24
|
+
const truncate = utils.truncate;
|
|
25
|
+
if (typeof receiveSync === "function" && typeof truncate === "function") {
|
|
26
|
+
return {
|
|
27
|
+
receiveSync: receiveSync as CollectionUtils<TItem>["receiveSync"],
|
|
28
|
+
truncate: truncate as CollectionUtils<TItem>["truncate"],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
throw new Error(
|
|
32
|
+
"Partial sync requires collection.utils.receiveSync and collection.utils.truncate",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Default fingerprint version: max `updatedAt` as epoch ms. */
|
|
37
|
+
export function defaultPartialSyncVersionMs<TItem extends PartialSyncItem>(
|
|
38
|
+
row: TItem,
|
|
39
|
+
): number {
|
|
40
|
+
const v = row.updatedAt;
|
|
41
|
+
if (v === null) return 0;
|
|
42
|
+
if (v instanceof Date) return v.getTime();
|
|
43
|
+
if (typeof v === "number") return v;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a row for an id stored in the partial-sync index map. Uses {@link PartialSyncCollection.get}
|
|
49
|
+
* first; if that misses (e.g. key type / boxed string mismatch vs TanStack’s internal map), scans
|
|
50
|
+
* {@link PartialSyncCollection.entries} by `String(key)`.
|
|
51
|
+
*/
|
|
52
|
+
export function getPartialSyncRowByMapId<TItem extends PartialSyncItem>(
|
|
53
|
+
collection: PartialSyncCollection<TItem>,
|
|
54
|
+
id: string | number,
|
|
55
|
+
): TItem | undefined {
|
|
56
|
+
const direct = collection.get(id);
|
|
57
|
+
if (direct !== undefined) return direct;
|
|
58
|
+
const sid = String(id);
|
|
59
|
+
for (const [k, row] of collection.entries()) {
|
|
60
|
+
if (String(k) === sid) return row;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns consecutive row ids for `[offset, offset + want)` if all present in the map; else null.
|
|
67
|
+
*/
|
|
68
|
+
export function tryIdsForIndexWindow<TKey extends string | number>(
|
|
69
|
+
map: Map<number, TKey>,
|
|
70
|
+
offset: number,
|
|
71
|
+
want: number,
|
|
72
|
+
totalCount: number,
|
|
73
|
+
): TKey[] | null {
|
|
74
|
+
if (totalCount === 0) return null;
|
|
75
|
+
const n = Math.min(want, Math.max(0, totalCount - offset));
|
|
76
|
+
if (n === 0) return [];
|
|
77
|
+
const out: TKey[] = [];
|
|
78
|
+
for (let i = 0; i < n; i += 1) {
|
|
79
|
+
const id = map.get(offset + i);
|
|
80
|
+
if (id === undefined) return null;
|
|
81
|
+
out.push(id);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fingerprint for reconciliation when every index in `[offset, offset + want)` is mapped and rows
|
|
88
|
+
* exist in the collection.
|
|
89
|
+
*/
|
|
90
|
+
export function computeFingerprintForIndexWindow<TItem extends PartialSyncItem>(
|
|
91
|
+
collection: PartialSyncCollection<TItem>,
|
|
92
|
+
map: Map<number, string | number>,
|
|
93
|
+
offset: number,
|
|
94
|
+
want: number,
|
|
95
|
+
getVersionMs: (row: TItem) => number = defaultPartialSyncVersionMs,
|
|
96
|
+
): RangeFingerprint | undefined {
|
|
97
|
+
if (want <= 0) return undefined;
|
|
98
|
+
let maxV = 0;
|
|
99
|
+
let count = 0;
|
|
100
|
+
for (let i = 0; i < want; i += 1) {
|
|
101
|
+
const id = map.get(offset + i);
|
|
102
|
+
if (id === undefined) return undefined;
|
|
103
|
+
const row = getPartialSyncRowByMapId(collection, id);
|
|
104
|
+
if (row === undefined) return undefined;
|
|
105
|
+
count += 1;
|
|
106
|
+
const ms = getVersionMs(row);
|
|
107
|
+
if (ms > maxV) maxV = ms;
|
|
108
|
+
}
|
|
109
|
+
return { version: maxV, count };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
defaultPredicateColumnValue,
|
|
114
|
+
matchesPredicate,
|
|
115
|
+
} from "../partial-sync-predicate-match";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { and, eq, gt, gte, lt, lte, not, type Ref } from "@tanstack/db";
|
|
2
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
3
|
+
import type { PartialSyncRowShape } from "../partial-sync-row-key";
|
|
4
|
+
import type { RangeCondition } from "../sync-protocol";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Row ref from `q.from({ items: collection }).where(({ items }) => …)`.
|
|
8
|
+
* Column access must match {@link RangeCondition.column} names on the stored row shape.
|
|
9
|
+
*
|
|
10
|
+
* Uses TanStack {@link Ref} so column reads are `ExpressionLike` (e.g. for `inArray`), not `unknown`.
|
|
11
|
+
*/
|
|
12
|
+
export type PredicateRowRef = Ref<
|
|
13
|
+
PartialSyncRowShape & Record<string, unknown>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Row accepted by {@link buildRangeConditionsAndExpression}: live query refs or plain objects (e.g. tests).
|
|
18
|
+
* Dynamic `column` access yields `unknown`, which TanStack comparison helpers still accept.
|
|
19
|
+
*/
|
|
20
|
+
export type PredicateRangeBuildRow = PredicateRowRef | Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds a TanStack DB `where` expression AND-ing all conditions (same semantics as
|
|
24
|
+
* {@link matchesPredicate} for plain property rows).
|
|
25
|
+
*/
|
|
26
|
+
export function buildRangeConditionsAndExpression(
|
|
27
|
+
row: PredicateRangeBuildRow,
|
|
28
|
+
conditions: RangeCondition[],
|
|
29
|
+
) {
|
|
30
|
+
if (conditions.length === 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"buildRangeConditionsAndExpression: pass a non-empty conditions list",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const parts = conditions.map((c) => rangeConditionExpression(row, c));
|
|
36
|
+
if (parts.length === 1) {
|
|
37
|
+
return parts[0];
|
|
38
|
+
}
|
|
39
|
+
const [a, b, ...rest] = parts;
|
|
40
|
+
return rest.length === 0 ? and(a, b) : and(a, b, ...rest);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rangeConditionExpression(
|
|
44
|
+
row: PredicateRangeBuildRow,
|
|
45
|
+
c: RangeCondition,
|
|
46
|
+
) {
|
|
47
|
+
const col = row[c.column];
|
|
48
|
+
switch (c.op) {
|
|
49
|
+
case "eq":
|
|
50
|
+
return eq(col, c.value);
|
|
51
|
+
case "neq":
|
|
52
|
+
return not(eq(col, c.value));
|
|
53
|
+
case "gt":
|
|
54
|
+
return gt(col, c.value);
|
|
55
|
+
case "gte":
|
|
56
|
+
return gte(col, c.value);
|
|
57
|
+
case "lt":
|
|
58
|
+
return lt(col, c.value);
|
|
59
|
+
case "lte":
|
|
60
|
+
return lte(col, c.value);
|
|
61
|
+
case "between": {
|
|
62
|
+
if (c.valueTo === undefined) {
|
|
63
|
+
throw new Error(`between requires valueTo for column ${c.column}`);
|
|
64
|
+
}
|
|
65
|
+
return and(gte(col, c.value), lte(col, c.valueTo));
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
exhaustiveGuard(c.op);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Collection, UtilsRecord } from "@tanstack/db";
|
|
2
|
+
import type { CacheManager } from "../cache-manager";
|
|
3
|
+
import type {
|
|
4
|
+
PartialSyncClientBridge,
|
|
5
|
+
PartialSyncReconcileResult,
|
|
6
|
+
PartialSyncState,
|
|
7
|
+
} from "../partial-sync-client-bridge";
|
|
8
|
+
import type { PartialSyncViewportAdapter } from "./partial-sync-adapter";
|
|
9
|
+
import type { ConnectPartialSyncTransport } from "../connect-partial-sync";
|
|
10
|
+
import type { SyncClientBridge } from "../sync-client-bridge";
|
|
11
|
+
import type { PartialSyncRowShape } from "../partial-sync-row-key";
|
|
12
|
+
import type { RangeCondition, SyncClientMessage } from "../sync-protocol";
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
PartialSyncRowRef,
|
|
16
|
+
PartialSyncRowShape,
|
|
17
|
+
PartialSyncRowVersion,
|
|
18
|
+
} from "../partial-sync-row-key";
|
|
19
|
+
|
|
20
|
+
/** Row shape for partial-sync React hooks (mandatory `updatedAt` for fingerprints / reconciliation). */
|
|
21
|
+
export type PartialSyncItem = PartialSyncRowShape;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Minimal collection surface for partial-sync window hooks (TanStack `Collection` satisfies this
|
|
25
|
+
* when `get` / `entries` are typed consistently).
|
|
26
|
+
*/
|
|
27
|
+
export type PartialSyncCollection<TItem extends PartialSyncItem> = {
|
|
28
|
+
get(key: string | number): TItem | undefined;
|
|
29
|
+
subscribeChanges(
|
|
30
|
+
callback: (changes: unknown[]) => void,
|
|
31
|
+
options?: { includeInitialState?: boolean },
|
|
32
|
+
): { unsubscribe: () => void };
|
|
33
|
+
entries(): IterableIterator<[string | number, TItem]>;
|
|
34
|
+
/** TanStack collections use `UtilsRecord`; runtime must include `receiveSync` / `truncate`. */
|
|
35
|
+
utils: UtilsRecord;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ViewportInfo = {
|
|
39
|
+
firstVisibleIndex: number;
|
|
40
|
+
lastVisibleIndex: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Per global row index: where the cell’s data comes from (for UI / debugging). */
|
|
44
|
+
export type PartialSyncRowSlot =
|
|
45
|
+
| "ready"
|
|
46
|
+
| "ready_global"
|
|
47
|
+
| "stale_map"
|
|
48
|
+
| "server"
|
|
49
|
+
| "none";
|
|
50
|
+
|
|
51
|
+
export type PartialSyncRowSlotView<TItem extends PartialSyncItem> = {
|
|
52
|
+
row: TItem | undefined;
|
|
53
|
+
slot: PartialSyncRowSlot;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type UsePartialSyncWindowOptions<
|
|
57
|
+
TItem extends PartialSyncItem,
|
|
58
|
+
TSortColumn extends keyof TItem & string = keyof TItem & string,
|
|
59
|
+
> = {
|
|
60
|
+
collection: PartialSyncCollection<TItem>;
|
|
61
|
+
sort: { column: TSortColumn; direction: "asc" | "desc" };
|
|
62
|
+
getSortValue: (row: TItem, column: TSortColumn) => unknown;
|
|
63
|
+
|
|
64
|
+
wsUrl: string;
|
|
65
|
+
wsTransport?: ConnectPartialSyncTransport;
|
|
66
|
+
/** Use a module-level function or `useCallback`; inline arrows are a new reference every render. */
|
|
67
|
+
serializeJson?: (value: unknown) => string;
|
|
68
|
+
/** Use a module-level function or `useCallback`; inline arrows are a new reference every render. */
|
|
69
|
+
deserializeJson?: (raw: string) => unknown;
|
|
70
|
+
|
|
71
|
+
getVersionMs?: (row: TItem) => number;
|
|
72
|
+
getSortPositions?: (row: TItem) => Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
pageLimit?: number;
|
|
75
|
+
seekCooldownMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Stable id for the **logical** collection instance (e.g. `${backend}-${roomId}`). The partial
|
|
78
|
+
* window resets (truncate + index map) when this or {@link sort} changes — **not** when the
|
|
79
|
+
* TanStack `collection` reference churns. Omit only if the collection instance is stable for the
|
|
80
|
+
* hook lifetime; otherwise local edits can spuriously reset the window.
|
|
81
|
+
*/
|
|
82
|
+
partialWindowResetKey?: string;
|
|
83
|
+
/**
|
|
84
|
+
* When set, `mutateBatch` / ack / `syncBatch` route through this bridge; `clientId` matches
|
|
85
|
+
* {@link PartialSyncClientBridge}. Use with {@link createPartialSyncedCollection} / {@link withSync}.
|
|
86
|
+
*/
|
|
87
|
+
mutationBridge?: SyncClientBridge<TItem>;
|
|
88
|
+
/**
|
|
89
|
+
* Called once when the WebSocket transport `send` is ready (same function {@link withSync} / mutation bridge use).
|
|
90
|
+
*/
|
|
91
|
+
mergeTransportSend?: (send: (msg: SyncClientMessage) => void) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Must match the server's partial-sync {@link PartialSyncServerBridgeOptions.collectionId} and align with
|
|
94
|
+
* {@link SyncClientBridgeOptions.collectionId} on {@link mutationBridge}.
|
|
95
|
+
*/
|
|
96
|
+
collectionId?: string;
|
|
97
|
+
/**
|
|
98
|
+
* `confirmed` filters dense `rows` to {@link PartialSyncClientBridge.serverConfirmedKeys} only
|
|
99
|
+
* (same semantics as {@link UsePartialSyncViewportOptions.cacheDisplayMode}).
|
|
100
|
+
*/
|
|
101
|
+
cacheDisplayMode?: CacheDisplayMode;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type UsePartialSyncWindowResult<TItem extends PartialSyncItem> = {
|
|
105
|
+
rows: TItem[];
|
|
106
|
+
windowStartIndex: number;
|
|
107
|
+
totalCount: number;
|
|
108
|
+
/**
|
|
109
|
+
* True while a server `requestRangeQuery` is in flight (cursor append or offset seek).
|
|
110
|
+
* False for cache-only window moves (`tryIds` / fingerprint `upToDate` without a round-trip).
|
|
111
|
+
*/
|
|
112
|
+
rangeRequestInFlight: boolean;
|
|
113
|
+
hasMore: boolean;
|
|
114
|
+
bridgeState: PartialSyncState;
|
|
115
|
+
bridge: PartialSyncClientBridge<TItem>;
|
|
116
|
+
cacheManager: CacheManager<TItem>;
|
|
117
|
+
fetchNext: () => Promise<void>;
|
|
118
|
+
seekToViewport: (
|
|
119
|
+
firstVisibleIndex: number,
|
|
120
|
+
opts?: {
|
|
121
|
+
scrollSettled?: boolean;
|
|
122
|
+
lastVisibleIndex?: number;
|
|
123
|
+
/** Skip density/cooldown short-circuits (e.g. index map out of sync with collection). */
|
|
124
|
+
force?: boolean;
|
|
125
|
+
},
|
|
126
|
+
) => void;
|
|
127
|
+
seekAfterScrollSettled: (
|
|
128
|
+
firstVisibleIndex: number,
|
|
129
|
+
lastVisibleIndex?: number,
|
|
130
|
+
) => void;
|
|
131
|
+
viewportInfo: ViewportInfo;
|
|
132
|
+
setViewportInfo: (info: ViewportInfo) => void;
|
|
133
|
+
lastSeekMeta: {
|
|
134
|
+
offset: number;
|
|
135
|
+
reason: "scroll" | "scrollSettled";
|
|
136
|
+
} | null;
|
|
137
|
+
/**
|
|
138
|
+
* Resolve one global index via index map + collection, and classify the cell.
|
|
139
|
+
* Use this for row UI so cached rows still render when the dense `rows` window has not caught up.
|
|
140
|
+
*/
|
|
141
|
+
getRowSlot: (globalIndex: number) => PartialSyncRowSlotView<TItem>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Collection usable with {@link useLiveQuery} and partial-sync helpers. */
|
|
145
|
+
export type PartialSyncLiveCollection<TItem extends PartialSyncItem> =
|
|
146
|
+
PartialSyncCollection<TItem> &
|
|
147
|
+
Collection<TItem, string | number, UtilsRecord>;
|
|
148
|
+
|
|
149
|
+
export type CacheDisplayMode = "immediate" | "confirmed";
|
|
150
|
+
|
|
151
|
+
export type UsePredicateFilteredRowsOptions<
|
|
152
|
+
TItem extends PartialSyncItem,
|
|
153
|
+
TSortColumn extends keyof TItem & string = keyof TItem & string,
|
|
154
|
+
> = {
|
|
155
|
+
collection: PartialSyncLiveCollection<TItem>;
|
|
156
|
+
conditions: RangeCondition[];
|
|
157
|
+
sort: { column: TSortColumn; direction: "asc" | "desc" };
|
|
158
|
+
getSortValue: (row: TItem, column: TSortColumn) => unknown;
|
|
159
|
+
/**
|
|
160
|
+
* For predicate `column` strings when using a custom row shape; the live-query path reads
|
|
161
|
+
* properties named in {@link RangeCondition.column} on each row (same as default column read).
|
|
162
|
+
*/
|
|
163
|
+
getColumnValue?: (row: TItem, column: string) => unknown;
|
|
164
|
+
limit?: number;
|
|
165
|
+
/**
|
|
166
|
+
* `immediate`: show all rows matching the predicate (including cache-only).
|
|
167
|
+
* `confirmed`: only rows whose keys appear in {@link confirmedRowKeys} (from the bridge).
|
|
168
|
+
*/
|
|
169
|
+
cacheDisplayMode?: CacheDisplayMode;
|
|
170
|
+
/** Required when `cacheDisplayMode` is `"confirmed"`; typically `bridge.serverConfirmedKeys`. */
|
|
171
|
+
confirmedRowKeys?: ReadonlySet<string | number>;
|
|
172
|
+
/** Pass `bridge.serverConfirmedKeysRevision` so React re-runs the query when the set mutates. */
|
|
173
|
+
confirmedKeysRevision?: number;
|
|
174
|
+
/**
|
|
175
|
+
* Row ids that stay visible even when they no longer match the viewport predicate (union with
|
|
176
|
+
* predicate hits). Useful for in-progress drags or pinned selections. Rows must already exist
|
|
177
|
+
* in the local collection.
|
|
178
|
+
*/
|
|
179
|
+
alwaysIncludeRowIds?: readonly string[];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export type UsePartialSyncCollectionOptions<TItem extends PartialSyncItem> = {
|
|
183
|
+
collection: PartialSyncCollection<TItem>;
|
|
184
|
+
mutationBridge: SyncClientBridge<TItem>;
|
|
185
|
+
wsUrl: string;
|
|
186
|
+
wsTransport?: ConnectPartialSyncTransport;
|
|
187
|
+
serializeJson?: (value: unknown) => string;
|
|
188
|
+
deserializeJson?: (raw: string) => unknown;
|
|
189
|
+
mergeTransportSend?: (send: (msg: SyncClientMessage) => void) => void;
|
|
190
|
+
collectionId?: string;
|
|
191
|
+
beforeApplyRows?: (rows: TItem[]) => Promise<void>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type UsePartialSyncCollectionResult<TItem extends PartialSyncItem> = {
|
|
195
|
+
bridge: PartialSyncClientBridge<TItem>;
|
|
196
|
+
bridgeState: PartialSyncState;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export type UsePartialSyncViewportOptions<
|
|
200
|
+
TItem extends PartialSyncItem,
|
|
201
|
+
TViewport,
|
|
202
|
+
TSortColumn extends keyof TItem & string,
|
|
203
|
+
> = {
|
|
204
|
+
bridge: PartialSyncClientBridge<TItem>;
|
|
205
|
+
/** From {@link usePartialSyncCollection}; drives {@link UsePartialSyncViewportResult.totalCount}. */
|
|
206
|
+
bridgeState: PartialSyncState;
|
|
207
|
+
collection: PartialSyncLiveCollection<TItem>;
|
|
208
|
+
adapter: PartialSyncViewportAdapter<TItem, TViewport, TSortColumn>;
|
|
209
|
+
viewport: TViewport;
|
|
210
|
+
predicateLimit: number;
|
|
211
|
+
prefetchPad?: number;
|
|
212
|
+
quietMs?: number;
|
|
213
|
+
maxWaitMs?: number;
|
|
214
|
+
/**
|
|
215
|
+
* Used when the bridge has not yet reported `totalCount` (e.g. world size for sparse grids).
|
|
216
|
+
*/
|
|
217
|
+
totalCountFallback?: number;
|
|
218
|
+
getColumnValue?: (row: TItem, column: string) => unknown;
|
|
219
|
+
cacheDisplayMode?: CacheDisplayMode;
|
|
220
|
+
/**
|
|
221
|
+
* Ids to always include in {@link UsePartialSyncViewportResult.viewportRows} in addition to
|
|
222
|
+
* rows matching the viewport predicate (see {@link UsePredicateFilteredRowsOptions.alwaysIncludeRowIds}).
|
|
223
|
+
*/
|
|
224
|
+
alwaysIncludeRowIds?: readonly string[];
|
|
225
|
+
/** After a successful manifest reconcile (re-entry with cached keys), receive `movedHints` etc. */
|
|
226
|
+
onRangeReconcile?: (result: PartialSyncReconcileResult<TItem>) => void;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export type UsePartialSyncViewportResult<TItem extends PartialSyncItem> = {
|
|
230
|
+
viewportRows: TItem[];
|
|
231
|
+
totalCount: number;
|
|
232
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { connectPartialSync } from "../connect-partial-sync";
|
|
3
|
+
import { PartialSyncClientBridge } from "../partial-sync-client-bridge";
|
|
4
|
+
import type { PartialSyncState } from "../partial-sync-client-bridge";
|
|
5
|
+
import type { SyncClientMessage } from "../sync-protocol";
|
|
6
|
+
import {
|
|
7
|
+
assertSyncUtils,
|
|
8
|
+
getPartialSyncRowByMapId,
|
|
9
|
+
primePartialSyncBridgeCachedIdsFromCollection,
|
|
10
|
+
} from "./partial-sync-utils";
|
|
11
|
+
import type {
|
|
12
|
+
PartialSyncItem,
|
|
13
|
+
UsePartialSyncCollectionOptions,
|
|
14
|
+
UsePartialSyncCollectionResult,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* WebSocket + {@link PartialSyncClientBridge} wiring shared by predicate viewports and custom UIs.
|
|
19
|
+
* Pair with {@link usePartialSyncViewport} for N-dimensional `rangeQuery` + local predicate rows.
|
|
20
|
+
*/
|
|
21
|
+
export function usePartialSyncCollection<TItem extends PartialSyncItem>({
|
|
22
|
+
collection,
|
|
23
|
+
mutationBridge,
|
|
24
|
+
wsUrl,
|
|
25
|
+
wsTransport = "json",
|
|
26
|
+
serializeJson = JSON.stringify,
|
|
27
|
+
deserializeJson = JSON.parse,
|
|
28
|
+
mergeTransportSend,
|
|
29
|
+
collectionId,
|
|
30
|
+
beforeApplyRows,
|
|
31
|
+
}: UsePartialSyncCollectionOptions<TItem>): UsePartialSyncCollectionResult<TItem> {
|
|
32
|
+
const [bridgeState, setBridgeState] = useState<PartialSyncState>({
|
|
33
|
+
status: "offline",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const syncUtils = useMemo(
|
|
37
|
+
() => assertSyncUtils<TItem>(collection.utils),
|
|
38
|
+
[collection],
|
|
39
|
+
);
|
|
40
|
+
const syncUtilsRef = useRef(syncUtils);
|
|
41
|
+
syncUtilsRef.current = syncUtils;
|
|
42
|
+
|
|
43
|
+
const partialClientId = mutationBridge.clientId;
|
|
44
|
+
|
|
45
|
+
const collectionRef = useRef(collection);
|
|
46
|
+
collectionRef.current = collection;
|
|
47
|
+
|
|
48
|
+
const bridge = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
new PartialSyncClientBridge<TItem>({
|
|
51
|
+
clientId: partialClientId,
|
|
52
|
+
...(collectionId !== undefined ? { collectionId } : {}),
|
|
53
|
+
collection: {
|
|
54
|
+
get: (key) => getPartialSyncRowByMapId(collectionRef.current, key),
|
|
55
|
+
utils: {
|
|
56
|
+
receiveSync: (messages) =>
|
|
57
|
+
syncUtilsRef.current.receiveSync(messages),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
send: () => {},
|
|
61
|
+
onStateChange: (state) => {
|
|
62
|
+
setBridgeState(state);
|
|
63
|
+
},
|
|
64
|
+
...(beforeApplyRows !== undefined ? { beforeApplyRows } : {}),
|
|
65
|
+
}),
|
|
66
|
+
[beforeApplyRows, collectionId, partialClientId],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const serializeJsonRef = useRef(serializeJson);
|
|
70
|
+
serializeJsonRef.current = serializeJson;
|
|
71
|
+
const deserializeJsonRef = useRef(deserializeJson);
|
|
72
|
+
deserializeJsonRef.current = deserializeJson;
|
|
73
|
+
const mutationBridgeRef = useRef(mutationBridge);
|
|
74
|
+
mutationBridgeRef.current = mutationBridge;
|
|
75
|
+
const mergeTransportSendRef = useRef(mergeTransportSend);
|
|
76
|
+
mergeTransportSendRef.current = mergeTransportSend;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keep the object returned by `subscribeChanges` and call `.unsubscribe()` on it. Destructuring
|
|
80
|
+
* `{ unsubscribe }` drops the method receiver, so TanStack's `unsubscribe()` runs with `this === undefined`
|
|
81
|
+
* and throws (e.g. reading `truncateCleanup`).
|
|
82
|
+
*
|
|
83
|
+
* Declare this layout effect before the WebSocket one so teardown runs `unsubscribe` before
|
|
84
|
+
* `disconnect` (layout cleanups run in declaration order in practice).
|
|
85
|
+
*/
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
primePartialSyncBridgeCachedIdsFromCollection(bridge, collection);
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
let seeded = false;
|
|
90
|
+
const trySeed = () => {
|
|
91
|
+
if (cancelled || seeded) return;
|
|
92
|
+
const rows = Array.from(collection.entries(), ([, v]) => v);
|
|
93
|
+
if (rows.length === 0) return;
|
|
94
|
+
bridge.seedHydratedLocalRows(rows);
|
|
95
|
+
seeded = true;
|
|
96
|
+
};
|
|
97
|
+
const changeSubscription = collection.subscribeChanges(trySeed, {
|
|
98
|
+
includeInitialState: true,
|
|
99
|
+
});
|
|
100
|
+
queueMicrotask(trySeed);
|
|
101
|
+
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
102
|
+
intervalId = setInterval(() => {
|
|
103
|
+
trySeed();
|
|
104
|
+
if (seeded && intervalId !== undefined) {
|
|
105
|
+
clearInterval(intervalId);
|
|
106
|
+
intervalId = undefined;
|
|
107
|
+
}
|
|
108
|
+
}, 50);
|
|
109
|
+
const stopId = setTimeout(() => {
|
|
110
|
+
if (intervalId !== undefined) {
|
|
111
|
+
clearInterval(intervalId);
|
|
112
|
+
}
|
|
113
|
+
}, 10_000);
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
changeSubscription.unsubscribe();
|
|
117
|
+
if (intervalId !== undefined) clearInterval(intervalId);
|
|
118
|
+
clearTimeout(stopId);
|
|
119
|
+
};
|
|
120
|
+
}, [bridge, collection]);
|
|
121
|
+
|
|
122
|
+
useLayoutEffect(() => {
|
|
123
|
+
const disconnect = connectPartialSync(bridge, {
|
|
124
|
+
url: wsUrl,
|
|
125
|
+
transport: wsTransport,
|
|
126
|
+
setTransportSend: (send) => {
|
|
127
|
+
bridge.setSend((message: SyncClientMessage) => send(message));
|
|
128
|
+
mergeTransportSendRef.current?.(send);
|
|
129
|
+
},
|
|
130
|
+
serializeJson: (value: unknown) => serializeJsonRef.current(value),
|
|
131
|
+
deserializeJson: (raw: string) => deserializeJsonRef.current(raw),
|
|
132
|
+
mutationBridge: mutationBridgeRef.current,
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
disconnect();
|
|
136
|
+
};
|
|
137
|
+
}, [bridge, wsTransport, wsUrl]);
|
|
138
|
+
|
|
139
|
+
return { bridge, bridgeState };
|
|
140
|
+
}
|