@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
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@firtoz/collection-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WebSocket sync protocol and bridges for TanStack DB collections",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"module": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"require": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./react": {
|
|
16
|
+
"types": "./src/react/index.ts",
|
|
17
|
+
"import": "./src/react/index.ts",
|
|
18
|
+
"require": "./src/react/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"./*": {
|
|
21
|
+
"types": "./src/*.ts",
|
|
22
|
+
"import": "./src/*.ts",
|
|
23
|
+
"require": "./src/*.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/**/*.ts",
|
|
28
|
+
"!src/**/*.test.ts",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsgo --noEmit -p ./tsconfig.json",
|
|
33
|
+
"lint": "biome check --write src",
|
|
34
|
+
"lint:ci": "biome ci src",
|
|
35
|
+
"format": "biome format src --write",
|
|
36
|
+
"test": "bun test src"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"typescript",
|
|
40
|
+
"websocket",
|
|
41
|
+
"sync",
|
|
42
|
+
"tanstack-db"
|
|
43
|
+
],
|
|
44
|
+
"author": "Firtina Ozbalikchi <firtoz@github.com>",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"homepage": "https://github.com/firtoz/fullstack-toolkit#readme",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/firtoz/fullstack-toolkit.git",
|
|
50
|
+
"directory": "packages/collection-sync"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/firtoz/fullstack-toolkit/issues"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18.0.0"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@firtoz/db-helpers": "^2.1.0",
|
|
63
|
+
"@firtoz/maybe-error": "^1.5.2",
|
|
64
|
+
"zod": "^4.3.6"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@firtoz/websocket-do": "^7.1.0",
|
|
68
|
+
"@standard-schema/spec": ">=1.1.0",
|
|
69
|
+
"@tanstack/db": ">=0.6.1",
|
|
70
|
+
"@tanstack/react-db": ">=0.1.79",
|
|
71
|
+
"react": ">=19.2.4"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@firtoz/websocket-do": "^7.1.0",
|
|
75
|
+
"@standard-schema/spec": "^1.1.0",
|
|
76
|
+
"@tanstack/db": "^0.6.1",
|
|
77
|
+
"@tanstack/react-db": "0.1.79",
|
|
78
|
+
"@types/react": "^19.2.14",
|
|
79
|
+
"react": "^19.2.4",
|
|
80
|
+
"typescript": "^6.0.2"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export interface CacheStorageEstimate {
|
|
2
|
+
usageBytes: number;
|
|
3
|
+
quotaBytes: number;
|
|
4
|
+
utilizationRatio: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type CacheEntry = {
|
|
8
|
+
key: string | number;
|
|
9
|
+
lastAccessedAt: number;
|
|
10
|
+
fetchedAt: number;
|
|
11
|
+
sortPositions: Map<string, unknown>;
|
|
12
|
+
estimatedSizeBytes: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
import type { PartialSyncRowRef } from "./partial-sync-row-key";
|
|
16
|
+
import { partialSyncRowKey } from "./partial-sync-row-key";
|
|
17
|
+
|
|
18
|
+
export interface CacheManagerOptions<TItem extends PartialSyncRowRef> {
|
|
19
|
+
evictionThreshold?: number;
|
|
20
|
+
evictionTarget?: number;
|
|
21
|
+
estimateRowSize?: (row: TItem) => number;
|
|
22
|
+
getStorageEstimate: () => Promise<CacheStorageEstimate>;
|
|
23
|
+
deleteRows: (keys: Array<string | number>) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CacheViewport = {
|
|
27
|
+
sortColumn: string;
|
|
28
|
+
sortDirection: "asc" | "desc";
|
|
29
|
+
fromValue: unknown;
|
|
30
|
+
toValue: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class CacheManager<TItem extends PartialSyncRowRef> {
|
|
34
|
+
#entries = new Map<string | number, CacheEntry>();
|
|
35
|
+
readonly evictionThreshold: number;
|
|
36
|
+
readonly evictionTarget: number;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly options: CacheManagerOptions<TItem>) {
|
|
39
|
+
this.evictionThreshold = options.evictionThreshold ?? 0.85;
|
|
40
|
+
this.evictionTarget = options.evictionTarget ?? 0.7;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get entryCount(): number {
|
|
44
|
+
return this.#entries.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recompute `sortPositions` for rows already tracked (e.g. after a local edit changes sort keys).
|
|
49
|
+
* Does not add new entries; skips keys missing from `getRow`.
|
|
50
|
+
*/
|
|
51
|
+
resyncSortPositionsForTrackedRows(
|
|
52
|
+
getRow: (key: string | number) => TItem | undefined,
|
|
53
|
+
getSortPositions: (row: TItem) => Record<string, unknown>,
|
|
54
|
+
): void {
|
|
55
|
+
for (const key of this.#entries.keys()) {
|
|
56
|
+
const row = getRow(key);
|
|
57
|
+
if (row === undefined) continue;
|
|
58
|
+
const entry = this.#entries.get(key);
|
|
59
|
+
if (entry === undefined) continue;
|
|
60
|
+
const sortPositionsObject = getSortPositions(row);
|
|
61
|
+
entry.sortPositions = new Map<string, unknown>(
|
|
62
|
+
Object.entries(sortPositionsObject),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
recordFetchedRows(
|
|
68
|
+
rows: TItem[],
|
|
69
|
+
getSortPositions: (row: TItem) => Record<string, unknown>,
|
|
70
|
+
): void {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const rowKey = partialSyncRowKey(row.id);
|
|
74
|
+
const existing = this.#entries.get(rowKey);
|
|
75
|
+
const sortPositionsObject = getSortPositions(row);
|
|
76
|
+
const sortPositions = new Map<string, unknown>(
|
|
77
|
+
Object.entries(sortPositionsObject),
|
|
78
|
+
);
|
|
79
|
+
const estimatedSizeBytes = this.options.estimateRowSize?.(row) ?? 256;
|
|
80
|
+
this.#entries.set(rowKey, {
|
|
81
|
+
key: rowKey,
|
|
82
|
+
lastAccessedAt: existing?.lastAccessedAt ?? now,
|
|
83
|
+
fetchedAt: now,
|
|
84
|
+
sortPositions,
|
|
85
|
+
estimatedSizeBytes,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
markAccessed(keys: Array<string | number>): void {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const entry = this.#entries.get(key);
|
|
94
|
+
if (!entry) continue;
|
|
95
|
+
entry.lastAccessedAt = now;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
removeEntries(keys: Array<string | number>): void {
|
|
100
|
+
for (const key of keys) this.#entries.delete(key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.#entries.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async estimateStoragePressure(): Promise<CacheStorageEstimate> {
|
|
108
|
+
const estimate = await this.options.getStorageEstimate();
|
|
109
|
+
return {
|
|
110
|
+
usageBytes: Math.max(0, estimate.usageBytes),
|
|
111
|
+
quotaBytes: Math.max(1, estimate.quotaBytes),
|
|
112
|
+
utilizationRatio: estimate.usageBytes / Math.max(1, estimate.quotaBytes),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async evictIfNeeded(viewport: CacheViewport): Promise<{
|
|
117
|
+
evictedKeys: Array<string | number>;
|
|
118
|
+
estimate: CacheStorageEstimate;
|
|
119
|
+
}> {
|
|
120
|
+
const estimate = await this.estimateStoragePressure();
|
|
121
|
+
if (estimate.utilizationRatio < this.evictionThreshold) {
|
|
122
|
+
return { evictedKeys: [], estimate };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const candidates = Array.from(this.#entries.values())
|
|
126
|
+
.filter((entry) => !this.#isEntryProtectedByViewport(entry, viewport))
|
|
127
|
+
.sort(
|
|
128
|
+
(a, b) => this.#scoreEntry(b, viewport) - this.#scoreEntry(a, viewport),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const evictedKeys: Array<string | number> = [];
|
|
132
|
+
let currentEstimate = estimate;
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
if (currentEstimate.utilizationRatio <= this.evictionTarget) break;
|
|
135
|
+
evictedKeys.push(candidate.key);
|
|
136
|
+
this.#entries.delete(candidate.key);
|
|
137
|
+
currentEstimate = {
|
|
138
|
+
...currentEstimate,
|
|
139
|
+
usageBytes: Math.max(
|
|
140
|
+
0,
|
|
141
|
+
currentEstimate.usageBytes - candidate.estimatedSizeBytes,
|
|
142
|
+
),
|
|
143
|
+
utilizationRatio:
|
|
144
|
+
Math.max(
|
|
145
|
+
0,
|
|
146
|
+
currentEstimate.usageBytes - candidate.estimatedSizeBytes,
|
|
147
|
+
) / currentEstimate.quotaBytes,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (evictedKeys.length > 0) {
|
|
152
|
+
await this.options.deleteRows(evictedKeys);
|
|
153
|
+
}
|
|
154
|
+
return { evictedKeys, estimate: currentEstimate };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#scoreEntry(entry: CacheEntry, viewport: CacheViewport): number {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const timeSinceAccessMs = Math.max(0, now - entry.lastAccessedAt);
|
|
160
|
+
const distance = this.#distanceFromViewport(entry, viewport);
|
|
161
|
+
// Weighted score: heavily prefer evicting far-away rows that have not been used recently.
|
|
162
|
+
return timeSinceAccessMs * 0.001 + distance * 1000;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#distanceFromViewport(entry: CacheEntry, viewport: CacheViewport): number {
|
|
166
|
+
const value = entry.sortPositions.get(viewport.sortColumn);
|
|
167
|
+
if (value === undefined || value === null) return 1;
|
|
168
|
+
// `fromValue` / `toValue` come from first/last *visible* rows. After a local sort-key edit,
|
|
169
|
+
// the first row can temporarily sort *after* the last row, so from > to. Treat the band as
|
|
170
|
+
// [min, max] so rows between them stay protected; otherwise every entry looks "outside" and
|
|
171
|
+
// eviction can wipe the visible window (empty list, no throw).
|
|
172
|
+
let low = viewport.fromValue;
|
|
173
|
+
let high = viewport.toValue;
|
|
174
|
+
if (this.#compareValues(low, high) > 0) {
|
|
175
|
+
const t = low;
|
|
176
|
+
low = high;
|
|
177
|
+
high = t;
|
|
178
|
+
}
|
|
179
|
+
const lowerCompare = this.#compareValues(value, low);
|
|
180
|
+
const upperCompare = this.#compareValues(value, high);
|
|
181
|
+
if (lowerCompare >= 0 && upperCompare <= 0) return 0;
|
|
182
|
+
const lowerDistance = Math.abs(lowerCompare);
|
|
183
|
+
const upperDistance = Math.abs(upperCompare);
|
|
184
|
+
return Math.min(lowerDistance, upperDistance);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#isEntryProtectedByViewport(
|
|
188
|
+
entry: CacheEntry,
|
|
189
|
+
viewport: CacheViewport,
|
|
190
|
+
): boolean {
|
|
191
|
+
return this.#distanceFromViewport(entry, viewport) === 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#compareValues(left: unknown, right: unknown): number {
|
|
195
|
+
const normalizedLeft = left instanceof Date ? left.getTime() : left;
|
|
196
|
+
const normalizedRight = right instanceof Date ? right.getTime() : right;
|
|
197
|
+
if (
|
|
198
|
+
typeof normalizedLeft === "number" &&
|
|
199
|
+
typeof normalizedRight === "number"
|
|
200
|
+
) {
|
|
201
|
+
if (normalizedLeft === normalizedRight) return 0;
|
|
202
|
+
return normalizedLeft < normalizedRight ? -1 : 1;
|
|
203
|
+
}
|
|
204
|
+
const leftStr = String(normalizedLeft);
|
|
205
|
+
const rightStr = String(normalizedRight);
|
|
206
|
+
return leftStr.localeCompare(rightStr);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
2
|
+
import type { SyncMessage } from "@firtoz/db-helpers";
|
|
3
|
+
import { ZodWebSocketClient } from "@firtoz/websocket-do/zod-client";
|
|
4
|
+
import type { PartialSyncClientBridge } from "./partial-sync-client-bridge";
|
|
5
|
+
import type { SyncClientBridge } from "./sync-client-bridge";
|
|
6
|
+
import {
|
|
7
|
+
partialSyncRowKey,
|
|
8
|
+
type PartialSyncRowShape,
|
|
9
|
+
} from "./partial-sync-row-key";
|
|
10
|
+
import {
|
|
11
|
+
createClientMessageSchema,
|
|
12
|
+
createServerMessageSchema,
|
|
13
|
+
DEFAULT_SYNC_COLLECTION_ID,
|
|
14
|
+
type SyncClientMessage,
|
|
15
|
+
type SyncServerMessage,
|
|
16
|
+
} from "./sync-protocol";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional browser `document` for Page Visibility — accessed via `globalThis` so this module typechecks
|
|
20
|
+
* under configs that omit the DOM global (e.g. Worker-only `lib`).
|
|
21
|
+
*/
|
|
22
|
+
type PageVisibilityDocument = {
|
|
23
|
+
readonly visibilityState: string;
|
|
24
|
+
addEventListener(type: "visibilitychange", listener: () => void): void;
|
|
25
|
+
removeEventListener(type: "visibilitychange", listener: () => void): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function pageVisibilityDocument(): PageVisibilityDocument | undefined {
|
|
29
|
+
return (
|
|
30
|
+
globalThis as typeof globalThis & { document?: PageVisibilityDocument }
|
|
31
|
+
).document;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ConnectPartialSyncTransport = "json" | "msgpack";
|
|
35
|
+
|
|
36
|
+
export type ConnectPartialSyncOptions<
|
|
37
|
+
TItem extends PartialSyncRowShape = PartialSyncRowShape,
|
|
38
|
+
> = {
|
|
39
|
+
url: string;
|
|
40
|
+
transport?: ConnectPartialSyncTransport;
|
|
41
|
+
/** Prefer a module-level function or `useCallback`; a new inline function each render can churn effects. */
|
|
42
|
+
serializeJson?: (value: unknown) => string;
|
|
43
|
+
/** Prefer a module-level function or `useCallback`; a new inline function each render can churn effects. */
|
|
44
|
+
deserializeJson?: (raw: string) => unknown;
|
|
45
|
+
setTransportSend: (send: (msg: SyncClientMessage) => void) => void;
|
|
46
|
+
onServerMessage?: (msg: SyncServerMessage<TItem>) => void;
|
|
47
|
+
/**
|
|
48
|
+
* When set, inbound messages are split: range traffic → `bridge`, ack/reject/syncBackfill → mutation bridge;
|
|
49
|
+
* `syncBatch` applies via mutation bridge then updates partial cache ids (no double `receiveSync`).
|
|
50
|
+
*/
|
|
51
|
+
mutationBridge?: SyncClientBridge<TItem>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @internal Exported for unit tests; prefer {@link connectPartialSync} in apps.
|
|
56
|
+
* After a `rangePatch`, call `partialBridge.flushPendingCoalescedInboundUpdates()` unless you use
|
|
57
|
+
* `connectPartialSync` (the pump flushes coalesced updates after each inbound job).
|
|
58
|
+
*/
|
|
59
|
+
export async function dispatchPartialSyncServerMessage<
|
|
60
|
+
TItem extends PartialSyncRowShape,
|
|
61
|
+
>(
|
|
62
|
+
msg: SyncServerMessage<TItem>,
|
|
63
|
+
partialBridge: PartialSyncClientBridge<TItem>,
|
|
64
|
+
mutationBridge: SyncClientBridge<TItem> | undefined,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const mid = msg.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
67
|
+
const forPartial = mid === partialBridge.collectionId;
|
|
68
|
+
const forMutation =
|
|
69
|
+
mutationBridge !== undefined && mid === mutationBridge.collectionId;
|
|
70
|
+
|
|
71
|
+
if (mutationBridge === undefined) {
|
|
72
|
+
if (!forPartial) return;
|
|
73
|
+
await partialBridge.handleServerMessage(msg);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
switch (msg.type) {
|
|
78
|
+
case "queryRangeChunk":
|
|
79
|
+
case "rangeUpToDate":
|
|
80
|
+
case "rangeDelta":
|
|
81
|
+
case "rangeReconcileResult":
|
|
82
|
+
case "rangePatch":
|
|
83
|
+
case "pong":
|
|
84
|
+
if (!forPartial) return;
|
|
85
|
+
await partialBridge.handleServerMessage(msg);
|
|
86
|
+
return;
|
|
87
|
+
case "ack":
|
|
88
|
+
case "reject":
|
|
89
|
+
case "syncBackfill":
|
|
90
|
+
if (!forMutation) return;
|
|
91
|
+
await mutationBridge.handleServerMessage(msg);
|
|
92
|
+
return;
|
|
93
|
+
case "syncBatch": {
|
|
94
|
+
if (!forMutation) return;
|
|
95
|
+
await mutationBridge.handleServerMessage(msg);
|
|
96
|
+
if (forPartial) {
|
|
97
|
+
const changes = msg.changes as SyncMessage<TItem>[];
|
|
98
|
+
partialBridge.syncTrackedIdsFromMessages(changes);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
exhaustiveGuard(msg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type CoalescedRangePatchMessage<TItem extends PartialSyncRowShape> =
|
|
108
|
+
Extract<SyncServerMessage<TItem>, { type: "rangePatch" }>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Last-wins merge of buffered `rangePatch` messages (same row id keeps the latest patch).
|
|
112
|
+
* @internal Exported for unit tests.
|
|
113
|
+
*/
|
|
114
|
+
export function mergeCoalescedRangePatches<TItem extends PartialSyncRowShape>(
|
|
115
|
+
patches: CoalescedRangePatchMessage<TItem>[],
|
|
116
|
+
): CoalescedRangePatchMessage<TItem>[] {
|
|
117
|
+
let truncateWinner: CoalescedRangePatchMessage<TItem> | undefined;
|
|
118
|
+
const byRow = new Map<string | number, CoalescedRangePatchMessage<TItem>>();
|
|
119
|
+
for (const p of patches) {
|
|
120
|
+
const ch = p.change;
|
|
121
|
+
switch (ch.type) {
|
|
122
|
+
case "truncate":
|
|
123
|
+
truncateWinner = p;
|
|
124
|
+
byRow.clear();
|
|
125
|
+
break;
|
|
126
|
+
case "insert":
|
|
127
|
+
case "update": {
|
|
128
|
+
const k = partialSyncRowKey(ch.value.id);
|
|
129
|
+
byRow.set(k, p);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "delete":
|
|
133
|
+
byRow.set(ch.key, p);
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
exhaustiveGuard(ch);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (truncateWinner !== undefined) return [truncateWinner];
|
|
140
|
+
return [...byRow.values()];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function connectPartialSync<
|
|
144
|
+
TItem extends PartialSyncRowShape = PartialSyncRowShape,
|
|
145
|
+
>(
|
|
146
|
+
bridge: PartialSyncClientBridge<TItem>,
|
|
147
|
+
options: ConnectPartialSyncOptions<TItem>,
|
|
148
|
+
): () => void {
|
|
149
|
+
const clientSchema = createClientMessageSchema();
|
|
150
|
+
const serverSchema = createServerMessageSchema<TItem>();
|
|
151
|
+
const useMsgpack = options.transport === "msgpack";
|
|
152
|
+
const mutationBridge = options.mutationBridge;
|
|
153
|
+
/**
|
|
154
|
+
* Serialized inbound handling without chaining `.then` per message (that grows the promise graph
|
|
155
|
+
* linearly with traffic → accumulating latency, memory pressure, and eventual tab crashes).
|
|
156
|
+
*/
|
|
157
|
+
type InboundJob = () => Promise<void>;
|
|
158
|
+
const inboundWorkQueue: InboundJob[] = [];
|
|
159
|
+
let inboundPumpRunning = false;
|
|
160
|
+
let connectDisposed = false;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Single pump: drain all queued jobs, then **await** `flushPendingCoalescedInboundUpdates` so
|
|
164
|
+
* coalesced `rangePatch` rows are applied before the next drain pass (prevents duplicate-insert
|
|
165
|
+
* from `ack`/`syncBatch`). If new jobs arrived during the flush, loop.
|
|
166
|
+
*
|
|
167
|
+
* Only one pump runs at a time (`inboundPumpRunning`). Unlike the earlier version that left
|
|
168
|
+
* the flag set while awaiting a potentially-stalled flush, the flag is reset in a `finally`
|
|
169
|
+
* that runs **after** both the drain and the flush, and errors are caught per-job so one
|
|
170
|
+
* failure cannot block subsequent work.
|
|
171
|
+
*/
|
|
172
|
+
const runInboundPump = async (): Promise<void> => {
|
|
173
|
+
if (inboundPumpRunning) return;
|
|
174
|
+
inboundPumpRunning = true;
|
|
175
|
+
try {
|
|
176
|
+
do {
|
|
177
|
+
while (inboundWorkQueue.length > 0) {
|
|
178
|
+
const job = inboundWorkQueue.shift();
|
|
179
|
+
if (job === undefined) continue;
|
|
180
|
+
try {
|
|
181
|
+
await job();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error("[connectPartialSync] inbound job error", err);
|
|
184
|
+
}
|
|
185
|
+
if (connectDisposed) return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await bridge.flushPendingCoalescedInboundUpdates();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error("[connectPartialSync] coalesced flush error", err);
|
|
191
|
+
}
|
|
192
|
+
} while (!connectDisposed && inboundWorkQueue.length > 0);
|
|
193
|
+
} finally {
|
|
194
|
+
inboundPumpRunning = false;
|
|
195
|
+
if (!connectDisposed && inboundWorkQueue.length > 0) {
|
|
196
|
+
void runInboundPump();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const enqueueInbound = (job: InboundJob): void => {
|
|
202
|
+
inboundWorkQueue.push(job);
|
|
203
|
+
void runInboundPump();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let rangePatchCoalesceBuffer: CoalescedRangePatchMessage<TItem>[] = [];
|
|
207
|
+
/**
|
|
208
|
+
* `requestAnimationFrame` can run between WebSocket deliveries, flushing a single `rangePatch`
|
|
209
|
+
* while more patches are still queued on the inbound chain — causing duplicate `receiveSync`
|
|
210
|
+
* and stepped replay. A deduped microtask flush runs after the current sync work so bursts
|
|
211
|
+
* in the same turn coalesce before paint.
|
|
212
|
+
*/
|
|
213
|
+
let rangePatchFlushMicrotaskQueued = false;
|
|
214
|
+
|
|
215
|
+
const drainRangePatchCoalesceBuffer = async (): Promise<void> => {
|
|
216
|
+
if (rangePatchCoalesceBuffer.length === 0) return;
|
|
217
|
+
const merged = mergeCoalescedRangePatches(rangePatchCoalesceBuffer);
|
|
218
|
+
rangePatchCoalesceBuffer = [];
|
|
219
|
+
for (const m of merged) {
|
|
220
|
+
await dispatchPartialSyncServerMessage(m, bridge, mutationBridge);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const scheduleCoalescedRangePatchFlushMicrotask = (): void => {
|
|
225
|
+
if (rangePatchFlushMicrotaskQueued) return;
|
|
226
|
+
rangePatchFlushMicrotaskQueued = true;
|
|
227
|
+
queueMicrotask(() => {
|
|
228
|
+
rangePatchFlushMicrotaskQueued = false;
|
|
229
|
+
enqueueInbound(async () => {
|
|
230
|
+
await drainRangePatchCoalesceBuffer();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const cancelCoalescedRangePatchDeferredFlush = (): void => {
|
|
236
|
+
rangePatchFlushMicrotaskQueued = false;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const flushCoalescedRangePatchesInline = async (): Promise<void> => {
|
|
240
|
+
cancelCoalescedRangePatchDeferredFlush();
|
|
241
|
+
await drainRangePatchCoalesceBuffer();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const zodClient = new ZodWebSocketClient({
|
|
245
|
+
url: options.url,
|
|
246
|
+
clientSchema,
|
|
247
|
+
serverSchema,
|
|
248
|
+
enableBufferMessages: useMsgpack,
|
|
249
|
+
...(useMsgpack
|
|
250
|
+
? {}
|
|
251
|
+
: {
|
|
252
|
+
serializeJson: options.serializeJson ?? JSON.stringify,
|
|
253
|
+
deserializeJson: options.deserializeJson ?? JSON.parse,
|
|
254
|
+
}),
|
|
255
|
+
onMessage: (msg) => {
|
|
256
|
+
enqueueInbound(async () => {
|
|
257
|
+
options.onServerMessage?.(msg);
|
|
258
|
+
if (mutationBridge === undefined) {
|
|
259
|
+
await dispatchPartialSyncServerMessage(msg, bridge, mutationBridge);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const mid = msg.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
263
|
+
const forPartial = mid === bridge.collectionId;
|
|
264
|
+
if (msg.type === "rangePatch" && forPartial) {
|
|
265
|
+
rangePatchCoalesceBuffer.push(msg);
|
|
266
|
+
scheduleCoalescedRangePatchFlushMicrotask();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await flushCoalescedRangePatchesInline();
|
|
270
|
+
await dispatchPartialSyncServerMessage(msg, bridge, mutationBridge);
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/** DO not call `WebSocket.send` until `OPEN`; queue outbound messages until then. */
|
|
276
|
+
const pendingOutbound: SyncClientMessage[] = [];
|
|
277
|
+
|
|
278
|
+
const flushPendingOutbound = () => {
|
|
279
|
+
while (
|
|
280
|
+
pendingOutbound.length > 0 &&
|
|
281
|
+
zodClient.socket.readyState === WebSocket.OPEN
|
|
282
|
+
) {
|
|
283
|
+
const message = pendingOutbound.shift();
|
|
284
|
+
if (message !== undefined) {
|
|
285
|
+
zodClient.send(message);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
options.setTransportSend((message) => {
|
|
291
|
+
if (zodClient.socket.readyState === WebSocket.OPEN) {
|
|
292
|
+
zodClient.send(message);
|
|
293
|
+
} else {
|
|
294
|
+
pendingOutbound.push(message);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
bridge.setConnecting();
|
|
299
|
+
|
|
300
|
+
const onOpen = () => {
|
|
301
|
+
flushPendingOutbound();
|
|
302
|
+
bridge.setConnected(true);
|
|
303
|
+
mutationBridge?.setConnected(true);
|
|
304
|
+
};
|
|
305
|
+
const onClose = () => {
|
|
306
|
+
pendingOutbound.length = 0;
|
|
307
|
+
bridge.setConnected(false);
|
|
308
|
+
mutationBridge?.setConnected(false);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
zodClient.socket.addEventListener("open", onOpen);
|
|
312
|
+
zodClient.socket.addEventListener("close", onClose);
|
|
313
|
+
|
|
314
|
+
if (zodClient.socket.readyState === WebSocket.OPEN) {
|
|
315
|
+
onOpen();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const onVisibilityFlush = (): void => {
|
|
319
|
+
const doc = pageVisibilityDocument();
|
|
320
|
+
if (doc === undefined) return;
|
|
321
|
+
if (doc.visibilityState !== "visible") return;
|
|
322
|
+
enqueueInbound(async () => {
|
|
323
|
+
await flushCoalescedRangePatchesInline();
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const visibilityDoc = pageVisibilityDocument();
|
|
328
|
+
if (visibilityDoc !== undefined) {
|
|
329
|
+
visibilityDoc.addEventListener("visibilitychange", onVisibilityFlush);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return () => {
|
|
333
|
+
connectDisposed = true;
|
|
334
|
+
const docCleanup = pageVisibilityDocument();
|
|
335
|
+
if (docCleanup !== undefined) {
|
|
336
|
+
docCleanup.removeEventListener("visibilitychange", onVisibilityFlush);
|
|
337
|
+
}
|
|
338
|
+
cancelCoalescedRangePatchDeferredFlush();
|
|
339
|
+
inboundWorkQueue.length = 0;
|
|
340
|
+
rangePatchCoalesceBuffer = [];
|
|
341
|
+
zodClient.socket.removeEventListener("open", onOpen);
|
|
342
|
+
zodClient.socket.removeEventListener("close", onClose);
|
|
343
|
+
pendingOutbound.length = 0;
|
|
344
|
+
bridge.setConnected(false);
|
|
345
|
+
mutationBridge?.setConnected(false);
|
|
346
|
+
options.setTransportSend(() => {});
|
|
347
|
+
zodClient.close();
|
|
348
|
+
};
|
|
349
|
+
}
|