@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,169 @@
|
|
|
1
|
+
import { inArray } from "@tanstack/db";
|
|
2
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { DEFAULT_PAGE_LIMIT } from "./constants";
|
|
5
|
+
import {
|
|
6
|
+
buildRangeConditionsAndExpression,
|
|
7
|
+
type PredicateRowRef,
|
|
8
|
+
} from "./range-conditions-expression";
|
|
9
|
+
import type { PartialSyncItem, UsePredicateFilteredRowsOptions } from "./types";
|
|
10
|
+
|
|
11
|
+
const PIN_IDS_SEP = "\u0000";
|
|
12
|
+
|
|
13
|
+
function compareByPartialSort<
|
|
14
|
+
TItem extends PartialSyncItem,
|
|
15
|
+
TSortColumn extends keyof TItem & string,
|
|
16
|
+
>(
|
|
17
|
+
a: TItem,
|
|
18
|
+
b: TItem,
|
|
19
|
+
sort: { column: TSortColumn; direction: "asc" | "desc" },
|
|
20
|
+
getSortValue: (row: TItem, column: TSortColumn) => unknown,
|
|
21
|
+
): number {
|
|
22
|
+
const va = getSortValue(a, sort.column);
|
|
23
|
+
const vb = getSortValue(b, sort.column);
|
|
24
|
+
let primary = 0;
|
|
25
|
+
if (typeof va === "number" && typeof vb === "number") {
|
|
26
|
+
if (va < vb) primary = -1;
|
|
27
|
+
else if (va > vb) primary = 1;
|
|
28
|
+
} else {
|
|
29
|
+
const sa = String(va);
|
|
30
|
+
const sb = String(vb);
|
|
31
|
+
if (sa < sb) primary = -1;
|
|
32
|
+
else if (sa > sb) primary = 1;
|
|
33
|
+
}
|
|
34
|
+
if (primary !== 0) {
|
|
35
|
+
return sort.direction === "desc" ? -primary : primary;
|
|
36
|
+
}
|
|
37
|
+
return String(a.id).localeCompare(String(b.id));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reactive predicate filter + sort + limit via TanStack DB `useLiveQuery` (IVM), instead of
|
|
42
|
+
* scanning the whole collection on each change.
|
|
43
|
+
*/
|
|
44
|
+
export function usePredicateFilteredRows<
|
|
45
|
+
TItem extends PartialSyncItem,
|
|
46
|
+
TSortColumn extends keyof TItem & string,
|
|
47
|
+
>({
|
|
48
|
+
collection,
|
|
49
|
+
conditions,
|
|
50
|
+
sort,
|
|
51
|
+
getSortValue,
|
|
52
|
+
getColumnValue: _getColumnValue,
|
|
53
|
+
limit = DEFAULT_PAGE_LIMIT,
|
|
54
|
+
cacheDisplayMode = "immediate",
|
|
55
|
+
confirmedRowKeys,
|
|
56
|
+
confirmedKeysRevision = 0,
|
|
57
|
+
alwaysIncludeRowIds,
|
|
58
|
+
}: UsePredicateFilteredRowsOptions<TItem, TSortColumn>): TItem[] {
|
|
59
|
+
void _getColumnValue;
|
|
60
|
+
|
|
61
|
+
const conditionsKey = useMemo(() => JSON.stringify(conditions), [conditions]);
|
|
62
|
+
|
|
63
|
+
const pinIdsSerialized =
|
|
64
|
+
alwaysIncludeRowIds === undefined || alwaysIncludeRowIds.length === 0
|
|
65
|
+
? ""
|
|
66
|
+
: [...new Set([...alwaysIncludeRowIds].map(String))]
|
|
67
|
+
.sort()
|
|
68
|
+
.join(PIN_IDS_SEP);
|
|
69
|
+
|
|
70
|
+
const { data } = useLiveQuery(
|
|
71
|
+
(q) => {
|
|
72
|
+
if (
|
|
73
|
+
cacheDisplayMode === "confirmed" &&
|
|
74
|
+
(confirmedRowKeys === undefined || confirmedRowKeys.size === 0)
|
|
75
|
+
) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let query = q.from({ items: collection });
|
|
80
|
+
|
|
81
|
+
if (conditions.length > 0) {
|
|
82
|
+
query = query.where((refs) =>
|
|
83
|
+
buildRangeConditionsAndExpression(
|
|
84
|
+
refs.items as PredicateRowRef,
|
|
85
|
+
conditions,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cacheDisplayMode === "confirmed" && confirmedRowKeys !== undefined) {
|
|
91
|
+
const keys = [...confirmedRowKeys];
|
|
92
|
+
query = query.where((refs) =>
|
|
93
|
+
inArray((refs.items as PredicateRowRef).id, keys),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
query = query.orderBy(
|
|
98
|
+
(refs) => (refs.items as PredicateRowRef)[sort.column],
|
|
99
|
+
sort.direction,
|
|
100
|
+
);
|
|
101
|
+
return query.limit(limit);
|
|
102
|
+
},
|
|
103
|
+
[
|
|
104
|
+
collection,
|
|
105
|
+
conditionsKey,
|
|
106
|
+
sort.column,
|
|
107
|
+
sort.direction,
|
|
108
|
+
limit,
|
|
109
|
+
cacheDisplayMode,
|
|
110
|
+
confirmedKeysRevision,
|
|
111
|
+
confirmedRowKeys,
|
|
112
|
+
],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const { data: pinnedData } = useLiveQuery(
|
|
116
|
+
(q) => {
|
|
117
|
+
if (pinIdsSerialized === "") return null;
|
|
118
|
+
if (
|
|
119
|
+
cacheDisplayMode === "confirmed" &&
|
|
120
|
+
(confirmedRowKeys === undefined || confirmedRowKeys.size === 0)
|
|
121
|
+
) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pinKeys = pinIdsSerialized.split(PIN_IDS_SEP);
|
|
126
|
+
|
|
127
|
+
let query = q.from({ items: collection });
|
|
128
|
+
|
|
129
|
+
if (cacheDisplayMode === "confirmed" && confirmedRowKeys !== undefined) {
|
|
130
|
+
const keys = [...confirmedRowKeys];
|
|
131
|
+
query = query.where((refs) =>
|
|
132
|
+
inArray((refs.items as PredicateRowRef).id, keys),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
query = query.where((refs) =>
|
|
137
|
+
inArray((refs.items as PredicateRowRef).id, pinKeys),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
query = query.orderBy(
|
|
141
|
+
(refs) => (refs.items as PredicateRowRef)[sort.column],
|
|
142
|
+
sort.direction,
|
|
143
|
+
);
|
|
144
|
+
return query.limit(pinKeys.length);
|
|
145
|
+
},
|
|
146
|
+
[
|
|
147
|
+
collection,
|
|
148
|
+
pinIdsSerialized,
|
|
149
|
+
sort.column,
|
|
150
|
+
sort.direction,
|
|
151
|
+
cacheDisplayMode,
|
|
152
|
+
confirmedKeysRevision,
|
|
153
|
+
confirmedRowKeys,
|
|
154
|
+
],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const predicateRows = (data ?? []) as TItem[];
|
|
158
|
+
const pinnedRows = (pinnedData ?? []) as TItem[];
|
|
159
|
+
|
|
160
|
+
return useMemo(() => {
|
|
161
|
+
if (pinIdsSerialized === "") return predicateRows;
|
|
162
|
+
const map = new Map<string, TItem>();
|
|
163
|
+
for (const r of predicateRows) map.set(String(r.id), r);
|
|
164
|
+
for (const r of pinnedRows) map.set(String(r.id), r);
|
|
165
|
+
const merged = [...map.values()];
|
|
166
|
+
merged.sort((a, b) => compareByPartialSort(a, b, sort, getSortValue));
|
|
167
|
+
return merged;
|
|
168
|
+
}, [predicateRows, pinnedRows, pinIdsSerialized, sort, getSortValue]);
|
|
169
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import type { SyncMessage } from "@firtoz/db-helpers";
|
|
2
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
3
|
+
import {
|
|
4
|
+
partialSyncRowKey,
|
|
5
|
+
partialSyncRowVersionWatermarkMs,
|
|
6
|
+
partialSyncRowVersionWatermarkMsUnknown,
|
|
7
|
+
type PartialSyncRowId,
|
|
8
|
+
type PartialSyncRowShape,
|
|
9
|
+
} from "./partial-sync-row-key";
|
|
10
|
+
import {
|
|
11
|
+
createClientMutationId,
|
|
12
|
+
DEFAULT_SYNC_COLLECTION_ID,
|
|
13
|
+
type MutationIntent,
|
|
14
|
+
type SyncClientMessage,
|
|
15
|
+
type SyncClientMessageBody,
|
|
16
|
+
type SyncServerMessage,
|
|
17
|
+
} from "./sync-protocol";
|
|
18
|
+
|
|
19
|
+
type CollectionWithReceiveSync<TItem> = {
|
|
20
|
+
utils: {
|
|
21
|
+
receiveSync: (messages: SyncMessage<TItem>[]) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SendFn = (msg: SyncClientMessage) => void;
|
|
26
|
+
|
|
27
|
+
type PendingMutation = {
|
|
28
|
+
clientMutationId: string;
|
|
29
|
+
key: string | number;
|
|
30
|
+
intent: MutationIntent;
|
|
31
|
+
updatedAt: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface SyncClientBridgeOptions<TItem extends PartialSyncRowShape> {
|
|
35
|
+
clientId: string;
|
|
36
|
+
/** Must match the server's {@link SyncServerBridgeOptions.collectionId}. */
|
|
37
|
+
collectionId?: string;
|
|
38
|
+
collection: CollectionWithReceiveSync<TItem>;
|
|
39
|
+
send: SendFn;
|
|
40
|
+
initialLastAckedServerVersion?: number;
|
|
41
|
+
onLastAckedServerVersionChange?: (version: number) => void;
|
|
42
|
+
onRejectedMutation?: (reason: string, mutationId: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* When `false`, `setConnected(true)` does not send `syncHello` (partial-sync + `mutateBatch` only).
|
|
45
|
+
* Default `true` for full sync.
|
|
46
|
+
*/
|
|
47
|
+
sendSyncHelloOnConnect?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SyncClientBridge<TItem extends PartialSyncRowShape> {
|
|
51
|
+
readonly clientId: string;
|
|
52
|
+
readonly collectionId: string;
|
|
53
|
+
#pendingMutations = new Map<string, PendingMutation>();
|
|
54
|
+
#pendingMutationByKey = new Map<string | number, string>();
|
|
55
|
+
/** Truncate has no row key; track at most one pending truncate mutation. */
|
|
56
|
+
#pendingTruncateMutationId: string | null = null;
|
|
57
|
+
#lastAckedServerVersion = 0;
|
|
58
|
+
#connected = false;
|
|
59
|
+
#activeBackfill:
|
|
60
|
+
| {
|
|
61
|
+
mode: "snapshot" | "delta";
|
|
62
|
+
serverVersion: number;
|
|
63
|
+
totalChunks: number;
|
|
64
|
+
receivedChunks: number;
|
|
65
|
+
snapshotTruncateApplied: boolean;
|
|
66
|
+
}
|
|
67
|
+
| undefined;
|
|
68
|
+
readonly #sendSyncHelloOnConnect: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* When set (via {@link SyncClientBridge.setRowGet} after `createCollection`), server `insert`
|
|
71
|
+
* messages are turned into `update` if that row is already in the collection — required when
|
|
72
|
+
* partial `rangePatch` hydrated the row before an `ack`/`syncBatch` insert arrives.
|
|
73
|
+
*/
|
|
74
|
+
#rowGet: ((key: string | number) => TItem | undefined) | undefined;
|
|
75
|
+
|
|
76
|
+
constructor(private readonly options: SyncClientBridgeOptions<TItem>) {
|
|
77
|
+
this.clientId = options.clientId;
|
|
78
|
+
this.collectionId = options.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
79
|
+
this.#sendSyncHelloOnConnect = options.sendSyncHelloOnConnect ?? true;
|
|
80
|
+
this.#lastAckedServerVersion = Math.max(
|
|
81
|
+
0,
|
|
82
|
+
options.initialLastAckedServerVersion ?? 0,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get pendingCount(): number {
|
|
87
|
+
return this.#pendingMutations.size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setConnected(connected: boolean): void {
|
|
91
|
+
this.#connected = connected;
|
|
92
|
+
if (connected && this.#sendSyncHelloOnConnect) {
|
|
93
|
+
this.sendHello();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wire `collection.get` after the TanStack collection exists so mutation-path `receiveSync` can
|
|
99
|
+
* coerce duplicate `insert` echoes to `update` (partial sync + `mutateBatch`).
|
|
100
|
+
*/
|
|
101
|
+
setRowGet(
|
|
102
|
+
get: ((key: string | number) => TItem | undefined) | undefined,
|
|
103
|
+
): void {
|
|
104
|
+
this.#rowGet = get;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendHello(): void {
|
|
108
|
+
this.#out({
|
|
109
|
+
type: "syncHello",
|
|
110
|
+
clientId: this.clientId,
|
|
111
|
+
lastAckedServerVersion: this.#lastAckedServerVersion,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#out(msg: SyncClientMessageBody): void {
|
|
116
|
+
this.options.send({
|
|
117
|
+
...msg,
|
|
118
|
+
collectionId: this.collectionId,
|
|
119
|
+
} as SyncClientMessage);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onLocalMutation(changes: SyncMessage<TItem>[]): void {
|
|
123
|
+
const intents = this.#toIntents(changes);
|
|
124
|
+
for (const intent of intents) {
|
|
125
|
+
this.#rememberPendingIntent(intent);
|
|
126
|
+
}
|
|
127
|
+
if (this.#connected) {
|
|
128
|
+
this.#sendPendingIntents();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
sendInsert(value: TItem): string {
|
|
133
|
+
const mutationId = createClientMutationId("insert");
|
|
134
|
+
const intent: MutationIntent = {
|
|
135
|
+
clientMutationId: mutationId,
|
|
136
|
+
type: "insert",
|
|
137
|
+
value: value as Record<string, unknown>,
|
|
138
|
+
};
|
|
139
|
+
this.#rememberPendingIntent(intent);
|
|
140
|
+
if (this.#connected) this.#sendPendingIntents();
|
|
141
|
+
return mutationId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
sendUpdate(updated: TItem, previousValue: TItem): string {
|
|
145
|
+
const mutationId = createClientMutationId("update");
|
|
146
|
+
const intent: MutationIntent = {
|
|
147
|
+
clientMutationId: mutationId,
|
|
148
|
+
type: "update",
|
|
149
|
+
key: partialSyncRowKey(updated.id),
|
|
150
|
+
value: updated as Record<string, unknown>,
|
|
151
|
+
previousValue: previousValue as Record<string, unknown>,
|
|
152
|
+
};
|
|
153
|
+
this.#rememberPendingIntent(intent);
|
|
154
|
+
if (this.#connected) this.#sendPendingIntents();
|
|
155
|
+
return mutationId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sendDelete(key: string | number): string {
|
|
159
|
+
const mutationId = createClientMutationId("delete");
|
|
160
|
+
const intent: MutationIntent = {
|
|
161
|
+
clientMutationId: mutationId,
|
|
162
|
+
type: "delete",
|
|
163
|
+
key,
|
|
164
|
+
};
|
|
165
|
+
this.#rememberPendingIntent(intent);
|
|
166
|
+
if (this.#connected) this.#sendPendingIntents();
|
|
167
|
+
return mutationId;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async handleServerMessage(message: SyncServerMessage<TItem>): Promise<void> {
|
|
171
|
+
const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
172
|
+
if (mid !== this.collectionId) return;
|
|
173
|
+
switch (message.type) {
|
|
174
|
+
case "ack":
|
|
175
|
+
this.#updateLastAckedServerVersion(message.serverVersion);
|
|
176
|
+
await this.options.collection.utils.receiveSync(
|
|
177
|
+
this.#coerceInsertsWhenRowExists(
|
|
178
|
+
this.#filterIncomingChanges(
|
|
179
|
+
message.changes as SyncMessage<TItem>[],
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
for (const mutationId of message.clientMutationIds) {
|
|
184
|
+
this.#forgetPendingMutation(mutationId);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
case "syncBatch":
|
|
188
|
+
this.#updateLastAckedServerVersion(message.serverVersion);
|
|
189
|
+
await this.options.collection.utils.receiveSync(
|
|
190
|
+
this.#coerceInsertsWhenRowExists(
|
|
191
|
+
this.#filterIncomingChanges(
|
|
192
|
+
message.changes as SyncMessage<TItem>[],
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
case "syncBackfill": {
|
|
198
|
+
this.#updateLastAckedServerVersion(message.serverVersion);
|
|
199
|
+
const incomingChanges = this.#filterIncomingChanges(
|
|
200
|
+
message.changes as SyncMessage<TItem>[],
|
|
201
|
+
);
|
|
202
|
+
const totalChunks = message.totalChunks ?? 1;
|
|
203
|
+
const chunkIndex = message.chunkIndex ?? 0;
|
|
204
|
+
const isChunked = totalChunks > 1 || message.chunkIndex !== undefined;
|
|
205
|
+
if (
|
|
206
|
+
!this.#activeBackfill ||
|
|
207
|
+
chunkIndex === 0 ||
|
|
208
|
+
this.#activeBackfill.serverVersion !== message.serverVersion ||
|
|
209
|
+
this.#activeBackfill.mode !== message.mode
|
|
210
|
+
) {
|
|
211
|
+
this.#activeBackfill = {
|
|
212
|
+
mode: message.mode,
|
|
213
|
+
serverVersion: message.serverVersion,
|
|
214
|
+
totalChunks,
|
|
215
|
+
receivedChunks: 0,
|
|
216
|
+
snapshotTruncateApplied: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const active = this.#activeBackfill;
|
|
221
|
+
const outgoingChanges: SyncMessage<TItem>[] = [];
|
|
222
|
+
if (active.mode === "snapshot" && !active.snapshotTruncateApplied) {
|
|
223
|
+
outgoingChanges.push({ type: "truncate" });
|
|
224
|
+
active.snapshotTruncateApplied = true;
|
|
225
|
+
}
|
|
226
|
+
outgoingChanges.push(...incomingChanges);
|
|
227
|
+
|
|
228
|
+
if (outgoingChanges.length > 0) {
|
|
229
|
+
await this.options.collection.utils.receiveSync(
|
|
230
|
+
this.#coerceInsertsWhenRowExists(outgoingChanges),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
active.receivedChunks += 1;
|
|
235
|
+
const isFinalChunk = isChunked
|
|
236
|
+
? chunkIndex >= totalChunks - 1
|
|
237
|
+
: active.receivedChunks >= 1;
|
|
238
|
+
if (isFinalChunk) {
|
|
239
|
+
this.#activeBackfill = undefined;
|
|
240
|
+
this.#sendPendingIntents();
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
case "reject": {
|
|
245
|
+
this.#forgetPendingMutation(message.clientMutationId);
|
|
246
|
+
const allChanges = message.correctiveChanges as SyncMessage<TItem>[];
|
|
247
|
+
if (allChanges.length > 0) {
|
|
248
|
+
await this.options.collection.utils.receiveSync(
|
|
249
|
+
this.#coerceInsertsWhenRowExists(allChanges),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.options.onRejectedMutation?.(
|
|
254
|
+
message.reason,
|
|
255
|
+
message.clientMutationId,
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case "pong":
|
|
260
|
+
return;
|
|
261
|
+
case "queryRangeChunk":
|
|
262
|
+
case "rangePatch":
|
|
263
|
+
case "rangeUpToDate":
|
|
264
|
+
case "rangeDelta":
|
|
265
|
+
case "rangeReconcileResult":
|
|
266
|
+
// Not supported by the full-sync bridge; partial sync uses PartialSyncClientBridge.
|
|
267
|
+
return;
|
|
268
|
+
default:
|
|
269
|
+
exhaustiveGuard(message);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#toIntents(changes: SyncMessage<TItem>[]): MutationIntent[] {
|
|
274
|
+
const intents: MutationIntent[] = [];
|
|
275
|
+
for (const change of changes) {
|
|
276
|
+
const clientMutationId = createClientMutationId(change.type);
|
|
277
|
+
switch (change.type) {
|
|
278
|
+
case "insert":
|
|
279
|
+
intents.push({
|
|
280
|
+
clientMutationId,
|
|
281
|
+
type: "insert",
|
|
282
|
+
value: change.value as Record<string, unknown>,
|
|
283
|
+
});
|
|
284
|
+
break;
|
|
285
|
+
case "update":
|
|
286
|
+
intents.push({
|
|
287
|
+
clientMutationId,
|
|
288
|
+
type: "update",
|
|
289
|
+
key: partialSyncRowKey(change.value.id),
|
|
290
|
+
value: change.value as Record<string, unknown>,
|
|
291
|
+
previousValue: change.previousValue as Record<string, unknown>,
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
case "delete":
|
|
295
|
+
intents.push({
|
|
296
|
+
clientMutationId,
|
|
297
|
+
type: "delete",
|
|
298
|
+
key: change.key,
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
case "truncate":
|
|
302
|
+
intents.push({
|
|
303
|
+
clientMutationId,
|
|
304
|
+
type: "truncate",
|
|
305
|
+
});
|
|
306
|
+
break;
|
|
307
|
+
default:
|
|
308
|
+
exhaustiveGuard(change);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return intents;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#rememberPendingIntent(intent: MutationIntent): void {
|
|
315
|
+
const key = this.#intentKey(intent);
|
|
316
|
+
if (intent.type === "truncate") {
|
|
317
|
+
if (this.#pendingTruncateMutationId) {
|
|
318
|
+
this.#pendingMutations.delete(this.#pendingTruncateMutationId);
|
|
319
|
+
}
|
|
320
|
+
this.#pendingTruncateMutationId = intent.clientMutationId;
|
|
321
|
+
const pending: PendingMutation = {
|
|
322
|
+
clientMutationId: intent.clientMutationId,
|
|
323
|
+
key: "__truncate__",
|
|
324
|
+
intent,
|
|
325
|
+
updatedAt: 0,
|
|
326
|
+
};
|
|
327
|
+
this.#pendingMutations.set(intent.clientMutationId, pending);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (key === null) return;
|
|
331
|
+
const pending: PendingMutation = {
|
|
332
|
+
clientMutationId: intent.clientMutationId,
|
|
333
|
+
key,
|
|
334
|
+
intent,
|
|
335
|
+
updatedAt: this.#intentUpdatedAt(intent),
|
|
336
|
+
};
|
|
337
|
+
this.#pendingMutationByKey.set(key, intent.clientMutationId);
|
|
338
|
+
this.#pendingMutations.set(intent.clientMutationId, pending);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#forgetPendingMutation(mutationId: string): void {
|
|
342
|
+
const pending = this.#pendingMutations.get(mutationId);
|
|
343
|
+
if (!pending) return;
|
|
344
|
+
this.#pendingMutations.delete(mutationId);
|
|
345
|
+
if (pending.intent.type === "truncate") {
|
|
346
|
+
if (this.#pendingTruncateMutationId === mutationId) {
|
|
347
|
+
this.#pendingTruncateMutationId = null;
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (this.#pendingMutationByKey.get(pending.key) === mutationId) {
|
|
352
|
+
this.#pendingMutationByKey.delete(pending.key);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#sendPendingIntents(): void {
|
|
357
|
+
if (!this.#connected) return;
|
|
358
|
+
if (this.#pendingMutations.size === 0) return;
|
|
359
|
+
const intents = Array.from(this.#pendingMutations.values()).map(
|
|
360
|
+
(p) => p.intent,
|
|
361
|
+
);
|
|
362
|
+
this.#out({
|
|
363
|
+
type: "mutateBatch",
|
|
364
|
+
clientId: this.clientId,
|
|
365
|
+
mutations: intents,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#localRowForIncomingInsert(id: PartialSyncRowId): TItem | undefined {
|
|
370
|
+
const get = this.#rowGet;
|
|
371
|
+
if (get === undefined) return undefined;
|
|
372
|
+
if (typeof id === "string" || typeof id === "number") {
|
|
373
|
+
return get(id) ?? get(partialSyncRowKey(id));
|
|
374
|
+
}
|
|
375
|
+
return get(partialSyncRowKey(id));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#coerceInsertsWhenRowExists(
|
|
379
|
+
changes: SyncMessage<TItem>[],
|
|
380
|
+
): SyncMessage<TItem>[] {
|
|
381
|
+
if (this.#rowGet === undefined) return changes;
|
|
382
|
+
const out: SyncMessage<TItem>[] = [];
|
|
383
|
+
for (const ch of changes) {
|
|
384
|
+
if (ch.type !== "insert") {
|
|
385
|
+
out.push(ch);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const local = this.#localRowForIncomingInsert(ch.value.id);
|
|
389
|
+
if (local === undefined) {
|
|
390
|
+
out.push(ch);
|
|
391
|
+
} else {
|
|
392
|
+
out.push({
|
|
393
|
+
type: "update",
|
|
394
|
+
value: ch.value,
|
|
395
|
+
previousValue: local,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#filterIncomingChanges(changes: SyncMessage<TItem>[]): SyncMessage<TItem>[] {
|
|
403
|
+
const out: SyncMessage<TItem>[] = [];
|
|
404
|
+
for (const change of changes) {
|
|
405
|
+
if (change.type !== "update") {
|
|
406
|
+
out.push(change);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const pendingMutationId = this.#pendingMutationByKey.get(
|
|
410
|
+
partialSyncRowKey(change.value.id),
|
|
411
|
+
);
|
|
412
|
+
if (!pendingMutationId) {
|
|
413
|
+
out.push(change);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const pending = this.#pendingMutations.get(pendingMutationId);
|
|
417
|
+
if (!pending) {
|
|
418
|
+
out.push(change);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (partialSyncRowVersionWatermarkMs(change.value) >= pending.updatedAt) {
|
|
422
|
+
out.push(change);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#intentKey(intent: MutationIntent): string | number | null {
|
|
429
|
+
switch (intent.type) {
|
|
430
|
+
case "insert": {
|
|
431
|
+
const raw = (intent.value as { id?: PartialSyncRowId }).id;
|
|
432
|
+
return raw === undefined ? "" : partialSyncRowKey(raw);
|
|
433
|
+
}
|
|
434
|
+
case "update":
|
|
435
|
+
return intent.key ?? null;
|
|
436
|
+
case "delete":
|
|
437
|
+
return intent.key ?? null;
|
|
438
|
+
case "truncate":
|
|
439
|
+
return null;
|
|
440
|
+
default:
|
|
441
|
+
exhaustiveGuard(intent.type);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#intentUpdatedAt(intent: MutationIntent): number {
|
|
446
|
+
if (intent.type === "insert" || intent.type === "update") {
|
|
447
|
+
return partialSyncRowVersionWatermarkMsUnknown(intent.value);
|
|
448
|
+
}
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#updateLastAckedServerVersion(version: number): void {
|
|
453
|
+
const nextVersion = Math.max(this.#lastAckedServerVersion, version);
|
|
454
|
+
if (nextVersion === this.#lastAckedServerVersion) return;
|
|
455
|
+
this.#lastAckedServerVersion = nextVersion;
|
|
456
|
+
this.options.onLastAckedServerVersionChange?.(nextVersion);
|
|
457
|
+
}
|
|
458
|
+
}
|