@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,362 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import { z } from "zod";
4
+
5
+ /** Default {@link SyncClientMessage} / {@link SyncServerMessage} `collectionId` when omitted on the wire. */
6
+ export const DEFAULT_SYNC_COLLECTION_ID = "default" as const;
7
+
8
+ const collectionIdSchema = z
9
+ .string()
10
+ .min(1)
11
+ .default(DEFAULT_SYNC_COLLECTION_ID);
12
+
13
+ const mutationTypeSchema = z.enum(["insert", "update", "delete", "truncate"]);
14
+
15
+ export const mutationIntentSchema = z.object({
16
+ clientMutationId: z.string().min(1),
17
+ type: mutationTypeSchema,
18
+ value: z.record(z.string(), z.unknown()).optional(),
19
+ previousValue: z.record(z.string(), z.unknown()).optional(),
20
+ key: z.union([z.string(), z.number()]).optional(),
21
+ });
22
+
23
+ export type MutationIntent = z.infer<typeof mutationIntentSchema>;
24
+
25
+ export type SyncSortDirection = "asc" | "desc";
26
+ export type SyncRangeSort = {
27
+ column: string;
28
+ direction: SyncSortDirection;
29
+ };
30
+
31
+ /** Fixed operator set for predicate-based range queries (e.g. spatial filters). */
32
+ export type RangeConditionOp =
33
+ | "gt"
34
+ | "gte"
35
+ | "lt"
36
+ | "lte"
37
+ | "eq"
38
+ | "neq"
39
+ | "between";
40
+
41
+ export type RangeCondition = {
42
+ column: string;
43
+ op: RangeConditionOp;
44
+ value: unknown;
45
+ /** Required when `op` is `"between"`. */
46
+ valueTo?: unknown;
47
+ };
48
+
49
+ export type IndexRangeCursor = {
50
+ kind: "index";
51
+ mode: "cursor";
52
+ sort: SyncRangeSort;
53
+ limit: number;
54
+ afterCursor: unknown | null;
55
+ };
56
+
57
+ export type IndexRangeOffset = {
58
+ kind: "index";
59
+ mode: "offset";
60
+ sort: SyncRangeSort;
61
+ limit: number;
62
+ offset: number;
63
+ };
64
+
65
+ export type PredicateRange = {
66
+ kind: "predicate";
67
+ conditions: RangeCondition[];
68
+ sort?: SyncRangeSort;
69
+ limit?: number;
70
+ };
71
+
72
+ export type SyncRange = IndexRangeCursor | IndexRangeOffset | PredicateRange;
73
+
74
+ /** Client watermark for reconciliation (max row version in range + count). */
75
+ export type RangeFingerprint = {
76
+ version: number;
77
+ count: number;
78
+ };
79
+
80
+ const syncRangeSortSchema = z.object({
81
+ column: z.string().min(1),
82
+ direction: z.enum(["asc", "desc"]),
83
+ });
84
+
85
+ const rangeConditionOpSchema = z.enum([
86
+ "gt",
87
+ "gte",
88
+ "lt",
89
+ "lte",
90
+ "eq",
91
+ "neq",
92
+ "between",
93
+ ]);
94
+
95
+ const rangeConditionSchema = z.object({
96
+ column: z.string().min(1),
97
+ op: rangeConditionOpSchema,
98
+ value: z.unknown(),
99
+ valueTo: z.unknown().optional(),
100
+ });
101
+
102
+ const indexRangeCursorSchema = z.object({
103
+ kind: z.literal("index"),
104
+ mode: z.literal("cursor"),
105
+ sort: syncRangeSortSchema,
106
+ limit: z.number().int().positive(),
107
+ afterCursor: z.unknown().nullable(),
108
+ });
109
+
110
+ const indexRangeOffsetSchema = z.object({
111
+ kind: z.literal("index"),
112
+ mode: z.literal("offset"),
113
+ sort: syncRangeSortSchema,
114
+ limit: z.number().int().positive(),
115
+ offset: z.number().int().nonnegative(),
116
+ });
117
+
118
+ const predicateRangeSchema = z.object({
119
+ kind: z.literal("predicate"),
120
+ conditions: z.array(rangeConditionSchema).min(1),
121
+ sort: syncRangeSortSchema.optional(),
122
+ limit: z.number().int().positive().optional(),
123
+ });
124
+
125
+ export const syncRangeSchema: z.ZodType<SyncRange> = z.union([
126
+ indexRangeCursorSchema,
127
+ indexRangeOffsetSchema,
128
+ predicateRangeSchema,
129
+ ]);
130
+
131
+ const rangeFingerprintSchema = z.object({
132
+ version: z.number().int().nonnegative(),
133
+ count: z.number().int().nonnegative(),
134
+ });
135
+
136
+ export function createClientMessageSchema() {
137
+ return z.discriminatedUnion("type", [
138
+ z.object({
139
+ type: z.literal("mutateBatch"),
140
+ collectionId: collectionIdSchema,
141
+ clientId: z.string().min(1),
142
+ mutations: z.array(mutationIntentSchema).min(1),
143
+ }),
144
+ z.object({
145
+ type: z.literal("syncHello"),
146
+ collectionId: collectionIdSchema,
147
+ clientId: z.string().min(1),
148
+ lastAckedServerVersion: z.number().int().nonnegative(),
149
+ }),
150
+ z.object({
151
+ type: z.literal("ping"),
152
+ collectionId: collectionIdSchema,
153
+ clientId: z.string().min(1),
154
+ timestamp: z.number(),
155
+ }),
156
+ z.object({
157
+ type: z.literal("queryRange"),
158
+ collectionId: collectionIdSchema,
159
+ clientId: z.string().min(1),
160
+ requestId: z.string().min(1),
161
+ sort: z.object({
162
+ column: z.string().min(1),
163
+ direction: z.enum(["asc", "desc"]),
164
+ }),
165
+ limit: z.number().int().positive(),
166
+ afterCursor: z.unknown().nullable(),
167
+ }),
168
+ z.object({
169
+ type: z.literal("queryByOffset"),
170
+ collectionId: collectionIdSchema,
171
+ clientId: z.string().min(1),
172
+ requestId: z.string().min(1),
173
+ sort: z.object({
174
+ column: z.string().min(1),
175
+ direction: z.enum(["asc", "desc"]),
176
+ }),
177
+ limit: z.number().int().positive(),
178
+ offset: z.number().int().nonnegative(),
179
+ }),
180
+ z.object({
181
+ type: z.literal("rangeQuery"),
182
+ collectionId: collectionIdSchema,
183
+ clientId: z.string().min(1),
184
+ requestId: z.string().min(1),
185
+ range: syncRangeSchema,
186
+ fingerprint: rangeFingerprintSchema.optional(),
187
+ }),
188
+ z.object({
189
+ type: z.literal("rangeReconcile"),
190
+ collectionId: collectionIdSchema,
191
+ clientId: z.string().min(1),
192
+ requestId: z.string().min(1),
193
+ range: syncRangeSchema,
194
+ manifest: z.array(
195
+ z.object({
196
+ id: z.union([z.string(), z.number()]),
197
+ version: z.number().int().nonnegative(),
198
+ }),
199
+ ),
200
+ }),
201
+ ]);
202
+ }
203
+
204
+ export type SyncClientMessage = z.infer<
205
+ ReturnType<typeof createClientMessageSchema>
206
+ >;
207
+
208
+ export const clientMessageSchema = createClientMessageSchema();
209
+
210
+ /** How to apply {@link SyncServerMessage} `syncBackfill` changes on the client. */
211
+ export type SyncBackfillMode = "snapshot" | "delta";
212
+
213
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
214
+ ? Omit<T, K>
215
+ : never;
216
+
217
+ /** Client payload before `collectionId` is attached (bridge outbox helpers). */
218
+ export type SyncClientMessageBody = DistributiveOmit<
219
+ SyncClientMessage,
220
+ "collectionId"
221
+ >;
222
+
223
+ export function createServerMessageSchema<
224
+ TItem = unknown,
225
+ TKey extends string | number = string | number,
226
+ >() {
227
+ const syncMessageSchema = z.custom<SyncMessage<TItem, TKey>>();
228
+ return z.discriminatedUnion("type", [
229
+ z.object({
230
+ type: z.literal("ack"),
231
+ collectionId: collectionIdSchema,
232
+ clientId: z.string().min(1),
233
+ clientMutationIds: z.array(z.string().min(1)),
234
+ serverVersion: z.number().int().nonnegative(),
235
+ changes: z.array(syncMessageSchema),
236
+ }),
237
+ z.object({
238
+ type: z.literal("syncBatch"),
239
+ collectionId: collectionIdSchema,
240
+ serverVersion: z.number().int().nonnegative(),
241
+ changes: z.array(syncMessageSchema),
242
+ }),
243
+ z.object({
244
+ type: z.literal("syncBackfill"),
245
+ collectionId: collectionIdSchema,
246
+ mode: z.enum(["snapshot", "delta"]),
247
+ serverVersion: z.number().int().nonnegative(),
248
+ changes: z.array(syncMessageSchema),
249
+ chunkIndex: z.number().int().nonnegative().optional(),
250
+ totalChunks: z.number().int().positive().optional(),
251
+ }),
252
+ z.object({
253
+ type: z.literal("reject"),
254
+ collectionId: collectionIdSchema,
255
+ clientId: z.string().min(1),
256
+ clientMutationId: z.string().min(1),
257
+ reason: z.string().min(1),
258
+ correctiveChanges: z.array(syncMessageSchema).default([]),
259
+ }),
260
+ z.object({
261
+ type: z.literal("pong"),
262
+ collectionId: collectionIdSchema,
263
+ timestamp: z.number(),
264
+ }),
265
+ z.object({
266
+ type: z.literal("queryRangeChunk"),
267
+ collectionId: collectionIdSchema,
268
+ requestId: z.string().min(1),
269
+ rows: z.array(z.custom<TItem>()),
270
+ totalCount: z.number().int().nonnegative(),
271
+ lastCursor: z.unknown().nullable(),
272
+ hasMore: z.boolean(),
273
+ chunkIndex: z.number().int().nonnegative(),
274
+ done: z.boolean(),
275
+ }),
276
+ z.object({
277
+ type: z.literal("rangePatch"),
278
+ collectionId: collectionIdSchema,
279
+ change: syncMessageSchema,
280
+ viewTransition: z.enum(["enterView", "exitView"]).optional(),
281
+ }),
282
+ z.object({
283
+ type: z.literal("rangeUpToDate"),
284
+ collectionId: collectionIdSchema,
285
+ requestId: z.string().min(1),
286
+ totalCount: z.number().int().nonnegative(),
287
+ }),
288
+ z.object({
289
+ type: z.literal("rangeDelta"),
290
+ collectionId: collectionIdSchema,
291
+ requestId: z.string().min(1),
292
+ totalCount: z.number().int().nonnegative(),
293
+ changes: z.array(syncMessageSchema),
294
+ lastCursor: z.unknown().optional(),
295
+ }),
296
+ z.object({
297
+ type: z.literal("rangeReconcileResult"),
298
+ collectionId: collectionIdSchema,
299
+ requestId: z.string().min(1),
300
+ added: z.array(syncMessageSchema),
301
+ updated: z.array(syncMessageSchema),
302
+ stale: z.array(z.union([z.string(), z.number()])),
303
+ movedHints: z.array(
304
+ z.object({
305
+ id: z.union([z.string(), z.number()]),
306
+ hint: z.record(z.string(), z.unknown()),
307
+ }),
308
+ ),
309
+ totalCount: z.number().int().nonnegative(),
310
+ }),
311
+ ]);
312
+ }
313
+
314
+ export type SyncServerMessage<
315
+ TItem = unknown,
316
+ TKey extends string | number = string | number,
317
+ > = z.infer<ReturnType<typeof createServerMessageSchema<TItem, TKey>>>;
318
+
319
+ /** Server payload before `collectionId` is attached (bridge outbox helpers). */
320
+ export type SyncServerMessageBody<
321
+ TItem = unknown,
322
+ TKey extends string | number = string | number,
323
+ > = DistributiveOmit<SyncServerMessage<TItem, TKey>, "collectionId">;
324
+
325
+ /** Attach `collectionId` to an outbound server message (single-collection servers). */
326
+ export function withServerCollectionId<TItem, TKey extends string | number>(
327
+ collectionId: string,
328
+ message: SyncServerMessage<TItem, TKey>,
329
+ ): SyncServerMessage<TItem, TKey> {
330
+ return { ...message, collectionId };
331
+ }
332
+
333
+ export const serverMessageSchema = createServerMessageSchema();
334
+
335
+ export function toSyncMessage(intent: MutationIntent): SyncMessage {
336
+ switch (intent.type) {
337
+ case "insert":
338
+ if (!intent.value) throw new Error("Insert intent requires value");
339
+ return { type: "insert", value: intent.value };
340
+ case "update":
341
+ if (!intent.value || !intent.previousValue) {
342
+ throw new Error("Update intent requires value and previousValue");
343
+ }
344
+ return {
345
+ type: "update",
346
+ value: intent.value,
347
+ previousValue: intent.previousValue,
348
+ };
349
+ case "delete":
350
+ if (intent.key === undefined)
351
+ throw new Error("Delete intent requires key");
352
+ return { type: "delete", key: intent.key };
353
+ case "truncate":
354
+ return { type: "truncate" };
355
+ default:
356
+ exhaustiveGuard(intent.type);
357
+ }
358
+ }
359
+
360
+ export function createClientMutationId(prefix = "m"): string {
361
+ return `${prefix}_${crypto.randomUUID()}`;
362
+ }
@@ -0,0 +1,267 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import type {
4
+ MutationIntent,
5
+ SyncBackfillMode,
6
+ SyncClientMessage,
7
+ SyncServerMessage,
8
+ SyncServerMessageBody,
9
+ } from "./sync-protocol";
10
+ import { DEFAULT_SYNC_COLLECTION_ID } from "./sync-protocol";
11
+ import { toSyncMessage } from "./sync-protocol";
12
+ import {
13
+ partialSyncRowKey,
14
+ type PartialSyncRowShape,
15
+ } from "./partial-sync-row-key";
16
+
17
+ export interface SyncServerBridgeStore<TItem> {
18
+ applySyncMessages: (messages: SyncMessage<TItem>[]) => Promise<void>;
19
+ getSnapshotMessages: () => Promise<SyncMessage<TItem>[]>;
20
+ getRow: (key: string | number) => Promise<TItem | undefined>;
21
+ }
22
+
23
+ export interface SyncServerBridgeOptions<TItem> {
24
+ store: SyncServerBridgeStore<TItem>;
25
+ sendToClient: (clientId: string, message: SyncServerMessage<TItem>) => void;
26
+ broadcastExcept: (
27
+ excludeClientId: string,
28
+ message: SyncServerMessage<TItem>,
29
+ ) => void | Promise<void>;
30
+ /** Deliver a server message to every connected client (e.g. {@link SyncServerBridge.pushServerChanges}). */
31
+ broadcastAll?: (message: SyncServerMessage<TItem>) => void;
32
+ /** Maximum number of changes per `syncBackfill` frame. Defaults to 500. */
33
+ backfillChunkSize?: number;
34
+ /** Multiplex key for sync messages. Default {@link DEFAULT_SYNC_COLLECTION_ID}. */
35
+ collectionId?: string;
36
+ }
37
+
38
+ export class SyncServerBridge<TItem extends PartialSyncRowShape> {
39
+ #serverVersion = 0;
40
+ #changeLog: Array<{ serverVersion: number; changes: SyncMessage<TItem>[] }> =
41
+ [];
42
+ readonly #cid: string;
43
+
44
+ constructor(private readonly options: SyncServerBridgeOptions<TItem>) {
45
+ this.#cid = options.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
46
+ }
47
+
48
+ get collectionId(): string {
49
+ return this.#cid;
50
+ }
51
+
52
+ #emit(clientId: string, body: SyncServerMessageBody<TItem>): void {
53
+ this.options.sendToClient(clientId, {
54
+ ...body,
55
+ collectionId: this.#cid,
56
+ } as SyncServerMessage<TItem>);
57
+ }
58
+
59
+ #broadcastExcept(
60
+ excludeClientId: string,
61
+ body: SyncServerMessageBody<TItem>,
62
+ ): void | Promise<void> {
63
+ return this.options.broadcastExcept(excludeClientId, {
64
+ ...body,
65
+ collectionId: this.#cid,
66
+ } as SyncServerMessage<TItem>);
67
+ }
68
+
69
+ #broadcastAll(body: SyncServerMessageBody<TItem>): void {
70
+ this.options.broadcastAll?.({
71
+ ...body,
72
+ collectionId: this.#cid,
73
+ } as SyncServerMessage<TItem>);
74
+ }
75
+
76
+ get serverVersion(): number {
77
+ return this.#serverVersion;
78
+ }
79
+
80
+ async handleClientMessage(message: SyncClientMessage): Promise<void> {
81
+ const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
82
+ if (mid !== this.#cid) return;
83
+ switch (message.type) {
84
+ case "ping":
85
+ this.#emit(message.clientId, {
86
+ type: "pong",
87
+ timestamp: message.timestamp,
88
+ });
89
+ return;
90
+ case "syncHello": {
91
+ const { mode, changes } = await this.#resolveBackfill(
92
+ message.lastAckedServerVersion,
93
+ );
94
+ const chunks = this.#chunkBackfillChanges(changes);
95
+ const totalChunks = chunks.length;
96
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
97
+ this.#emit(message.clientId, {
98
+ type: "syncBackfill",
99
+ mode,
100
+ serverVersion: this.#serverVersion,
101
+ changes: chunks[chunkIndex],
102
+ chunkIndex,
103
+ totalChunks,
104
+ });
105
+ }
106
+ return;
107
+ }
108
+ case "mutateBatch":
109
+ await this.#handleMutateBatch(message);
110
+ return;
111
+ case "queryRange":
112
+ case "queryByOffset":
113
+ case "rangeQuery":
114
+ case "rangeReconcile":
115
+ // Not supported by the full-sync bridge; partial sync uses PartialSyncServerBridge.
116
+ return;
117
+ default:
118
+ exhaustiveGuard(message);
119
+ }
120
+ }
121
+
122
+ async #handleMutateBatch(
123
+ message: Extract<SyncClientMessage, { type: "mutateBatch" }>,
124
+ ): Promise<void> {
125
+ const resolvedChanges: SyncMessage<TItem>[] = [];
126
+ const acceptedMutationIds: string[] = [];
127
+
128
+ for (const mutation of message.mutations) {
129
+ try {
130
+ const change = await this.#intentToMessageLww(mutation);
131
+ if (!change) {
132
+ continue;
133
+ }
134
+ resolvedChanges.push(change);
135
+ acceptedMutationIds.push(mutation.clientMutationId);
136
+ } catch (error) {
137
+ this.#emit(message.clientId, {
138
+ type: "reject",
139
+ clientId: message.clientId,
140
+ clientMutationId: mutation.clientMutationId,
141
+ reason: error instanceof Error ? error.message : String(error),
142
+ correctiveChanges: [],
143
+ });
144
+ }
145
+ }
146
+
147
+ if (resolvedChanges.length === 0) return;
148
+
149
+ await this.options.store.applySyncMessages(resolvedChanges);
150
+ this.#serverVersion += 1;
151
+ this.#changeLog.push({
152
+ serverVersion: this.#serverVersion,
153
+ changes: resolvedChanges,
154
+ });
155
+
156
+ this.#emit(message.clientId, {
157
+ type: "ack",
158
+ clientId: message.clientId,
159
+ clientMutationIds: acceptedMutationIds,
160
+ serverVersion: this.#serverVersion,
161
+ changes: resolvedChanges,
162
+ });
163
+
164
+ await Promise.resolve(
165
+ this.#broadcastExcept(message.clientId, {
166
+ type: "syncBatch",
167
+ serverVersion: this.#serverVersion,
168
+ changes: resolvedChanges,
169
+ }),
170
+ );
171
+ }
172
+
173
+ async #intentToMessageLww(
174
+ intent: MutationIntent,
175
+ ): Promise<SyncMessage<TItem> | null> {
176
+ const base = toSyncMessage(intent) as SyncMessage<TItem>;
177
+ if (base.type !== "update") {
178
+ return base;
179
+ }
180
+ const key = partialSyncRowKey(intent.key ?? base.value.id);
181
+ const existing = await this.options.store.getRow(key);
182
+ if (!existing) {
183
+ return base;
184
+ }
185
+ const incomingUpdatedAt = this.#toMillis(base.value.updatedAt);
186
+ const existingUpdatedAt = this.#toMillis(existing.updatedAt);
187
+ if (incomingUpdatedAt <= existingUpdatedAt) {
188
+ return {
189
+ type: "update",
190
+ value: existing,
191
+ previousValue: base.previousValue,
192
+ };
193
+ }
194
+ return base;
195
+ }
196
+
197
+ #toMillis(value: number | Date | null | undefined): number {
198
+ if (typeof value === "number") return value;
199
+ if (value instanceof Date) return value.getTime();
200
+ return 0;
201
+ }
202
+
203
+ async #resolveBackfill(lastAckedServerVersion: number): Promise<{
204
+ mode: SyncBackfillMode;
205
+ changes: SyncMessage<TItem>[];
206
+ }> {
207
+ if (lastAckedServerVersion <= 0) {
208
+ return {
209
+ mode: "snapshot",
210
+ changes: await this.options.store.getSnapshotMessages(),
211
+ };
212
+ }
213
+ // Client can be "ahead" after server restart/reset; force a full snapshot.
214
+ if (lastAckedServerVersion > this.#serverVersion) {
215
+ return {
216
+ mode: "snapshot",
217
+ changes: await this.options.store.getSnapshotMessages(),
218
+ };
219
+ }
220
+ const oldestLogged = this.#changeLog[0]?.serverVersion;
221
+ if (
222
+ oldestLogged !== undefined &&
223
+ lastAckedServerVersion < oldestLogged - 1
224
+ ) {
225
+ return {
226
+ mode: "snapshot",
227
+ changes: await this.options.store.getSnapshotMessages(),
228
+ };
229
+ }
230
+
231
+ const changes: SyncMessage<TItem>[] = [];
232
+ for (const entry of this.#changeLog) {
233
+ if (entry.serverVersion > lastAckedServerVersion) {
234
+ changes.push(...entry.changes);
235
+ }
236
+ }
237
+ return { mode: "delta", changes };
238
+ }
239
+
240
+ #chunkBackfillChanges(changes: SyncMessage<TItem>[]): SyncMessage<TItem>[][] {
241
+ const chunkSize = Math.max(1, this.options.backfillChunkSize ?? 500);
242
+ if (changes.length === 0) return [[]];
243
+ const chunks: SyncMessage<TItem>[][] = [];
244
+ for (let i = 0; i < changes.length; i += chunkSize) {
245
+ chunks.push(changes.slice(i, i + chunkSize));
246
+ }
247
+ return chunks;
248
+ }
249
+
250
+ /**
251
+ * Apply mutations that originated on the server (cron, admin API, etc.) and fan out to all clients.
252
+ */
253
+ async pushServerChanges(changes: SyncMessage<TItem>[]): Promise<void> {
254
+ if (changes.length === 0) return;
255
+ await this.options.store.applySyncMessages(changes);
256
+ this.#serverVersion += 1;
257
+ this.#changeLog.push({
258
+ serverVersion: this.#serverVersion,
259
+ changes,
260
+ });
261
+ this.#broadcastAll({
262
+ type: "syncBatch",
263
+ serverVersion: this.#serverVersion,
264
+ changes,
265
+ });
266
+ }
267
+ }