@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/src/with-sync.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { SyncMessage } from "@firtoz/db-helpers";
|
|
2
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
import type {
|
|
4
|
+
CollectionConfig,
|
|
5
|
+
NonSingleResult,
|
|
6
|
+
UtilsRecord,
|
|
7
|
+
} from "@tanstack/db";
|
|
8
|
+
import { SyncClientBridge } from "./sync-client-bridge";
|
|
9
|
+
import type { PartialSyncRowShape } from "./partial-sync-row-key";
|
|
10
|
+
import type { SyncClientMessage } from "./sync-protocol";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sync cursor persistence (see {@link WithSyncOptions}):
|
|
14
|
+
*
|
|
15
|
+
* - **Ephemeral collections** (in-memory only, no durable row storage): omit `syncStateKey`.
|
|
16
|
+
* Each load gets a new `clientId` and `lastAckedServerVersion: 0`, so the server answers with a
|
|
17
|
+
* **full snapshot** backfill — correct because local rows do not survive refresh.
|
|
18
|
+
*
|
|
19
|
+
* - **Durable collections** (IndexedDB, sqlite-wasm, etc.): set `syncStateKey` (and default storage).
|
|
20
|
+
* `persistLastAckedServerVersion` defaults to **true**, so reconnect sends the last acked server
|
|
21
|
+
* version and the server can return **delta** backfill when the changelog allows.
|
|
22
|
+
*
|
|
23
|
+
* - To keep a stable `clientId` in storage but **always** request a full snapshot (rare), set
|
|
24
|
+
* `syncStateKey` and `persistLastAckedServerVersion: false`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Row shape required for sync (matches {@link SyncClientBridge}). */
|
|
28
|
+
export type SyncableCollectionItem = PartialSyncRowShape;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Key/value persistence for sync metadata (`clientId`, `lastAckedServerVersion`).
|
|
32
|
+
* Same shape as `Storage`; use `localStorage`, `sessionStorage`, a `Map` adapter, etc.
|
|
33
|
+
*/
|
|
34
|
+
export type SyncStateStorage = {
|
|
35
|
+
getItem(key: string): string | null;
|
|
36
|
+
setItem(key: string, value: string): void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type WithSyncOptions = {
|
|
40
|
+
/**
|
|
41
|
+
* If set, sync metadata (`clientId`, optionally `lastAckedServerVersion`) is read/written via
|
|
42
|
+
* {@link syncStateStorage} or `localStorage`. Omit for ephemeral collections (e.g. memory) so
|
|
43
|
+
* every load handshakes as a fresh client with a full snapshot — see module note above.
|
|
44
|
+
*/
|
|
45
|
+
syncStateKey?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Where to read/write {@link syncStateKey}. Defaults to `globalThis.localStorage` when `syncStateKey` is set and this is omitted.
|
|
48
|
+
* Pass `null` to skip persistence even when `syncStateKey` is set (e.g. tests).
|
|
49
|
+
*/
|
|
50
|
+
syncStateStorage?: SyncStateStorage | null;
|
|
51
|
+
/**
|
|
52
|
+
* Persist and reuse lastAcked server version across page reloads (enables delta backfill on reconnect).
|
|
53
|
+
* Defaults to `true` when {@link syncStateKey} is set and a storage backend is resolved; set `false` to always handshake with `lastAckedServerVersion: 0` (full snapshot). When `false`, existing stored `lastAckedServerVersion` is preserved in storage so it is not wiped on load.
|
|
54
|
+
*/
|
|
55
|
+
persistLastAckedServerVersion?: boolean;
|
|
56
|
+
onRejectedMutation?: (reason: string, mutationId: string) => void;
|
|
57
|
+
/**
|
|
58
|
+
* When `false`, the bridge does not send `syncHello` on connect (use with partial sync + `mutateBatch`).
|
|
59
|
+
* Default `true`.
|
|
60
|
+
*/
|
|
61
|
+
sendSyncHelloOnConnect?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* When `false`, local {@link CollectionConfig.utils.truncate} clears storage only — it does **not**
|
|
64
|
+
* enqueue a `truncate` for the next `mutateBatch`. Partial sync calls truncate on window reset; forwarding
|
|
65
|
+
* it would batch with unrelated user edits and make the server apply `truncate` + `update`, wiping data.
|
|
66
|
+
* Default `true` (full sync). {@link createPartialSyncedCollection} sets this to `false`.
|
|
67
|
+
*/
|
|
68
|
+
forwardTruncateToMutations?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Must match the server's {@link SyncServerBridgeOptions.collectionId} on the same WebSocket.
|
|
71
|
+
*/
|
|
72
|
+
collectionId?: string;
|
|
73
|
+
/**
|
|
74
|
+
* When set to a positive number (milliseconds), local mutation batches are debounced: each
|
|
75
|
+
* burst merges into one `onLocalMutation` call after this many milliseconds of quiet time.
|
|
76
|
+
* `truncate` messages flush any pending batch immediately and are not delayed.
|
|
77
|
+
*/
|
|
78
|
+
localMutationThrottleMs?: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Returns `globalThis.localStorage` when it looks usable; otherwise `null`. */
|
|
82
|
+
export function getBrowserLocalStorageSyncStateStorage(): SyncStateStorage | null {
|
|
83
|
+
const g = globalThis as typeof globalThis & {
|
|
84
|
+
localStorage?: SyncStateStorage;
|
|
85
|
+
};
|
|
86
|
+
const ls = g.localStorage;
|
|
87
|
+
if (!ls || typeof ls.getItem !== "function") return null;
|
|
88
|
+
return ls;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveSyncStateStorage(
|
|
92
|
+
syncOptions: WithSyncOptions | undefined,
|
|
93
|
+
): SyncStateStorage | null {
|
|
94
|
+
if (typeof syncOptions?.syncStateKey !== "string") return null;
|
|
95
|
+
if (syncOptions.syncStateStorage !== undefined) {
|
|
96
|
+
return syncOptions.syncStateStorage;
|
|
97
|
+
}
|
|
98
|
+
return getBrowserLocalStorageSyncStateStorage();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Infer the collection row type from options passed to {@link withSync} / {@link createSyncedCollection}.
|
|
103
|
+
* Prefer `CollectionConfig`'s first type parameter (the select row). That matches Drizzle SQLite configs
|
|
104
|
+
* where `schema` is an insert schema whose {@link InferSchemaOutput} can differ from the row type.
|
|
105
|
+
*/
|
|
106
|
+
export type InferItemFromCollectionOptions<T> = T extends Omit<
|
|
107
|
+
CollectionConfig<infer TItem, infer _K, infer _S, infer _U>,
|
|
108
|
+
"utils"
|
|
109
|
+
> & { utils: UtilsRecord }
|
|
110
|
+
? TItem
|
|
111
|
+
: T extends WithSyncableCollectionConfig<
|
|
112
|
+
infer TItem,
|
|
113
|
+
infer _K,
|
|
114
|
+
infer _S,
|
|
115
|
+
infer _U
|
|
116
|
+
>
|
|
117
|
+
? TItem
|
|
118
|
+
: T extends CollectionConfig<
|
|
119
|
+
infer TItem,
|
|
120
|
+
infer _TKey,
|
|
121
|
+
infer _TSchema,
|
|
122
|
+
infer _TUtils
|
|
123
|
+
>
|
|
124
|
+
? TItem
|
|
125
|
+
: T extends { getKey: (item: infer I) => unknown }
|
|
126
|
+
? I
|
|
127
|
+
: never;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Any TanStack {@link CollectionConfig} (from memory / IndexedDB / SQLite helpers, etc.)
|
|
131
|
+
* that is not a single-row collection. Requires `utils` (sync backends always provide it).
|
|
132
|
+
*/
|
|
133
|
+
export type WithSyncableCollectionConfig<
|
|
134
|
+
TItem extends SyncableCollectionItem = SyncableCollectionItem,
|
|
135
|
+
TKey extends string | number = string | number,
|
|
136
|
+
TSchema extends StandardSchemaV1 = never,
|
|
137
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
138
|
+
> = Omit<CollectionConfig<TItem, TKey, TSchema, TUtils>, "utils"> &
|
|
139
|
+
NonSingleResult & {
|
|
140
|
+
utils: TUtils;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
type PersistedSyncState = {
|
|
144
|
+
clientId: string;
|
|
145
|
+
lastAckedServerVersion: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function readPersistedSyncState(
|
|
149
|
+
key: string,
|
|
150
|
+
storage: SyncStateStorage | null,
|
|
151
|
+
): PersistedSyncState | null {
|
|
152
|
+
if (!storage) return null;
|
|
153
|
+
try {
|
|
154
|
+
const raw = storage.getItem(key);
|
|
155
|
+
if (!raw) return null;
|
|
156
|
+
const parsed = JSON.parse(raw) as Partial<PersistedSyncState>;
|
|
157
|
+
if (typeof parsed.clientId !== "string") return null;
|
|
158
|
+
return {
|
|
159
|
+
clientId: parsed.clientId,
|
|
160
|
+
lastAckedServerVersion:
|
|
161
|
+
typeof parsed.lastAckedServerVersion === "number"
|
|
162
|
+
? parsed.lastAckedServerVersion
|
|
163
|
+
: 0,
|
|
164
|
+
};
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writePersistedSyncState(
|
|
171
|
+
key: string,
|
|
172
|
+
storage: SyncStateStorage | null,
|
|
173
|
+
state: PersistedSyncState,
|
|
174
|
+
): void {
|
|
175
|
+
if (!storage) return;
|
|
176
|
+
try {
|
|
177
|
+
storage.setItem(key, JSON.stringify(state));
|
|
178
|
+
} catch {
|
|
179
|
+
// ignore quota / private mode
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Wraps TanStack DB collection options so local mutations are forwarded to {@link SyncClientBridge}.
|
|
185
|
+
* Pair with {@link connectSync} or {@link createSyncedCollection} to attach a WebSocket transport.
|
|
186
|
+
*
|
|
187
|
+
* Row type is inferred from your collection config via {@link InferItemFromCollectionOptions}.
|
|
188
|
+
*/
|
|
189
|
+
/** Widened config union from memory / Drizzle / IndexedDB helpers. */
|
|
190
|
+
export type AnyWithSyncableCollectionConfig = WithSyncableCollectionConfig<
|
|
191
|
+
// biome-ignore lint/suspicious/noExplicitAny: item type slot for heterogeneous backends
|
|
192
|
+
any,
|
|
193
|
+
// biome-ignore lint/suspicious/noExplicitAny: key type slot
|
|
194
|
+
any,
|
|
195
|
+
// biome-ignore lint/suspicious/noExplicitAny: schema type slot
|
|
196
|
+
any,
|
|
197
|
+
// biome-ignore lint/suspicious/noExplicitAny: utils type slot
|
|
198
|
+
any
|
|
199
|
+
>;
|
|
200
|
+
|
|
201
|
+
export function withSync<TConfig extends AnyWithSyncableCollectionConfig>(
|
|
202
|
+
baseOptions: TConfig,
|
|
203
|
+
syncOptions?: WithSyncOptions,
|
|
204
|
+
): {
|
|
205
|
+
options: TConfig;
|
|
206
|
+
bridge: SyncClientBridge<InferItemFromCollectionOptions<TConfig>>;
|
|
207
|
+
setTransportSend: (send: (msg: SyncClientMessage) => void) => void;
|
|
208
|
+
} {
|
|
209
|
+
type TItem = InferItemFromCollectionOptions<TConfig>;
|
|
210
|
+
|
|
211
|
+
const syncStateKey = syncOptions?.syncStateKey;
|
|
212
|
+
const syncStateStorage = resolveSyncStateStorage(syncOptions);
|
|
213
|
+
|
|
214
|
+
const persisted =
|
|
215
|
+
typeof syncStateKey === "string"
|
|
216
|
+
? readPersistedSyncState(syncStateKey, syncStateStorage)
|
|
217
|
+
: null;
|
|
218
|
+
const shouldPersistLastAckedServerVersion =
|
|
219
|
+
syncOptions?.persistLastAckedServerVersion ??
|
|
220
|
+
(typeof syncStateKey === "string" && syncStateStorage !== null);
|
|
221
|
+
const clientId = persisted?.clientId ?? `client-${crypto.randomUUID()}`;
|
|
222
|
+
const persistedLastAcked = persisted?.lastAckedServerVersion ?? 0;
|
|
223
|
+
let lastAckedServerVersion = shouldPersistLastAckedServerVersion
|
|
224
|
+
? persistedLastAcked
|
|
225
|
+
: 0;
|
|
226
|
+
if (typeof syncStateKey === "string") {
|
|
227
|
+
writePersistedSyncState(syncStateKey, syncStateStorage, {
|
|
228
|
+
clientId,
|
|
229
|
+
lastAckedServerVersion: shouldPersistLastAckedServerVersion
|
|
230
|
+
? lastAckedServerVersion
|
|
231
|
+
: persistedLastAcked,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let transportSend: (msg: SyncClientMessage) => void = () => {};
|
|
236
|
+
|
|
237
|
+
const forwardTruncateToMutations =
|
|
238
|
+
syncOptions?.forwardTruncateToMutations ?? true;
|
|
239
|
+
|
|
240
|
+
const originalReceiveSync = baseOptions.utils.receiveSync.bind(
|
|
241
|
+
baseOptions.utils,
|
|
242
|
+
);
|
|
243
|
+
const originalTruncate = baseOptions.utils.truncate.bind(baseOptions.utils);
|
|
244
|
+
|
|
245
|
+
const bridge = new SyncClientBridge<TItem>({
|
|
246
|
+
clientId,
|
|
247
|
+
...(syncOptions?.collectionId !== undefined
|
|
248
|
+
? { collectionId: syncOptions.collectionId }
|
|
249
|
+
: {}),
|
|
250
|
+
collection: {
|
|
251
|
+
utils: {
|
|
252
|
+
receiveSync: originalReceiveSync,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
send: (message) => transportSend(message),
|
|
256
|
+
initialLastAckedServerVersion: lastAckedServerVersion,
|
|
257
|
+
onLastAckedServerVersionChange: (version) => {
|
|
258
|
+
lastAckedServerVersion = Math.max(lastAckedServerVersion, version);
|
|
259
|
+
if (typeof syncStateKey === "string") {
|
|
260
|
+
writePersistedSyncState(syncStateKey, syncStateStorage, {
|
|
261
|
+
clientId,
|
|
262
|
+
lastAckedServerVersion,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
onRejectedMutation: syncOptions?.onRejectedMutation,
|
|
267
|
+
sendSyncHelloOnConnect: syncOptions?.sendSyncHelloOnConnect,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const throttleMs = syncOptions?.localMutationThrottleMs;
|
|
271
|
+
let mutationThrottleBuffer: SyncMessage<TItem>[] = [];
|
|
272
|
+
let mutationThrottleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
273
|
+
|
|
274
|
+
const flushMutationThrottle = (): void => {
|
|
275
|
+
mutationThrottleTimer = null;
|
|
276
|
+
if (mutationThrottleBuffer.length === 0) return;
|
|
277
|
+
const batch = mutationThrottleBuffer;
|
|
278
|
+
mutationThrottleBuffer = [];
|
|
279
|
+
bridge.onLocalMutation(batch);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const emitLocalMutation = (writes: SyncMessage<TItem>[]): void => {
|
|
283
|
+
if (
|
|
284
|
+
throttleMs === undefined ||
|
|
285
|
+
throttleMs <= 0 ||
|
|
286
|
+
writes.some((w) => w.type === "truncate")
|
|
287
|
+
) {
|
|
288
|
+
if (mutationThrottleTimer !== null) {
|
|
289
|
+
clearTimeout(mutationThrottleTimer);
|
|
290
|
+
mutationThrottleTimer = null;
|
|
291
|
+
flushMutationThrottle();
|
|
292
|
+
}
|
|
293
|
+
bridge.onLocalMutation(writes);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
mutationThrottleBuffer.push(...writes);
|
|
297
|
+
if (mutationThrottleTimer !== null) {
|
|
298
|
+
clearTimeout(mutationThrottleTimer);
|
|
299
|
+
}
|
|
300
|
+
mutationThrottleTimer = setTimeout(flushMutationThrottle, throttleMs);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const onInsert = baseOptions.onInsert
|
|
304
|
+
? async (params: Parameters<NonNullable<TConfig["onInsert"]>>[0]) => {
|
|
305
|
+
const writes: SyncMessage<TItem>[] = params.transaction.mutations.map(
|
|
306
|
+
(mutation) => ({
|
|
307
|
+
type: "insert" as const,
|
|
308
|
+
value: mutation.modified,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
emitLocalMutation(writes);
|
|
312
|
+
await baseOptions.onInsert?.(params);
|
|
313
|
+
}
|
|
314
|
+
: undefined;
|
|
315
|
+
|
|
316
|
+
const onUpdate = baseOptions.onUpdate
|
|
317
|
+
? async (params: Parameters<NonNullable<TConfig["onUpdate"]>>[0]) => {
|
|
318
|
+
const writes: SyncMessage<TItem>[] = params.transaction.mutations.map(
|
|
319
|
+
(mutation) => ({
|
|
320
|
+
type: "update" as const,
|
|
321
|
+
value: mutation.modified,
|
|
322
|
+
previousValue: mutation.original,
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
emitLocalMutation(writes);
|
|
326
|
+
await baseOptions.onUpdate?.(params);
|
|
327
|
+
}
|
|
328
|
+
: undefined;
|
|
329
|
+
|
|
330
|
+
const onDelete = baseOptions.onDelete
|
|
331
|
+
? async (params: Parameters<NonNullable<TConfig["onDelete"]>>[0]) => {
|
|
332
|
+
const writes: SyncMessage<TItem>[] = params.transaction.mutations.map(
|
|
333
|
+
(mutation) => ({
|
|
334
|
+
type: "delete" as const,
|
|
335
|
+
key: mutation.key as string | number,
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
emitLocalMutation(writes);
|
|
339
|
+
await baseOptions.onDelete?.(params);
|
|
340
|
+
}
|
|
341
|
+
: undefined;
|
|
342
|
+
|
|
343
|
+
const utils = {
|
|
344
|
+
...baseOptions.utils,
|
|
345
|
+
truncate: async () => {
|
|
346
|
+
await originalTruncate();
|
|
347
|
+
if (forwardTruncateToMutations) {
|
|
348
|
+
emitLocalMutation([{ type: "truncate" } as SyncMessage<TItem>]);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const options = {
|
|
354
|
+
...baseOptions,
|
|
355
|
+
...(onInsert !== undefined ? { onInsert } : {}),
|
|
356
|
+
...(onUpdate !== undefined ? { onUpdate } : {}),
|
|
357
|
+
...(onDelete !== undefined ? { onDelete } : {}),
|
|
358
|
+
utils,
|
|
359
|
+
} as unknown as TConfig;
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
options,
|
|
363
|
+
bridge,
|
|
364
|
+
setTransportSend: (send) => {
|
|
365
|
+
transportSend = send;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|