@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 +143 -14
- package/package.json +1 -1
- package/src/index.d.ts +102 -1
- package/src/index.js +35 -1
- package/src/offline.js +434 -0
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
|
-
|
|
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
|
|
113
|
+
Get preferences for the current user.
|
|
114
114
|
|
|
115
115
|
#### `vault.setMetadata(metadata)`
|
|
116
|
-
Set
|
|
116
|
+
Set preferences (replaces all existing).
|
|
117
117
|
|
|
118
118
|
#### `vault.updateMetadata(metadata)`
|
|
119
|
-
Update
|
|
119
|
+
Update preferences (merges with existing).
|
|
120
120
|
|
|
121
121
|
```javascript
|
|
122
|
-
// Example: Store
|
|
122
|
+
// Example: Store user preferences
|
|
123
123
|
await vault.setMetadata({
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
theme: 'dark',
|
|
125
|
+
timezone: 'UTC',
|
|
126
|
+
language: 'en'
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
// Read
|
|
130
|
-
const
|
|
131
|
-
console.log(
|
|
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({
|
|
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
|
|
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
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
|
|
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
|
|
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
|
+
}
|