@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,200 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import type { RangeCondition } from "./sync-protocol";
4
+ import type { PartialSyncRowShape } from "./partial-sync-row-key";
5
+ import { partialSyncRowKey } from "./partial-sync-row-key";
6
+ import { matchesPredicate } from "./partial-sync-predicate-match";
7
+
8
+ /** Metadata on `rangePatch` when an update crosses client interest boundaries. */
9
+ export type PartialSyncViewTransition = "enterView" | "exitView";
10
+
11
+ export type PartialSyncPatchResult<TItem extends PartialSyncRowShape> = {
12
+ change: SyncMessage<TItem>;
13
+ viewTransition?: PartialSyncViewTransition;
14
+ };
15
+
16
+ /** Tracked 1D sort interval delivered to a client (index / cursor queries). */
17
+ export type DeliveredRange = {
18
+ sortColumn: string;
19
+ sortDirection: "asc" | "desc";
20
+ fromValue: unknown;
21
+ toValue: unknown;
22
+ };
23
+
24
+ export function compareInterestValues(left: unknown, right: unknown): number {
25
+ const leftValue = left instanceof Date ? left.getTime() : left;
26
+ const rightValue = right instanceof Date ? right.getTime() : right;
27
+ if (typeof leftValue === "number" && typeof rightValue === "number") {
28
+ return leftValue === rightValue ? 0 : leftValue < rightValue ? -1 : 1;
29
+ }
30
+ const leftString = String(leftValue);
31
+ const rightString = String(rightValue);
32
+ return leftString.localeCompare(rightString);
33
+ }
34
+
35
+ export function sortValueWithinDeliveredRange(
36
+ value: unknown,
37
+ range: DeliveredRange,
38
+ ): boolean {
39
+ if (value === undefined || value === null) return false;
40
+ const compareFrom = compareInterestValues(value, range.fromValue);
41
+ const compareTo = compareInterestValues(value, range.toValue);
42
+ if (range.sortDirection === "asc") {
43
+ return compareFrom >= 0 && compareTo <= 0;
44
+ }
45
+ return compareFrom <= 0 && compareTo >= 0;
46
+ }
47
+
48
+ export function rowMatchesDeliveredSortRanges<TItem>(
49
+ ranges: DeliveredRange[],
50
+ row: TItem,
51
+ getSortValue: (row: TItem, column: string) => unknown,
52
+ ): boolean {
53
+ if (ranges.length === 0) return false;
54
+ return ranges.some((range) => {
55
+ const sortValue = getSortValue(row, range.sortColumn);
56
+ return sortValueWithinDeliveredRange(sortValue, range);
57
+ });
58
+ }
59
+
60
+ export function rowMatchesPredicateGroups<TItem>(
61
+ predicateGroups: RangeCondition[][],
62
+ row: TItem,
63
+ getColumnValue: (row: TItem, column: string) => unknown,
64
+ ): boolean {
65
+ if (predicateGroups.length === 0) return false;
66
+ return predicateGroups.some((group) =>
67
+ matchesPredicate(row, group, getColumnValue),
68
+ );
69
+ }
70
+
71
+ export function rowMatchesClientInterest<TItem>(
72
+ sortRanges: DeliveredRange[],
73
+ predicateGroups: RangeCondition[][],
74
+ row: TItem,
75
+ getSortValue: (row: TItem, column: string) => unknown,
76
+ getColumnValue: (row: TItem, column: string) => unknown,
77
+ ): boolean {
78
+ /** Predicate viewport wins over 1D sort windows so chunk sort keys do not widen visibility. */
79
+ if (predicateGroups.length > 0) {
80
+ return rowMatchesPredicateGroups(predicateGroups, row, getColumnValue);
81
+ }
82
+ if (sortRanges.length > 0) {
83
+ return rowMatchesDeliveredSortRanges(sortRanges, row, getSortValue);
84
+ }
85
+ return false;
86
+ }
87
+
88
+ export type ClassifyPartialSyncRangePatchOptions = {
89
+ /**
90
+ * When set, `delete` is only forwarded if the key was previously delivered to this client
91
+ * (viewport-scoped deletes). When omitted, `delete` is always forwarded (legacy behavior).
92
+ */
93
+ deliveredRowIds?: ReadonlySet<string> | undefined;
94
+ };
95
+
96
+ /**
97
+ * Maps a server-side change to a `rangePatch` payload, or `null` if this client should not
98
+ * receive a patch. View enter/exit keeps the real `update` on the wire so clients can cache
99
+ * rows and filter locally instead of fake delete/insert.
100
+ */
101
+ export function classifyPartialSyncRangePatch<
102
+ TItem extends PartialSyncRowShape,
103
+ >(
104
+ sortRanges: DeliveredRange[],
105
+ predicateGroups: RangeCondition[][],
106
+ change: SyncMessage<TItem>,
107
+ getSortValue: (row: TItem, column: string) => unknown,
108
+ getColumnValue: (row: TItem, column: string) => unknown,
109
+ options?: ClassifyPartialSyncRangePatchOptions,
110
+ ): PartialSyncPatchResult<TItem> | null {
111
+ const deliveredIds = options?.deliveredRowIds;
112
+ if (change.type === "delete" && deliveredIds !== undefined) {
113
+ return deliveredIds.has(String(partialSyncRowKey(change.key)))
114
+ ? { change }
115
+ : null;
116
+ }
117
+
118
+ const hasInterest = sortRanges.length > 0 || predicateGroups.length > 0;
119
+ if (!hasInterest) return null;
120
+
121
+ if (change.type === "truncate") return { change };
122
+ if (change.type === "delete") return { change };
123
+
124
+ if (change.type === "insert") {
125
+ return rowMatchesClientInterest(
126
+ sortRanges,
127
+ predicateGroups,
128
+ change.value,
129
+ getSortValue,
130
+ getColumnValue,
131
+ )
132
+ ? { change }
133
+ : null;
134
+ }
135
+
136
+ if (change.type === "update") {
137
+ const newIn = rowMatchesClientInterest(
138
+ sortRanges,
139
+ predicateGroups,
140
+ change.value,
141
+ getSortValue,
142
+ getColumnValue,
143
+ );
144
+ const oldIn =
145
+ change.previousValue !== undefined
146
+ ? rowMatchesClientInterest(
147
+ sortRanges,
148
+ predicateGroups,
149
+ change.previousValue,
150
+ getSortValue,
151
+ getColumnValue,
152
+ )
153
+ : false;
154
+ if (newIn && oldIn) return { change };
155
+ if (newIn && !oldIn) return { change, viewTransition: "enterView" };
156
+ if (!newIn && oldIn) return { change, viewTransition: "exitView" };
157
+ return null;
158
+ }
159
+
160
+ exhaustiveGuard(change);
161
+ }
162
+
163
+ /**
164
+ * Filters sync messages to those relevant to a single predicate range (viewport). Used for
165
+ * `rangeDelta` so clients never receive changelog rows outside their requested conditions.
166
+ *
167
+ * - `insert` / `update` / `truncate`: uses {@link classifyPartialSyncRangePatch} with only this
168
+ * predicate group (no sort ranges).
169
+ * - `delete`: passed through unchanged — callers should filter deletes in the store when the
170
+ * deleted row snapshot is available (e.g. changelog payload).
171
+ */
172
+ export function filterSyncMessagesForPredicateRange<
173
+ TItem extends PartialSyncRowShape,
174
+ >(
175
+ conditions: RangeCondition[],
176
+ changes: SyncMessage<TItem>[],
177
+ getSortValue: (row: TItem, column: string) => unknown,
178
+ getColumnValue: (row: TItem, column: string) => unknown,
179
+ ): SyncMessage<TItem>[] {
180
+ const predicateGroups: RangeCondition[][] = [conditions];
181
+ const sortRanges: DeliveredRange[] = [];
182
+ const out: SyncMessage<TItem>[] = [];
183
+ for (const change of changes) {
184
+ if (change.type === "delete") {
185
+ out.push(change);
186
+ continue;
187
+ }
188
+ const patch = classifyPartialSyncRangePatch(
189
+ sortRanges,
190
+ predicateGroups,
191
+ change,
192
+ getSortValue,
193
+ getColumnValue,
194
+ );
195
+ if (patch !== null) {
196
+ out.push(patch.change);
197
+ }
198
+ }
199
+ return out;
200
+ }
@@ -0,0 +1,152 @@
1
+ import type { SyncMessage } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import type { PartialSyncServerBridge } from "./partial-sync-server-bridge";
4
+ import {
5
+ partialSyncRowKey,
6
+ type PartialSyncRowShape,
7
+ } from "./partial-sync-row-key";
8
+ import type {
9
+ MutationIntent,
10
+ SyncClientMessage,
11
+ SyncServerMessage,
12
+ SyncServerMessageBody,
13
+ } from "./sync-protocol";
14
+ import { DEFAULT_SYNC_COLLECTION_ID, toSyncMessage } from "./sync-protocol";
15
+
16
+ export interface PartialSyncMutationHandlerStore<
17
+ TItem extends PartialSyncRowShape,
18
+ > {
19
+ applySyncMessages: (messages: SyncMessage<TItem>[]) => Promise<void>;
20
+ getRow: (key: string | number) => Promise<TItem | undefined>;
21
+ }
22
+
23
+ export interface PartialSyncMutationHandlerOptions<
24
+ TItem extends PartialSyncRowShape,
25
+ > {
26
+ store: PartialSyncMutationHandlerStore<TItem>;
27
+ partialBridge: Pick<PartialSyncServerBridge<TItem>, "pushServerChanges">;
28
+ sendToClient: (clientId: string, message: SyncServerMessage<TItem>) => void;
29
+ collectionId?: string;
30
+ }
31
+
32
+ function toMillis(value: number | Date | null | undefined): number {
33
+ if (typeof value === "number") return value;
34
+ if (value instanceof Date) return value.getTime();
35
+ return 0;
36
+ }
37
+
38
+ /**
39
+ * Partial-sync durable object mutation path: `mutateBatch` → `ack` / `reject` + interest-scoped
40
+ * `rangePatch` via {@link PartialSyncServerBridge.pushServerChanges}. No `serverVersion`, changelog,
41
+ * or `syncBatch` broadcast.
42
+ */
43
+ export class PartialSyncMutationHandler<TItem extends PartialSyncRowShape> {
44
+ readonly #cid: string;
45
+
46
+ constructor(
47
+ private readonly options: PartialSyncMutationHandlerOptions<TItem>,
48
+ ) {
49
+ this.#cid = options.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
50
+ }
51
+
52
+ get collectionId(): string {
53
+ return this.#cid;
54
+ }
55
+
56
+ #emit(clientId: string, body: SyncServerMessageBody<TItem>): void {
57
+ this.options.sendToClient(clientId, {
58
+ ...body,
59
+ collectionId: this.#cid,
60
+ } as SyncServerMessage<TItem>);
61
+ }
62
+
63
+ async handleClientMessage(message: SyncClientMessage): Promise<void> {
64
+ const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
65
+ if (mid !== this.#cid) return;
66
+ switch (message.type) {
67
+ case "ping":
68
+ this.#emit(message.clientId, {
69
+ type: "pong",
70
+ timestamp: message.timestamp,
71
+ });
72
+ return;
73
+ case "mutateBatch":
74
+ await this.#handleMutateBatch(message);
75
+ return;
76
+ case "syncHello":
77
+ case "queryRange":
78
+ case "queryByOffset":
79
+ case "rangeQuery":
80
+ case "rangeReconcile":
81
+ return;
82
+ default:
83
+ exhaustiveGuard(message);
84
+ }
85
+ }
86
+
87
+ async #intentToMessageLww(
88
+ intent: MutationIntent,
89
+ ): Promise<SyncMessage<TItem> | null> {
90
+ const base = toSyncMessage(intent) as SyncMessage<TItem>;
91
+ if (base.type !== "update") {
92
+ return base;
93
+ }
94
+ const key = partialSyncRowKey(intent.key ?? base.value.id);
95
+ const existing = await this.options.store.getRow(key);
96
+ if (!existing) {
97
+ return base;
98
+ }
99
+ const incomingUpdatedAt = toMillis(base.value.updatedAt);
100
+ const existingUpdatedAt = toMillis(existing.updatedAt);
101
+ if (incomingUpdatedAt <= existingUpdatedAt) {
102
+ return {
103
+ type: "update",
104
+ value: existing,
105
+ previousValue: base.previousValue,
106
+ };
107
+ }
108
+ return base;
109
+ }
110
+
111
+ async #handleMutateBatch(
112
+ message: Extract<SyncClientMessage, { type: "mutateBatch" }>,
113
+ ): Promise<void> {
114
+ const resolvedChanges: SyncMessage<TItem>[] = [];
115
+ const acceptedMutationIds: string[] = [];
116
+
117
+ for (const mutation of message.mutations) {
118
+ try {
119
+ const change = await this.#intentToMessageLww(mutation);
120
+ if (!change) {
121
+ continue;
122
+ }
123
+ resolvedChanges.push(change);
124
+ acceptedMutationIds.push(mutation.clientMutationId);
125
+ } catch (error) {
126
+ this.#emit(message.clientId, {
127
+ type: "reject",
128
+ clientId: message.clientId,
129
+ clientMutationId: mutation.clientMutationId,
130
+ reason: error instanceof Error ? error.message : String(error),
131
+ correctiveChanges: [],
132
+ });
133
+ }
134
+ }
135
+
136
+ if (resolvedChanges.length === 0) return;
137
+
138
+ await this.options.store.applySyncMessages(resolvedChanges);
139
+
140
+ this.#emit(message.clientId, {
141
+ type: "ack",
142
+ clientId: message.clientId,
143
+ clientMutationIds: acceptedMutationIds,
144
+ serverVersion: 0,
145
+ changes: resolvedChanges,
146
+ });
147
+
148
+ await this.options.partialBridge.pushServerChanges(resolvedChanges, {
149
+ excludeClientId: message.clientId,
150
+ });
151
+ }
152
+ }
@@ -0,0 +1,65 @@
1
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
2
+ import type { RangeCondition } from "./sync-protocol";
3
+
4
+ function compareUnknown(a: unknown, b: unknown): number {
5
+ const na = a instanceof Date ? a.getTime() : a;
6
+ const nb = b instanceof Date ? b.getTime() : b;
7
+ if (typeof na === "number" && typeof nb === "number") {
8
+ if (na === nb) return 0;
9
+ return na < nb ? -1 : 1;
10
+ }
11
+ return String(na).localeCompare(String(nb));
12
+ }
13
+
14
+ export function defaultPredicateColumnValue<TItem>(
15
+ row: TItem,
16
+ column: string,
17
+ ): unknown {
18
+ if (row !== null && typeof row === "object" && column in row) {
19
+ return (row as Record<string, unknown>)[column];
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ export function matchesPredicate<TItem>(
25
+ row: TItem,
26
+ conditions: RangeCondition[],
27
+ getColumnValue: (row: TItem, column: string) => unknown,
28
+ ): boolean {
29
+ for (const c of conditions) {
30
+ const v = getColumnValue(row, c.column);
31
+ switch (c.op) {
32
+ case "eq":
33
+ if (compareUnknown(v, c.value) !== 0) return false;
34
+ break;
35
+ case "neq":
36
+ if (compareUnknown(v, c.value) === 0) return false;
37
+ break;
38
+ case "gt":
39
+ if (compareUnknown(v, c.value) <= 0) return false;
40
+ break;
41
+ case "gte":
42
+ if (compareUnknown(v, c.value) < 0) return false;
43
+ break;
44
+ case "lt":
45
+ if (compareUnknown(v, c.value) >= 0) return false;
46
+ break;
47
+ case "lte":
48
+ if (compareUnknown(v, c.value) > 0) return false;
49
+ break;
50
+ case "between": {
51
+ if (c.valueTo === undefined) return false;
52
+ if (
53
+ compareUnknown(v, c.value) < 0 ||
54
+ compareUnknown(v, c.valueTo) > 0
55
+ ) {
56
+ return false;
57
+ }
58
+ break;
59
+ }
60
+ default:
61
+ exhaustiveGuard(c.op);
62
+ }
63
+ }
64
+ return true;
65
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Row `id` values accepted by partial-sync (plain keys and ORM outputs such as drizzle-sqlite-wasm
3
+ * insert-schema types that are string-like but not assignable to `string | number` in TypeScript).
4
+ */
5
+ export type PartialSyncRowId = string | number | { toString(): string };
6
+
7
+ /**
8
+ * Minimal object shape keyed by a partial-sync row id. Use when only identity matters (e.g. cache
9
+ * keys). Prefer {@link PartialSyncRowShape} for sync / partial-sync bridges and hooks.
10
+ */
11
+ export type PartialSyncRowRef = {
12
+ id: PartialSyncRowId;
13
+ };
14
+
15
+ /**
16
+ * Version watermark for sync / partial-sync (fingerprints, reconciliation). The **`updatedAt` key
17
+ * must exist** on the object; `null` or `undefined` mean “no ms watermark” at runtime (treated as
18
+ * `0` where a number is needed). Rows or ORM types **without** this property are not suitable for
19
+ * sync / partial-sync APIs—use {@link PartialSyncRowRef} only when you only need identity (e.g.
20
+ * cache keys). (`undefined` is included in the union so Drizzle `InferSelectModel` / optional
21
+ * columns remain assignable.)
22
+ */
23
+ export type PartialSyncRowVersion = {
24
+ updatedAt: number | Date | null | undefined;
25
+ };
26
+
27
+ /**
28
+ * Row shape required across {@link PartialSyncClientBridge}, {@link SyncClientBridge}, and React
29
+ * partial-sync hooks: stable id plus a mandatory {@link PartialSyncRowVersion} key (see there).
30
+ */
31
+ export type PartialSyncRowShape = PartialSyncRowRef & PartialSyncRowVersion;
32
+
33
+ /** Max `updatedAt` as epoch ms; `null` / `undefined` → 0. */
34
+ export function partialSyncRowVersionWatermarkMs(
35
+ row: PartialSyncRowVersion,
36
+ ): number {
37
+ const v = row.updatedAt;
38
+ if (v === null || v === undefined) return 0;
39
+ if (typeof v === "number") return v;
40
+ if (v instanceof Date) return v.getTime();
41
+ return 0;
42
+ }
43
+
44
+ /**
45
+ * Like {@link partialSyncRowVersionWatermarkMs} for decoded protocol payloads that are not yet
46
+ * narrowed to {@link PartialSyncRowVersion} (e.g. mutate-batch `value` fields). Missing
47
+ * `updatedAt` → 0.
48
+ */
49
+ export function partialSyncRowVersionWatermarkMsUnknown(row: unknown): number {
50
+ if (!row || typeof row !== "object" || !("updatedAt" in row)) return 0;
51
+ return partialSyncRowVersionWatermarkMs(row as PartialSyncRowVersion);
52
+ }
53
+
54
+ export function partialSyncRowKey(id: PartialSyncRowId): string | number {
55
+ if (typeof id === "string" || typeof id === "number") return id;
56
+ return String(id);
57
+ }