@zveltio/sdk 1.2.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/README.md +191 -0
- package/dist/client/Auth.d.ts +19 -0
- package/dist/client/Auth.d.ts.map +1 -0
- package/dist/client/Auth.js +45 -0
- package/dist/client/QueryBuilder.d.ts +17 -0
- package/dist/client/QueryBuilder.d.ts.map +1 -0
- package/dist/client/QueryBuilder.js +76 -0
- package/dist/client/RealtimeClient.d.ts +22 -0
- package/dist/client/RealtimeClient.d.ts.map +1 -0
- package/dist/client/RealtimeClient.js +96 -0
- package/dist/client/ZveltioClient.d.ts +20 -0
- package/dist/client/ZveltioClient.d.ts.map +1 -0
- package/dist/client/ZveltioClient.js +78 -0
- package/dist/client.d.ts +82 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +122 -0
- package/dist/core.d.ts +61 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +65 -0
- package/dist/crdt.d.ts +41 -0
- package/dist/crdt.d.ts.map +1 -0
- package/dist/crdt.js +87 -0
- package/dist/extension/index.d.ts +46 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/local-store.d.ts +68 -0
- package/dist/local-store.d.ts.map +1 -0
- package/dist/local-store.js +263 -0
- package/dist/realtime.d.ts +14 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +82 -0
- package/dist/schema-watcher.d.ts +41 -0
- package/dist/schema-watcher.d.ts.map +1 -0
- package/dist/schema-watcher.js +182 -0
- package/dist/svelte.d.ts +38 -0
- package/dist/svelte.d.ts.map +1 -0
- package/dist/svelte.js +48 -0
- package/dist/sync-manager.d.ts +73 -0
- package/dist/sync-manager.d.ts.map +1 -0
- package/dist/sync-manager.js +293 -0
- package/dist/tests/local-store.test.d.ts +2 -0
- package/dist/tests/local-store.test.d.ts.map +1 -0
- package/dist/tests/local-store.test.js +76 -0
- package/dist/tests/setup.d.ts +2 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +3 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ZveltioClient } from './client.js';
|
|
2
|
+
export type CRDTConflictResolution = 'lww' | 'custom';
|
|
3
|
+
export interface SyncManagerConfig {
|
|
4
|
+
/** Sync interval in ms (default: 5000) */
|
|
5
|
+
syncInterval?: number;
|
|
6
|
+
/** Max retry attempts per operation (default: 5) */
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
/** Exponential backoff base in ms (default: 1000) */
|
|
9
|
+
backoffBase?: number;
|
|
10
|
+
/** Callback for conflicts (default: server-wins) */
|
|
11
|
+
onConflict?: (local: any, server: any) => any;
|
|
12
|
+
/** CRDT conflict resolution strategy: 'lww' (field-level LWW merge) or 'custom' (onConflict cb). Default: 'lww' */
|
|
13
|
+
conflictResolution?: CRDTConflictResolution;
|
|
14
|
+
}
|
|
15
|
+
export declare class SyncManager {
|
|
16
|
+
private store;
|
|
17
|
+
private client;
|
|
18
|
+
private realtime;
|
|
19
|
+
private config;
|
|
20
|
+
private syncTimer;
|
|
21
|
+
private isOnline;
|
|
22
|
+
private isSyncing;
|
|
23
|
+
private listeners;
|
|
24
|
+
constructor(client: ZveltioClient, config?: SyncManagerConfig);
|
|
25
|
+
start(realtimeUrl?: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Returns a local-first collection proxy:
|
|
28
|
+
* - list/get reads LOCAL (instant)
|
|
29
|
+
* - create/update/delete writes LOCAL + queues sync
|
|
30
|
+
* - subscribe receives realtime updates
|
|
31
|
+
*/
|
|
32
|
+
collection(name: string): {
|
|
33
|
+
/** List records — reads LOCAL instantly */
|
|
34
|
+
list: () => Promise<{
|
|
35
|
+
_syncStatus: "synced" | "pending" | "conflict";
|
|
36
|
+
id: string;
|
|
37
|
+
}[]>;
|
|
38
|
+
/** Get one record — reads LOCAL instantly */
|
|
39
|
+
get: (id: string) => Promise<{
|
|
40
|
+
_syncStatus: "synced" | "pending" | "conflict";
|
|
41
|
+
id: string;
|
|
42
|
+
} | null>;
|
|
43
|
+
/** Create — writes LOCAL + queues sync */
|
|
44
|
+
create: (data: Record<string, any>) => Promise<{
|
|
45
|
+
_syncStatus: "synced" | "pending" | "conflict";
|
|
46
|
+
id: string;
|
|
47
|
+
}>;
|
|
48
|
+
/** Update — writes LOCAL + queues sync */
|
|
49
|
+
update: (id: string, data: Record<string, any>) => Promise<{
|
|
50
|
+
_syncStatus: "synced" | "pending" | "conflict";
|
|
51
|
+
id: string;
|
|
52
|
+
}>;
|
|
53
|
+
/** Delete — soft delete LOCAL + queues sync */
|
|
54
|
+
delete: (id: string) => Promise<void>;
|
|
55
|
+
/** Subscribe la changes (realtime + local writes) */
|
|
56
|
+
subscribe: (callback: (records: any[]) => void) => () => void;
|
|
57
|
+
/** Get pending conflicts for UI resolution */
|
|
58
|
+
getConflicts: () => Promise<import("./local-store.js").LocalRecord[]>;
|
|
59
|
+
/** Resolve a conflict manually */
|
|
60
|
+
resolveConflict: (id: string, resolvedData: Record<string, any>) => Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
/** Force sync now (non-blocking) */
|
|
63
|
+
syncNow(): Promise<void>;
|
|
64
|
+
private notifyListeners;
|
|
65
|
+
/** Status: pending operations count, conflicts count */
|
|
66
|
+
getStatus(): Promise<{
|
|
67
|
+
pending: number;
|
|
68
|
+
conflicts: number;
|
|
69
|
+
isOnline: boolean;
|
|
70
|
+
}>;
|
|
71
|
+
stop(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=sync-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["../src/sync-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAKjD,MAAM,MAAM,sBAAsB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,GAAG,CAAC;IAC9C,mHAAmH;IACnH,kBAAkB,CAAC,EAAE,sBAAsB,CAAC;CAC7C;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,OAAO,CAAC,SAAS,CAAyD;gBAE9D,MAAM,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB;IAY3D,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiChD;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM;QAEnB,2CAA2C;;;;;QAU3C,6CAA6C;kBAC7B,MAAM;;;;QAUtB,0CAA0C;uBACrB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAYxC,0CAA0C;qBACvB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAapD,+CAA+C;qBAC5B,MAAM;QAMzB,qDAAqD;8BAC/B,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI;QAoD9C,8CAA8C;;QAK9C,kCAAkC;8BAE5B,MAAM,gBACI,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;IASvC,oCAAoC;IAC9B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YA8GhB,eAAe;IAmB7B,wDAAwD;IAClD,SAAS,IAAI,OAAO,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IAUI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAK5B"}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { LocalStore } from './local-store.js';
|
|
2
|
+
import { mergeLWW, fromDocument } from './crdt.js';
|
|
3
|
+
export class SyncManager {
|
|
4
|
+
store;
|
|
5
|
+
client;
|
|
6
|
+
realtime = null;
|
|
7
|
+
config;
|
|
8
|
+
syncTimer = null;
|
|
9
|
+
isOnline = true;
|
|
10
|
+
isSyncing = false;
|
|
11
|
+
listeners = new Map();
|
|
12
|
+
constructor(client, config = {}) {
|
|
13
|
+
this.store = new LocalStore();
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.config = {
|
|
16
|
+
syncInterval: config.syncInterval ?? 5000,
|
|
17
|
+
maxRetries: config.maxRetries ?? 5,
|
|
18
|
+
backoffBase: config.backoffBase ?? 1000,
|
|
19
|
+
onConflict: config.onConflict ?? ((_local, server) => server),
|
|
20
|
+
conflictResolution: config.conflictResolution ?? 'lww',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async start(realtimeUrl) {
|
|
24
|
+
await this.store.open();
|
|
25
|
+
// Online/offline detection
|
|
26
|
+
if (typeof window !== 'undefined') {
|
|
27
|
+
window.addEventListener('online', () => {
|
|
28
|
+
this.isOnline = true;
|
|
29
|
+
this.syncNow(); // Sync immediately on reconnect
|
|
30
|
+
});
|
|
31
|
+
window.addEventListener('offline', () => {
|
|
32
|
+
this.isOnline = false;
|
|
33
|
+
});
|
|
34
|
+
this.isOnline = navigator.onLine;
|
|
35
|
+
}
|
|
36
|
+
// Realtime: receives push updates from server
|
|
37
|
+
if (realtimeUrl) {
|
|
38
|
+
const { ZveltioRealtime } = await import('./realtime.js');
|
|
39
|
+
this.realtime = new ZveltioRealtime(realtimeUrl);
|
|
40
|
+
this.realtime.connect();
|
|
41
|
+
// Subscriptions are per-collection via collection()
|
|
42
|
+
}
|
|
43
|
+
// Periodic sync
|
|
44
|
+
this.syncTimer = setInterval(() => this.syncNow(), this.config.syncInterval);
|
|
45
|
+
// Initial sync
|
|
46
|
+
await this.syncNow();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns a local-first collection proxy:
|
|
50
|
+
* - list/get reads LOCAL (instant)
|
|
51
|
+
* - create/update/delete writes LOCAL + queues sync
|
|
52
|
+
* - subscribe receives realtime updates
|
|
53
|
+
*/
|
|
54
|
+
collection(name) {
|
|
55
|
+
return {
|
|
56
|
+
/** List records — reads LOCAL instantly */
|
|
57
|
+
list: async () => {
|
|
58
|
+
const records = await this.store.list(name);
|
|
59
|
+
return records.map((r) => ({
|
|
60
|
+
id: r.id,
|
|
61
|
+
...r.data,
|
|
62
|
+
_syncStatus: r._syncStatus,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
/** Get one record — reads LOCAL instantly */
|
|
66
|
+
get: async (id) => {
|
|
67
|
+
const record = await this.store.get(name, id);
|
|
68
|
+
if (!record)
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
id: record.id,
|
|
72
|
+
...record.data,
|
|
73
|
+
_syncStatus: record._syncStatus,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
/** Create — writes LOCAL + queues sync */
|
|
77
|
+
create: async (data) => {
|
|
78
|
+
const id = data.id || crypto.randomUUID();
|
|
79
|
+
const record = await this.store.put(name, id, data);
|
|
80
|
+
this.notifyListeners(name);
|
|
81
|
+
this.syncNow(); // Trigger sync immediately (non-blocking)
|
|
82
|
+
return {
|
|
83
|
+
id: record.id,
|
|
84
|
+
...record.data,
|
|
85
|
+
_syncStatus: record._syncStatus,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
/** Update — writes LOCAL + queues sync */
|
|
89
|
+
update: async (id, data) => {
|
|
90
|
+
const existing = await this.store.get(name, id);
|
|
91
|
+
const merged = { ...(existing?.data || {}), ...data };
|
|
92
|
+
const record = await this.store.put(name, id, merged);
|
|
93
|
+
this.notifyListeners(name);
|
|
94
|
+
this.syncNow();
|
|
95
|
+
return {
|
|
96
|
+
id: record.id,
|
|
97
|
+
...record.data,
|
|
98
|
+
_syncStatus: record._syncStatus,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
/** Delete — soft delete LOCAL + queues sync */
|
|
102
|
+
delete: async (id) => {
|
|
103
|
+
await this.store.delete(name, id);
|
|
104
|
+
this.notifyListeners(name);
|
|
105
|
+
this.syncNow();
|
|
106
|
+
},
|
|
107
|
+
/** Subscribe la changes (realtime + local writes) */
|
|
108
|
+
subscribe: (callback) => {
|
|
109
|
+
if (!this.listeners.has(name))
|
|
110
|
+
this.listeners.set(name, new Set());
|
|
111
|
+
this.listeners.get(name).add(callback);
|
|
112
|
+
// Subscribe to realtime server push
|
|
113
|
+
let unsubRealtime;
|
|
114
|
+
if (this.realtime) {
|
|
115
|
+
unsubRealtime = this.realtime.subscribe(name, async (event) => {
|
|
116
|
+
// Apply update from server to local store
|
|
117
|
+
if (event.event === 'record.created' ||
|
|
118
|
+
event.event === 'record.updated') {
|
|
119
|
+
try {
|
|
120
|
+
const serverRecord = await this.client
|
|
121
|
+
.collection(name)
|
|
122
|
+
.get(event.record_id);
|
|
123
|
+
await this.store.applyServerUpdate(name, event.record_id, serverRecord, Date.now());
|
|
124
|
+
this.notifyListeners(name);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* offline or error — ignore, periodic sync will resolve */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (event.event === 'record.deleted') {
|
|
131
|
+
await this.store.delete(name, event.record_id);
|
|
132
|
+
this.notifyListeners(name);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Emit current state immediately
|
|
137
|
+
this.store.list(name).then((records) => {
|
|
138
|
+
callback(records.map((r) => ({
|
|
139
|
+
id: r.id,
|
|
140
|
+
...r.data,
|
|
141
|
+
_syncStatus: r._syncStatus,
|
|
142
|
+
})));
|
|
143
|
+
});
|
|
144
|
+
// Return unsubscribe function
|
|
145
|
+
return () => {
|
|
146
|
+
this.listeners.get(name)?.delete(callback);
|
|
147
|
+
unsubRealtime?.();
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
/** Get pending conflicts for UI resolution */
|
|
151
|
+
getConflicts: async () => {
|
|
152
|
+
return this.store.getConflicts(name);
|
|
153
|
+
},
|
|
154
|
+
/** Resolve a conflict manually */
|
|
155
|
+
resolveConflict: async (id, resolvedData) => {
|
|
156
|
+
await this.store.resolveConflict(name, id, resolvedData);
|
|
157
|
+
this.notifyListeners(name);
|
|
158
|
+
this.syncNow();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Force sync now (non-blocking) */
|
|
163
|
+
async syncNow() {
|
|
164
|
+
if (!this.isOnline || this.isSyncing)
|
|
165
|
+
return;
|
|
166
|
+
this.isSyncing = true;
|
|
167
|
+
try {
|
|
168
|
+
// Step 1: Upload offline blobs BEFORE syncing records
|
|
169
|
+
const pendingBlobs = await this.store.getPendingBlobs();
|
|
170
|
+
for (const blobItem of pendingBlobs) {
|
|
171
|
+
try {
|
|
172
|
+
const file = new File([blobItem.blob], `offline_${blobItem.id}`, {
|
|
173
|
+
type: blobItem.blob.type,
|
|
174
|
+
});
|
|
175
|
+
const result = (await this.client.storage.upload(file));
|
|
176
|
+
const url = result?.url || result?.publicUrl || result?.path || '';
|
|
177
|
+
if (!url)
|
|
178
|
+
continue; // Upload returned without URL — skip
|
|
179
|
+
// Replace local_blob_* reference with real URL in record
|
|
180
|
+
const record = await this.store.get(blobItem.collection, blobItem.recordId);
|
|
181
|
+
if (record && record.data[blobItem.field] === blobItem.id) {
|
|
182
|
+
await this.store.put(blobItem.collection, blobItem.recordId, {
|
|
183
|
+
...record.data,
|
|
184
|
+
[blobItem.field]: url,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
await this.store.deleteBlob(blobItem.id);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Offline or upload error — skip, retry at next sync cycle
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const pending = await this.store.getPendingOps();
|
|
194
|
+
for (const op of pending) {
|
|
195
|
+
if (op.attempts >= this.config.maxRetries)
|
|
196
|
+
continue; // Skip exhausted operations
|
|
197
|
+
try {
|
|
198
|
+
const serverVersion = Date.now();
|
|
199
|
+
// Attach CRDT doc to payload if available
|
|
200
|
+
const localRecord = await this.store.get(op.collection, op.recordId);
|
|
201
|
+
const crdtPayload = localRecord?._crdtDoc
|
|
202
|
+
? { ...op.payload, __crdt: localRecord._crdtDoc }
|
|
203
|
+
: op.payload;
|
|
204
|
+
switch (op.operation) {
|
|
205
|
+
case 'create':
|
|
206
|
+
await this.client
|
|
207
|
+
.collection(op.collection)
|
|
208
|
+
.create({ id: op.recordId, ...crdtPayload });
|
|
209
|
+
break;
|
|
210
|
+
case 'update':
|
|
211
|
+
await this.client
|
|
212
|
+
.collection(op.collection)
|
|
213
|
+
.update(op.recordId, crdtPayload);
|
|
214
|
+
break;
|
|
215
|
+
case 'delete':
|
|
216
|
+
await this.client.collection(op.collection).delete(op.recordId);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
await this.store.markSynced(op.id, op.collection, op.recordId, serverVersion);
|
|
220
|
+
this.notifyListeners(op.collection);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
// Conflict from server (409) — apply conflict resolution
|
|
224
|
+
if (err.message?.includes('409')) {
|
|
225
|
+
try {
|
|
226
|
+
const serverRecord = await this.client
|
|
227
|
+
.collection(op.collection)
|
|
228
|
+
.get(op.recordId);
|
|
229
|
+
const localRecord = await this.store.get(op.collection, op.recordId);
|
|
230
|
+
// CRDT LWW merge if both sides have CRDT docs and strategy is 'lww'
|
|
231
|
+
if (this.config.conflictResolution === 'lww' &&
|
|
232
|
+
localRecord?._crdtDoc &&
|
|
233
|
+
serverRecord.__crdt) {
|
|
234
|
+
const merged = mergeLWW(localRecord._crdtDoc, serverRecord.__crdt);
|
|
235
|
+
const resolvedData = fromDocument(merged);
|
|
236
|
+
await this.store.resolveConflict(op.collection, op.recordId, resolvedData);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const resolved = this.config.onConflict(localRecord?.data, serverRecord);
|
|
240
|
+
await this.store.resolveConflict(op.collection, op.recordId, resolved);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* fallback: server wins — ignore error */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// Exponential backoff
|
|
249
|
+
await this.store.markFailed(op.id, err.message || 'Unknown error');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.isSyncing = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async notifyListeners(collection) {
|
|
259
|
+
const callbacks = this.listeners.get(collection);
|
|
260
|
+
if (!callbacks?.size)
|
|
261
|
+
return;
|
|
262
|
+
const records = await this.store.list(collection);
|
|
263
|
+
const mapped = records.map((r) => ({
|
|
264
|
+
id: r.id,
|
|
265
|
+
...r.data,
|
|
266
|
+
_syncStatus: r._syncStatus,
|
|
267
|
+
}));
|
|
268
|
+
callbacks.forEach((cb) => {
|
|
269
|
+
try {
|
|
270
|
+
cb(mapped);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* ignore callback errors */
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/** Status: pending operations count, conflicts count */
|
|
278
|
+
async getStatus() {
|
|
279
|
+
const pending = await this.store.getPendingOps();
|
|
280
|
+
const conflicts = await this.store.getConflicts();
|
|
281
|
+
return {
|
|
282
|
+
pending: pending.length,
|
|
283
|
+
conflicts: conflicts.length,
|
|
284
|
+
isOnline: this.isOnline,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async stop() {
|
|
288
|
+
if (this.syncTimer)
|
|
289
|
+
clearInterval(this.syncTimer);
|
|
290
|
+
this.realtime?.disconnect();
|
|
291
|
+
await this.store.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-store.test.d.ts","sourceRoot":"","sources":["../../src/tests/local-store.test.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,CAAC"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import './setup';
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
3
|
+
import { LocalStore } from '../local-store.js';
|
|
4
|
+
describe('LocalStore — conflict detection', () => {
|
|
5
|
+
let store;
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
store = new LocalStore();
|
|
8
|
+
await store.open();
|
|
9
|
+
// Reset DB state — fake-indexeddb is a module-level singleton shared across
|
|
10
|
+
// test files within the same Bun process (src/ and dist/ runs both use it).
|
|
11
|
+
// Without clearing, dist/ tests find records left by src/ tests and
|
|
12
|
+
// _localVersion is wrong (e.g. 2 instead of 1 for a fresh put).
|
|
13
|
+
await store.clear();
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await store.close();
|
|
17
|
+
});
|
|
18
|
+
it('no conflict when record is clean (no local edits)', async () => {
|
|
19
|
+
// Simulate a record that arrived from server with no local edits
|
|
20
|
+
await store.applyServerUpdate('posts', 'rec-1', { title: 'Hello' }, 1);
|
|
21
|
+
const rec = await store.get('posts', 'rec-1');
|
|
22
|
+
expect(rec?._syncStatus).toBe('synced');
|
|
23
|
+
expect(rec?._conflictData).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
it('detects conflict when server update races a pending local write', async () => {
|
|
26
|
+
// 1. Client writes locally (status=pending, localVersion=1, serverVersion=0)
|
|
27
|
+
await store.put('posts', 'rec-2', { title: 'Draft' });
|
|
28
|
+
const before = await store.get('posts', 'rec-2');
|
|
29
|
+
expect(before?._syncStatus).toBe('pending');
|
|
30
|
+
expect(before?._localVersion).toBe(1);
|
|
31
|
+
expect(before?._serverVersion).toBe(0);
|
|
32
|
+
// 2. Server pushes an update
|
|
33
|
+
await store.applyServerUpdate('posts', 'rec-2', { title: 'Server Title' }, 3);
|
|
34
|
+
const after = await store.get('posts', 'rec-2');
|
|
35
|
+
expect(after?._syncStatus).toBe('conflict');
|
|
36
|
+
expect(after?._conflictData).toEqual({ title: 'Server Title' });
|
|
37
|
+
// Local data is NOT overwritten so user can compare both versions
|
|
38
|
+
expect(after?.data).toEqual({ title: 'Draft' });
|
|
39
|
+
});
|
|
40
|
+
it('detects conflict when localVersion > serverVersion regardless of syncStatus (CLAUDE.md scenario)', async () => {
|
|
41
|
+
// Reproduce: _localVersion=2, _serverVersion=1, _syncStatus='synced'
|
|
42
|
+
// Step 1: server sends version 1 → _localVersion=0, _serverVersion=1
|
|
43
|
+
await store.applyServerUpdate('posts', 'rec-3', { title: 'Original' }, 1);
|
|
44
|
+
// Step 2: two local edits → _localVersion=2, _serverVersion=1
|
|
45
|
+
await store.put('posts', 'rec-3', { title: 'Edit 1' }); // localVersion=1
|
|
46
|
+
await store.put('posts', 'rec-3', { title: 'Edit 2' }); // localVersion=2
|
|
47
|
+
// Step 3: forcibly mark as 'synced' while localVersion(2) > serverVersion(1)
|
|
48
|
+
// (simulate the race: sync ACK arrived for v1 but client already at v2)
|
|
49
|
+
const pending = await store.get('posts', 'rec-3');
|
|
50
|
+
expect(pending?._localVersion).toBe(2);
|
|
51
|
+
expect(pending?._serverVersion).toBe(1);
|
|
52
|
+
// Patch syncStatus to 'synced' without advancing serverVersion — the race condition
|
|
53
|
+
const queueItems = await store.getPendingOps();
|
|
54
|
+
// Use markSynced for the first op only — server confirmed v1, client is at v2
|
|
55
|
+
if (queueItems[0]) {
|
|
56
|
+
await store.markSynced(queueItems[0].id, 'posts', 'rec-3', 1);
|
|
57
|
+
}
|
|
58
|
+
const raceState = await store.get('posts', 'rec-3');
|
|
59
|
+
// After markSynced: _serverVersion=1, _localVersion=2 → still dirty
|
|
60
|
+
expect(raceState?._localVersion).toBeGreaterThan(raceState?._serverVersion ?? 0);
|
|
61
|
+
// Step 4: server pushes a concurrent update
|
|
62
|
+
await store.applyServerUpdate('posts', 'rec-3', { title: 'Concurrent Server Edit' }, 5);
|
|
63
|
+
const conflict = await store.get('posts', 'rec-3');
|
|
64
|
+
expect(conflict?._syncStatus).toBe('conflict');
|
|
65
|
+
expect(conflict?._conflictData).toEqual({ title: 'Concurrent Server Edit' });
|
|
66
|
+
});
|
|
67
|
+
it('applies cleanly when server version is newer and no local edits', async () => {
|
|
68
|
+
await store.applyServerUpdate('posts', 'rec-4', { title: 'v1' }, 1);
|
|
69
|
+
await store.applyServerUpdate('posts', 'rec-4', { title: 'v2' }, 2);
|
|
70
|
+
const rec = await store.get('posts', 'rec-4');
|
|
71
|
+
expect(rec?._syncStatus).toBe('synced');
|
|
72
|
+
expect(rec?.data).toEqual({ title: 'v2' });
|
|
73
|
+
expect(rec?._serverVersion).toBe(2);
|
|
74
|
+
expect(rec?._conflictData).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../src/tests/setup.ts"],"names":[],"mappings":"AAEA,OAAO,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface ZveltioConfig {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
onUnauthorized?: () => void;
|
|
5
|
+
}
|
|
6
|
+
export interface QueryOptions {
|
|
7
|
+
page?: number;
|
|
8
|
+
limit?: number;
|
|
9
|
+
sort?: string;
|
|
10
|
+
order?: 'asc' | 'desc';
|
|
11
|
+
filter?: Record<string, any>;
|
|
12
|
+
search?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface QueryResponse<T = any> {
|
|
15
|
+
records: T[];
|
|
16
|
+
pagination: {
|
|
17
|
+
total: number;
|
|
18
|
+
page: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
pages: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface SingleResponse<T = any> {
|
|
24
|
+
record: T;
|
|
25
|
+
}
|
|
26
|
+
export interface CreateResponse<T = any> {
|
|
27
|
+
record: T;
|
|
28
|
+
}
|
|
29
|
+
export interface DeleteResponse {
|
|
30
|
+
success: boolean;
|
|
31
|
+
id: string;
|
|
32
|
+
}
|
|
33
|
+
export interface RealtimeMessage {
|
|
34
|
+
event: 'insert' | 'update' | 'delete';
|
|
35
|
+
collection: string;
|
|
36
|
+
data: any;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,GAAG;IACpC,OAAO,EAAE,CAAC,EAAE,CAAC;IACb,UAAU,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,GAAG;IACrC,MAAM,EAAE,CAAC,CAAC;CACX;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,GAAG;IACrC,MAAM,EAAE,CAAC,CAAC;CACX;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,GAAG,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zveltio/sdk",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./extension": {
|
|
13
|
+
"types": "./dist/extension/index.d.ts",
|
|
14
|
+
"import": "./dist/extension/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"dev": "tsc --watch",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "bun test --preload ./src/tests/setup.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"idb": "^8.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"fake-indexeddb": "^6.2.5",
|
|
32
|
+
"hono": "^4.12.5",
|
|
33
|
+
"typescript": "^5.4.0"
|
|
34
|
+
}
|
|
35
|
+
}
|