@syncvault/sdk 1.1.0 → 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 CHANGED
@@ -210,3 +210,81 @@ await fetch(`https://api.syncvault.dev/api/entitlements/${userId}`, {
210
210
  ```
211
211
 
212
212
  Never expose the secret token in client-side code.
213
+
214
+ ## Offline Sync
215
+
216
+ The SDK supports offline-first sync with local caching and automatic retry.
217
+
218
+ ### Basic Usage
219
+
220
+ ```javascript
221
+ import { SyncVault, createOfflineClient } from '@syncvault/sdk';
222
+
223
+ const baseClient = new SyncVault({ appToken: 'your_token' });
224
+ const vault = createOfflineClient(baseClient, {
225
+ retryInterval: 30000, // Retry every 30 seconds
226
+ maxRetries: 10, // Max retries per operation
227
+ autoSync: true // Auto-sync when online
228
+ });
229
+
230
+ // Initialize (loads cache from storage)
231
+ await vault.init();
232
+
233
+ // Authenticate
234
+ await vault.auth('username', 'password');
235
+
236
+ // Put - queues if offline, syncs when online
237
+ await vault.put('data.json', { hello: 'world' });
238
+
239
+ // Get - returns cached data if offline
240
+ const data = await vault.get('data.json');
241
+
242
+ // Check pending operations
243
+ if (vault.hasPendingChanges()) {
244
+ console.log('Pending:', vault.pendingCount());
245
+ }
246
+ ```
247
+
248
+ ### Callbacks
249
+
250
+ ```javascript
251
+ vault.onSyncSuccess = (op) => {
252
+ console.log('Synced:', op.path);
253
+ };
254
+
255
+ vault.onSyncError = (op, error) => {
256
+ console.log('Failed:', op.path, error);
257
+ };
258
+ ```
259
+
260
+ ### Manual Sync Control
261
+
262
+ ```javascript
263
+ // Manually trigger sync
264
+ await vault.syncPending();
265
+
266
+ // Stop auto-sync
267
+ vault.stopAutoSync();
268
+
269
+ // Clear cache/queue
270
+ await vault.getStore().clearCache();
271
+ await vault.getStore().clearQueue();
272
+ ```
273
+
274
+ ### Custom Storage (React Native, etc.)
275
+
276
+ ```javascript
277
+ const customStorage = {
278
+ async get(key) {
279
+ return AsyncStorage.getItem(key).then(JSON.parse);
280
+ },
281
+ async set(key, value) {
282
+ await AsyncStorage.setItem(key, JSON.stringify(value));
283
+ },
284
+ async remove(key) {
285
+ await AsyncStorage.removeItem(key);
286
+ }
287
+ };
288
+
289
+ const vault = createOfflineClient(baseClient, { storage: customStorage });
290
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncvault/sdk",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "SyncVault SDK - Zero-knowledge encrypted sync for Node.js and browsers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -72,3 +72,90 @@ export declare class SyncVault {
72
72
 
73
73
  export declare function encrypt(data: unknown, password: string): Promise<string>;
74
74
  export declare function decrypt<T = unknown>(encryptedBase64: string, password: string): Promise<T>;
75
+
76
+ // Offline sync types
77
+ export interface PendingOperation {
78
+ id: string;
79
+ type: 'put' | 'delete';
80
+ path: string;
81
+ data?: string;
82
+ createdAt: number;
83
+ retries: number;
84
+ }
85
+
86
+ export interface CacheEntry {
87
+ path: string;
88
+ data: string;
89
+ updatedAt: number;
90
+ }
91
+
92
+ export interface OfflineStorage {
93
+ get(key: string): Promise<unknown | null>;
94
+ set(key: string, value: unknown): Promise<void>;
95
+ remove(key: string): Promise<void>;
96
+ }
97
+
98
+ export interface OfflineOptions {
99
+ storage?: OfflineStorage;
100
+ retryInterval?: number;
101
+ maxRetries?: number;
102
+ autoSync?: boolean;
103
+ }
104
+
105
+ export declare class OfflineStore {
106
+ constructor(storage?: OfflineStorage | null);
107
+ load(): Promise<void>;
108
+ persist(): Promise<void>;
109
+ getCached(path: string): CacheEntry | null;
110
+ setCache(path: string, data: string): Promise<void>;
111
+ removeCache(path: string): Promise<void>;
112
+ queueOperation(op: Partial<PendingOperation>): Promise<void>;
113
+ getPendingOperations(): PendingOperation[];
114
+ removeOperation(id: string): Promise<void>;
115
+ incrementRetry(id: string): Promise<void>;
116
+ hasPendingOperations(): boolean;
117
+ clearQueue(): Promise<void>;
118
+ clearCache(): Promise<void>;
119
+ }
120
+
121
+ export declare class OfflineSyncVault {
122
+ constructor(baseClient: SyncVault, options?: OfflineOptions);
123
+
124
+ onSyncSuccess: ((op: PendingOperation) => void) | null;
125
+ onSyncError: ((op: PendingOperation, error: Error) => void) | null;
126
+
127
+ init(): Promise<void>;
128
+
129
+ // Auth (proxied to base client)
130
+ auth(username: string, password: string): Promise<User>;
131
+ register(username: string, password: string): Promise<User>;
132
+ setAuth(token: string, password: string): void;
133
+ getAuthUrl(state?: string): string;
134
+ exchangeCode(code: string, password: string): Promise<User>;
135
+ isAuthenticated(): boolean;
136
+ logout(): void;
137
+
138
+ // Data operations with offline support
139
+ put<T = unknown>(path: string, data: T): Promise<PutResponse | { queued: boolean; path: string }>;
140
+ get<T = unknown>(path: string): Promise<T>;
141
+ delete(path: string): Promise<DeleteResponse | { queued: boolean; path: string }>;
142
+ list(): Promise<FileInfo[]>;
143
+
144
+ // Metadata/entitlements (no offline caching)
145
+ getMetadata<T extends Metadata = Metadata>(): Promise<T>;
146
+ setMetadata<T extends Metadata = Metadata>(metadata: T): Promise<T>;
147
+ updateMetadata<T extends Metadata = Metadata>(metadata: Partial<T>): Promise<T>;
148
+ getEntitlements<T extends Entitlements = Entitlements>(): Promise<T>;
149
+ getQuota(): Promise<QuotaInfo>;
150
+ getUser(): Promise<User>;
151
+
152
+ // Sync control
153
+ startAutoSync(): void;
154
+ stopAutoSync(): void;
155
+ syncPending(): Promise<void>;
156
+ hasPendingChanges(): boolean;
157
+ pendingCount(): number;
158
+ getStore(): OfflineStore;
159
+ }
160
+
161
+ export declare function createOfflineClient(baseClient: SyncVault, options?: OfflineOptions): OfflineSyncVault;
package/src/index.js CHANGED
@@ -280,3 +280,4 @@ export class SyncVault {
280
280
  }
281
281
 
282
282
  export { encrypt, decrypt } from './crypto.js';
283
+ export { OfflineSyncVault, OfflineStore, createOfflineClient } from './offline.js';
package/src/offline.js ADDED
@@ -0,0 +1,434 @@
1
+ import { encrypt, decrypt } from './crypto.js';
2
+
3
+ const DEFAULT_STORE_NAME = 'syncvault-offline';
4
+ const CACHE_KEY = 'cache';
5
+ const QUEUE_KEY = 'queue';
6
+
7
+ /**
8
+ * Storage adapter interface
9
+ */
10
+ class MemoryStorage {
11
+ constructor() {
12
+ this.data = new Map();
13
+ }
14
+
15
+ async get(key) {
16
+ return this.data.get(key) || null;
17
+ }
18
+
19
+ async set(key, value) {
20
+ this.data.set(key, value);
21
+ }
22
+
23
+ async remove(key) {
24
+ this.data.delete(key);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * LocalStorage adapter (browser)
30
+ */
31
+ class LocalStorageAdapter {
32
+ constructor(prefix = DEFAULT_STORE_NAME) {
33
+ this.prefix = prefix;
34
+ }
35
+
36
+ async get(key) {
37
+ const item = localStorage.getItem(`${this.prefix}:${key}`);
38
+ return item ? JSON.parse(item) : null;
39
+ }
40
+
41
+ async set(key, value) {
42
+ localStorage.setItem(`${this.prefix}:${key}`, JSON.stringify(value));
43
+ }
44
+
45
+ async remove(key) {
46
+ localStorage.removeItem(`${this.prefix}:${key}`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Offline store for caching and queue management
52
+ */
53
+ export class OfflineStore {
54
+ constructor(storage = null) {
55
+ this.storage = storage || this._detectStorage();
56
+ this.cache = {};
57
+ this.queue = [];
58
+ this.loaded = false;
59
+ }
60
+
61
+ _detectStorage() {
62
+ if (typeof localStorage !== 'undefined') {
63
+ return new LocalStorageAdapter();
64
+ }
65
+ return new MemoryStorage();
66
+ }
67
+
68
+ async load() {
69
+ if (this.loaded) return;
70
+
71
+ const cache = await this.storage.get(CACHE_KEY);
72
+ const queue = await this.storage.get(QUEUE_KEY);
73
+
74
+ this.cache = cache || {};
75
+ this.queue = queue || [];
76
+ this.loaded = true;
77
+ }
78
+
79
+ async persist() {
80
+ await this.storage.set(CACHE_KEY, this.cache);
81
+ await this.storage.set(QUEUE_KEY, this.queue);
82
+ }
83
+
84
+ getCached(path) {
85
+ return this.cache[path] || null;
86
+ }
87
+
88
+ async setCache(path, data) {
89
+ this.cache[path] = {
90
+ path,
91
+ data,
92
+ updatedAt: Date.now()
93
+ };
94
+ await this.persist();
95
+ }
96
+
97
+ async removeCache(path) {
98
+ delete this.cache[path];
99
+ await this.persist();
100
+ }
101
+
102
+ async queueOperation(op) {
103
+ op.id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
104
+ op.createdAt = Date.now();
105
+ op.retries = 0;
106
+ this.queue.push(op);
107
+ await this.persist();
108
+ }
109
+
110
+ getPendingOperations() {
111
+ return [...this.queue];
112
+ }
113
+
114
+ async removeOperation(id) {
115
+ this.queue = this.queue.filter(op => op.id !== id);
116
+ await this.persist();
117
+ }
118
+
119
+ async incrementRetry(id) {
120
+ const op = this.queue.find(op => op.id === id);
121
+ if (op) {
122
+ op.retries++;
123
+ await this.persist();
124
+ }
125
+ }
126
+
127
+ hasPendingOperations() {
128
+ return this.queue.length > 0;
129
+ }
130
+
131
+ async clearQueue() {
132
+ this.queue = [];
133
+ await this.persist();
134
+ }
135
+
136
+ async clearCache() {
137
+ this.cache = {};
138
+ await this.persist();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Check if error is network-related
144
+ */
145
+ function isNetworkError(error) {
146
+ if (!error) return false;
147
+ const msg = error.message?.toLowerCase() || '';
148
+ return (
149
+ error.name === 'TypeError' ||
150
+ msg.includes('network') ||
151
+ msg.includes('failed to fetch') ||
152
+ msg.includes('load failed') ||
153
+ msg.includes('offline') ||
154
+ msg.includes('timeout') ||
155
+ msg.includes('econnrefused') ||
156
+ msg.includes('enotfound')
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Offline-capable SyncVault client
162
+ */
163
+ export class OfflineSyncVault {
164
+ constructor(baseClient, options = {}) {
165
+ this.client = baseClient;
166
+ this.store = new OfflineStore(options.storage);
167
+ this.retryInterval = options.retryInterval || 30000;
168
+ this.maxRetries = options.maxRetries || 10;
169
+ this.autoSync = options.autoSync !== false;
170
+
171
+ this.onSyncSuccess = null;
172
+ this.onSyncError = null;
173
+
174
+ this._syncTimer = null;
175
+ this._initialized = false;
176
+ }
177
+
178
+ async init() {
179
+ if (this._initialized) return;
180
+ await this.store.load();
181
+ this._initialized = true;
182
+
183
+ if (this.autoSync) {
184
+ this.startAutoSync();
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Proxy auth methods to base client
190
+ */
191
+ async auth(username, password) {
192
+ await this.init();
193
+ return this.client.auth(username, password);
194
+ }
195
+
196
+ async register(username, password) {
197
+ await this.init();
198
+ return this.client.register(username, password);
199
+ }
200
+
201
+ setAuth(token, password) {
202
+ this.client.setAuth(token, password);
203
+ }
204
+
205
+ getAuthUrl(state) {
206
+ return this.client.getAuthUrl(state);
207
+ }
208
+
209
+ async exchangeCode(code, password) {
210
+ return this.client.exchangeCode(code, password);
211
+ }
212
+
213
+ isAuthenticated() {
214
+ return this.client.isAuthenticated();
215
+ }
216
+
217
+ logout() {
218
+ this.client.logout();
219
+ }
220
+
221
+ /**
222
+ * Put with offline support
223
+ */
224
+ async put(path, data) {
225
+ await this.init();
226
+
227
+ const encrypted = await encrypt(data, this.client.password);
228
+
229
+ try {
230
+ const result = await this.client.put(path, data);
231
+ await this.store.setCache(path, encrypted);
232
+ return result;
233
+ } catch (error) {
234
+ if (isNetworkError(error)) {
235
+ await this.store.setCache(path, encrypted);
236
+ await this.store.queueOperation({
237
+ type: 'put',
238
+ path,
239
+ data: encrypted
240
+ });
241
+ return { queued: true, path };
242
+ }
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get with offline fallback
249
+ */
250
+ async get(path) {
251
+ await this.init();
252
+
253
+ try {
254
+ const result = await this.client.get(path);
255
+ // Update cache with fresh data
256
+ const encrypted = await encrypt(result, this.client.password);
257
+ await this.store.setCache(path, encrypted);
258
+ return result;
259
+ } catch (error) {
260
+ if (isNetworkError(error)) {
261
+ const cached = this.store.getCached(path);
262
+ if (cached) {
263
+ return decrypt(cached.data, this.client.password);
264
+ }
265
+ throw new Error('Offline and no cached data available');
266
+ }
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Delete with offline support
273
+ */
274
+ async delete(path) {
275
+ await this.init();
276
+
277
+ try {
278
+ const result = await this.client.delete(path);
279
+ await this.store.removeCache(path);
280
+ return result;
281
+ } catch (error) {
282
+ if (isNetworkError(error)) {
283
+ await this.store.removeCache(path);
284
+ await this.store.queueOperation({
285
+ type: 'delete',
286
+ path
287
+ });
288
+ return { queued: true, path };
289
+ }
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * List files (no offline caching for list)
296
+ */
297
+ async list() {
298
+ return this.client.list();
299
+ }
300
+
301
+ /**
302
+ * Proxy metadata/entitlements (no offline for these)
303
+ */
304
+ async getMetadata() {
305
+ return this.client.getMetadata();
306
+ }
307
+
308
+ async setMetadata(metadata) {
309
+ return this.client.setMetadata(metadata);
310
+ }
311
+
312
+ async updateMetadata(metadata) {
313
+ return this.client.updateMetadata(metadata);
314
+ }
315
+
316
+ async getEntitlements() {
317
+ return this.client.getEntitlements();
318
+ }
319
+
320
+ async getQuota() {
321
+ return this.client.getQuota();
322
+ }
323
+
324
+ async getUser() {
325
+ return this.client.getUser();
326
+ }
327
+
328
+ /**
329
+ * Start automatic sync of pending operations
330
+ */
331
+ startAutoSync() {
332
+ if (this._syncTimer) return;
333
+
334
+ this._syncTimer = setInterval(() => {
335
+ this.syncPending();
336
+ }, this.retryInterval);
337
+
338
+ // Also listen for online event in browser
339
+ if (typeof window !== 'undefined') {
340
+ window.addEventListener('online', () => this.syncPending());
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Stop automatic sync
346
+ */
347
+ stopAutoSync() {
348
+ if (this._syncTimer) {
349
+ clearInterval(this._syncTimer);
350
+ this._syncTimer = null;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Manually sync pending operations
356
+ */
357
+ async syncPending() {
358
+ const ops = this.store.getPendingOperations();
359
+
360
+ for (const op of ops) {
361
+ if (op.retries >= this.maxRetries) {
362
+ await this.store.removeOperation(op.id);
363
+ if (this.onSyncError) {
364
+ this.onSyncError(op, new Error('Max retries exceeded'));
365
+ }
366
+ continue;
367
+ }
368
+
369
+ try {
370
+ if (op.type === 'put') {
371
+ await this._syncPut(op);
372
+ } else if (op.type === 'delete') {
373
+ await this._syncDelete(op);
374
+ }
375
+
376
+ await this.store.removeOperation(op.id);
377
+ if (this.onSyncSuccess) {
378
+ this.onSyncSuccess(op);
379
+ }
380
+ } catch (error) {
381
+ if (isNetworkError(error)) {
382
+ await this.store.incrementRetry(op.id);
383
+ } else {
384
+ await this.store.removeOperation(op.id);
385
+ if (this.onSyncError) {
386
+ this.onSyncError(op, error);
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ async _syncPut(op) {
394
+ await this.client._request('/api/sync/put', {
395
+ method: 'POST',
396
+ body: JSON.stringify({ path: op.path, data: op.data })
397
+ });
398
+ }
399
+
400
+ async _syncDelete(op) {
401
+ await this.client._request('/api/sync/delete', {
402
+ method: 'POST',
403
+ body: JSON.stringify({ path: op.path })
404
+ });
405
+ }
406
+
407
+ /**
408
+ * Check if there are pending changes
409
+ */
410
+ hasPendingChanges() {
411
+ return this.store.hasPendingOperations();
412
+ }
413
+
414
+ /**
415
+ * Get number of pending operations
416
+ */
417
+ pendingCount() {
418
+ return this.store.getPendingOperations().length;
419
+ }
420
+
421
+ /**
422
+ * Get the offline store for direct access
423
+ */
424
+ getStore() {
425
+ return this.store;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Create an offline-capable client
431
+ */
432
+ export function createOfflineClient(baseClient, options = {}) {
433
+ return new OfflineSyncVault(baseClient, options);
434
+ }