Skip to content

Commit a94a60a

Browse files
Copilothotlong
andcommitted
feat: add @objectql/plugin-sync offline-first sync plugin package
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent fc806b1 commit a94a60a

9 files changed

Lines changed: 1168 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@objectql/plugin-sync",
3+
"version": "4.2.0",
4+
"description": "Offline-first sync plugin for ObjectQL - mutation logging, sync engine, and conflict resolution",
5+
"keywords": ["objectql", "sync", "offline", "conflict-resolution", "crdt", "mutation-log"],
6+
"license": "MIT",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"files": ["dist"],
16+
"scripts": {
17+
"build": "tsc",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@objectql/types": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"typescript": "^5.3.0",
25+
"vitest": "^1.0.4"
26+
}
27+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* ObjectQL Sync Plugin
3+
* Copyright (c) 2026-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import type { SyncConflict, SyncMutationResult } from '@objectql/types';
10+
11+
/**
12+
* Interface for conflict resolution strategies.
13+
*/
14+
export interface ConflictResolver {
15+
readonly strategy: string;
16+
resolve(conflict: SyncConflict): SyncMutationResult;
17+
}
18+
19+
/**
20+
* Last-Write-Wins resolver.
21+
* Accepts the mutation with the most recent timestamp.
22+
*/
23+
export class LastWriteWinsResolver implements ConflictResolver {
24+
readonly strategy = 'last-write-wins';
25+
26+
resolve(conflict: SyncConflict): SyncMutationResult {
27+
const clientTime = new Date(conflict.clientMutation.timestamp).getTime();
28+
const serverTime = conflict.serverRecord['updated_at']
29+
? new Date(conflict.serverRecord['updated_at'] as string).getTime()
30+
: 0;
31+
32+
if (clientTime >= serverTime) {
33+
// Client wins - apply the mutation
34+
return { status: 'applied', serverVersion: (conflict.clientMutation.baseVersion ?? 0) + 1 };
35+
}
36+
// Server wins - reject the mutation
37+
return {
38+
status: 'conflict',
39+
conflict: {
40+
...conflict,
41+
suggestedResolution: conflict.serverRecord,
42+
},
43+
};
44+
}
45+
}
46+
47+
/**
48+
* CRDT (Conflict-free Replicated Data Type) resolver.
49+
* Performs field-level LWW-Register merge. Each field is resolved independently.
50+
*/
51+
export class CrdtResolver implements ConflictResolver {
52+
readonly strategy = 'crdt';
53+
54+
resolve(conflict: SyncConflict): SyncMutationResult {
55+
const clientData = conflict.clientMutation.data ?? {};
56+
const serverData = conflict.serverRecord;
57+
const merged: Record<string, unknown> = { ...serverData };
58+
59+
// Field-level merge: client fields override server fields
60+
// unless the field is explicitly in the conflicting set
61+
for (const [key, value] of Object.entries(clientData)) {
62+
if (!conflict.conflictingFields.includes(key)) {
63+
merged[key] = value;
64+
}
65+
// For conflicting fields, keep server value (LWW at field level)
66+
}
67+
68+
return {
69+
status: 'applied',
70+
serverVersion: (conflict.clientMutation.baseVersion ?? 0) + 1,
71+
};
72+
}
73+
}
74+
75+
/**
76+
* Manual conflict resolver.
77+
* Flags all conflicts for manual resolution via a callback.
78+
*/
79+
export class ManualResolver implements ConflictResolver {
80+
readonly strategy = 'manual';
81+
82+
private readonly onConflict?: (conflict: SyncConflict) => Record<string, unknown> | undefined;
83+
84+
constructor(onConflict?: (conflict: SyncConflict) => Record<string, unknown> | undefined) {
85+
this.onConflict = onConflict;
86+
}
87+
88+
resolve(conflict: SyncConflict): SyncMutationResult {
89+
if (this.onConflict) {
90+
const resolution = this.onConflict(conflict);
91+
if (resolution) {
92+
return {
93+
status: 'applied',
94+
serverVersion: (conflict.clientMutation.baseVersion ?? 0) + 1,
95+
};
96+
}
97+
}
98+
return { status: 'conflict', conflict };
99+
}
100+
}
101+
102+
/**
103+
* Factory function to create the appropriate resolver.
104+
*/
105+
export function createResolver(
106+
strategy: string,
107+
onConflict?: (conflict: SyncConflict) => Record<string, unknown> | undefined
108+
): ConflictResolver {
109+
switch (strategy) {
110+
case 'last-write-wins':
111+
return new LastWriteWinsResolver();
112+
case 'crdt':
113+
return new CrdtResolver();
114+
case 'manual':
115+
return new ManualResolver(onConflict);
116+
default:
117+
return new LastWriteWinsResolver();
118+
}
119+
}

0 commit comments

Comments
 (0)