@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.
@@ -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
+ }