@syncvault/sdk 1.0.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
@@ -105,33 +105,59 @@ List all files for this app.
105
105
  #### `vault.delete(path)`
106
106
  Delete a file.
107
107
 
108
- ### Metadata Methods
108
+ ### Metadata Methods (Preferences)
109
109
 
110
- App metadata is unencrypted data stored server-side. Use it for app-specific logic like subscription status, feature flags, or user preferences that don't need encryption.
110
+ Metadata is unencrypted data for app preferences like theme, timezone, language. Use it for settings that don't need encryption and are needed for app logic.
111
111
 
112
112
  #### `vault.getMetadata()`
113
- Get app metadata for the current user.
113
+ Get preferences for the current user.
114
114
 
115
115
  #### `vault.setMetadata(metadata)`
116
- Set app metadata (replaces all existing metadata).
116
+ Set preferences (replaces all existing).
117
117
 
118
118
  #### `vault.updateMetadata(metadata)`
119
- Update app metadata (merges with existing metadata).
119
+ Update preferences (merges with existing).
120
120
 
121
121
  ```javascript
122
- // Example: Store subscription status
122
+ // Example: Store user preferences
123
123
  await vault.setMetadata({
124
- subscriptionActive: true,
125
- subscriptionExpiresAt: '2026-12-31',
126
- plan: 'premium'
124
+ theme: 'dark',
125
+ timezone: 'UTC',
126
+ language: 'en'
127
127
  });
128
128
 
129
- // Read metadata
130
- const meta = await vault.getMetadata();
131
- console.log(meta.subscriptionActive); // true
129
+ // Read preferences
130
+ const prefs = await vault.getMetadata();
131
+ console.log(prefs.theme); // 'dark'
132
132
 
133
133
  // Update specific fields
134
- await vault.updateMetadata({ lastLogin: new Date().toISOString() });
134
+ await vault.updateMetadata({ language: 'es' });
135
+ ```
136
+
137
+ ### Entitlements Methods
138
+
139
+ Entitlements are read-only data set by the developer's backend. Use them for subscription status, feature flags, etc. Users can read but not modify entitlements.
140
+
141
+ #### `vault.getEntitlements()`
142
+ Get entitlements for the current user.
143
+
144
+ ```javascript
145
+ // Read entitlements (set by developer backend)
146
+ const entitlements = await vault.getEntitlements();
147
+ console.log(entitlements.plan); // 'premium'
148
+ console.log(entitlements.features); // ['advanced', 'export']
149
+ ```
150
+
151
+ ### Quota Methods
152
+
153
+ #### `vault.getQuota()`
154
+ Get user's storage quota information.
155
+
156
+ ```javascript
157
+ const quota = await vault.getQuota();
158
+ console.log(quota.quotaBytes); // 10485760 (10MB) or null if unlimited
159
+ console.log(quota.usedBytes); // 1048576 (1MB)
160
+ console.log(quota.unlimited); // false
135
161
  ```
136
162
 
137
163
  ### State Methods
@@ -158,4 +184,107 @@ Users see these permissions during OAuth authorization.
158
184
 
159
185
  All data is encrypted client-side using AES-256-GCM with a key derived from the user's password using PBKDF2 (100,000 iterations). The server never sees unencrypted data.
160
186
 
161
- Note: Metadata is NOT encrypted - use it only for non-sensitive app logic.
187
+ Note: Metadata (preferences) and entitlements are NOT encrypted - use them only for non-sensitive settings and subscription status.
188
+
189
+ ## Setting Entitlements (Developer Backend)
190
+
191
+ Entitlements can only be set from your backend using both the app token and secret token:
192
+
193
+ ```javascript
194
+ // On your backend (e.g., after payment webhook)
195
+ await fetch(`https://api.syncvault.dev/api/entitlements/${userId}`, {
196
+ method: 'PUT',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'X-App-Token': process.env.SYNCVAULT_APP_TOKEN,
200
+ 'X-Secret-Token': process.env.SYNCVAULT_SECRET_TOKEN
201
+ },
202
+ body: JSON.stringify({
203
+ entitlements: {
204
+ plan: 'premium',
205
+ features: ['advanced', 'export'],
206
+ expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
207
+ }
208
+ })
209
+ });
210
+ ```
211
+
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.0.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
@@ -27,6 +27,14 @@ export interface DeleteResponse {
27
27
 
28
28
  export type Metadata = Record<string, unknown>;
29
29
 
30
+ export type Entitlements = Record<string, unknown>;
31
+
32
+ export interface QuotaInfo {
33
+ quotaBytes: number | null;
34
+ usedBytes: number;
35
+ unlimited: boolean;
36
+ }
37
+
30
38
  export declare class SyncVault {
31
39
  constructor(options: SyncVaultOptions);
32
40
 
@@ -45,11 +53,17 @@ export declare class SyncVault {
45
53
  list(): Promise<FileInfo[]>;
46
54
  delete(path: string): Promise<DeleteResponse>;
47
55
 
48
- // Metadata operations (unencrypted server-side data)
56
+ // Metadata operations (unencrypted, for app preferences like theme, timezone)
49
57
  getMetadata<T extends Metadata = Metadata>(): Promise<T>;
50
58
  setMetadata<T extends Metadata = Metadata>(metadata: T): Promise<T>;
51
59
  updateMetadata<T extends Metadata = Metadata>(metadata: Partial<T>): Promise<T>;
52
60
 
61
+ // Entitlements (read-only, set by developer's backend for subscriptions, feature flags)
62
+ getEntitlements<T extends Entitlements = Entitlements>(): Promise<T>;
63
+
64
+ // Quota info
65
+ getQuota(): Promise<QuotaInfo>;
66
+
53
67
  // State
54
68
  isAuthenticated(): boolean;
55
69
  logout(): void;
@@ -58,3 +72,90 @@ export declare class SyncVault {
58
72
 
59
73
  export declare function encrypt(data: unknown, password: string): Promise<string>;
60
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
@@ -2,6 +2,15 @@ import { encrypt, decrypt, prepareAuthPassword } from './crypto.js';
2
2
 
3
3
  const DEFAULT_SERVER = 'https://api.syncvault.dev';
4
4
 
5
+ export class SyncVaultError extends Error {
6
+ constructor(message, statusCode, data) {
7
+ super(message);
8
+ this.name = 'SyncVaultError';
9
+ this.statusCode = statusCode;
10
+ this.data = data;
11
+ }
12
+ }
13
+
5
14
  export class SyncVault {
6
15
  constructor(options = {}) {
7
16
  if (!options.appToken) {
@@ -186,6 +195,26 @@ export class SyncVault {
186
195
  return response.metadata;
187
196
  }
188
197
 
198
+ /**
199
+ * Get entitlements for current user (read-only, set by developer's backend)
200
+ * Entitlements are used for subscription status, feature flags, etc.
201
+ */
202
+ async getEntitlements() {
203
+ this._checkAuth();
204
+
205
+ const response = await this._request('/api/sync/entitlements');
206
+ return response.entitlements;
207
+ }
208
+
209
+ /**
210
+ * Get user storage quota info for the current app
211
+ */
212
+ async getQuota() {
213
+ this._checkAuth();
214
+
215
+ return this._request('/api/sync/quota');
216
+ }
217
+
189
218
  /**
190
219
  * Check if user is authenticated
191
220
  */
@@ -239,7 +268,11 @@ export class SyncVault {
239
268
  const data = await response.json();
240
269
 
241
270
  if (!response.ok) {
242
- throw new Error(data.error || 'Request failed');
271
+ throw new SyncVaultError(
272
+ data.error || 'Request failed',
273
+ response.status,
274
+ data
275
+ );
243
276
  }
244
277
 
245
278
  return data;
@@ -247,3 +280,4 @@ export class SyncVault {
247
280
  }
248
281
 
249
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
+ }