@syncvault/sdk 1.2.0 → 1.4.1
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/package.json +7 -3
- package/src/crypto.js +69 -0
- package/src/index.d.ts +23 -1
- package/src/index.js +221 -4
- package/src/offline.js +30 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncvault/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "SyncVault SDK - Zero-knowledge encrypted sync for Node.js and browsers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"src"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node test/test.js"
|
|
21
|
+
"test": "node test/test.js",
|
|
22
|
+
"build": "esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --minify --sourcemap"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"syncvault",
|
|
@@ -45,5 +46,8 @@
|
|
|
45
46
|
"engines": {
|
|
46
47
|
"node": ">=18.0.0"
|
|
47
48
|
},
|
|
48
|
-
"sideEffects": false
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"esbuild": "^0.27.2"
|
|
52
|
+
}
|
|
49
53
|
}
|
package/src/crypto.js
CHANGED
|
@@ -137,3 +137,72 @@ function base64ToBuffer(base64) {
|
|
|
137
137
|
}
|
|
138
138
|
return bytes;
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Decrypt data that was encrypted by an app server using hybrid encryption.
|
|
143
|
+
* The server encrypts an AES key with RSA-OAEP, then encrypts the data with AES-GCM.
|
|
144
|
+
*
|
|
145
|
+
* Format: encryptedAESKey (256 bytes) + iv (12 bytes) + authTag (16 bytes) + ciphertext
|
|
146
|
+
*
|
|
147
|
+
* @param {string} encryptedBase64 - Base64 encoded encrypted package
|
|
148
|
+
* @param {string} privateKeyBase64 - User's RSA private key in base64 (PKCS8 format)
|
|
149
|
+
*/
|
|
150
|
+
export async function decryptFromServer(encryptedBase64, privateKeyBase64) {
|
|
151
|
+
try {
|
|
152
|
+
const combined = base64ToBuffer(encryptedBase64);
|
|
153
|
+
|
|
154
|
+
const RSA_KEY_SIZE = 256;
|
|
155
|
+
const AES_IV_LENGTH = 12;
|
|
156
|
+
const AUTH_TAG_LENGTH = 16;
|
|
157
|
+
|
|
158
|
+
const minLength = RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH;
|
|
159
|
+
if (combined.length < minLength) {
|
|
160
|
+
throw new Error(`Invalid encrypted data: too short (${combined.length} bytes, need ${minLength})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const encryptedAESKey = combined.slice(0, RSA_KEY_SIZE);
|
|
164
|
+
const iv = combined.slice(RSA_KEY_SIZE, RSA_KEY_SIZE + AES_IV_LENGTH);
|
|
165
|
+
const authTag = combined.slice(RSA_KEY_SIZE + AES_IV_LENGTH, RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH);
|
|
166
|
+
const ciphertext = combined.slice(RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH);
|
|
167
|
+
|
|
168
|
+
const privateKeyBuffer = base64ToBuffer(privateKeyBase64);
|
|
169
|
+
|
|
170
|
+
const privateKey = await crypto.subtle.importKey(
|
|
171
|
+
'pkcs8',
|
|
172
|
+
privateKeyBuffer,
|
|
173
|
+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
|
174
|
+
false,
|
|
175
|
+
['decrypt']
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const rawAESKey = await crypto.subtle.decrypt(
|
|
179
|
+
{ name: 'RSA-OAEP' },
|
|
180
|
+
privateKey,
|
|
181
|
+
encryptedAESKey
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const aesKey = await crypto.subtle.importKey(
|
|
185
|
+
'raw',
|
|
186
|
+
rawAESKey,
|
|
187
|
+
{ name: 'AES-GCM' },
|
|
188
|
+
false,
|
|
189
|
+
['decrypt']
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const ciphertextWithTag = new Uint8Array(ciphertext.length + AUTH_TAG_LENGTH);
|
|
193
|
+
ciphertextWithTag.set(ciphertext, 0);
|
|
194
|
+
ciphertextWithTag.set(authTag, ciphertext.length);
|
|
195
|
+
|
|
196
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
197
|
+
{ name: 'AES-GCM', iv },
|
|
198
|
+
aesKey,
|
|
199
|
+
ciphertextWithTag
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
return JSON.parse(decoder.decode(decrypted));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error('[SDK decryptFromServer] Error:', err.message);
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -35,6 +35,20 @@ export interface QuotaInfo {
|
|
|
35
35
|
unlimited: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface SharedVault {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
ownerId: string;
|
|
42
|
+
ownerUsername: string;
|
|
43
|
+
memberCount: number;
|
|
44
|
+
isOwner: boolean;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PutOptions {
|
|
49
|
+
updatedAt?: number | Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
export declare class SyncVault {
|
|
39
53
|
constructor(options: SyncVaultOptions);
|
|
40
54
|
|
|
@@ -48,7 +62,7 @@ export declare class SyncVault {
|
|
|
48
62
|
register(username: string, password: string): Promise<User>;
|
|
49
63
|
|
|
50
64
|
// Data operations
|
|
51
|
-
put<T = unknown>(path: string, data: T): Promise<PutResponse>;
|
|
65
|
+
put<T = unknown>(path: string, data: T, options?: PutOptions): Promise<PutResponse>;
|
|
52
66
|
get<T = unknown>(path: string): Promise<T>;
|
|
53
67
|
list(): Promise<FileInfo[]>;
|
|
54
68
|
delete(path: string): Promise<DeleteResponse>;
|
|
@@ -64,6 +78,13 @@ export declare class SyncVault {
|
|
|
64
78
|
// Quota info
|
|
65
79
|
getQuota(): Promise<QuotaInfo>;
|
|
66
80
|
|
|
81
|
+
// Shared vaults
|
|
82
|
+
getSharedVaults(): Promise<SharedVault[]>;
|
|
83
|
+
listShared(vaultId: string): Promise<FileInfo[]>;
|
|
84
|
+
putShared<T = unknown>(vaultId: string, path: string, data: T, sharedPassword?: string): Promise<PutResponse>;
|
|
85
|
+
getShared<T = unknown>(vaultId: string, path: string, sharedPassword?: string): Promise<T>;
|
|
86
|
+
deleteShared(vaultId: string, path: string): Promise<DeleteResponse>;
|
|
87
|
+
|
|
67
88
|
// State
|
|
68
89
|
isAuthenticated(): boolean;
|
|
69
90
|
logout(): void;
|
|
@@ -79,6 +100,7 @@ export interface PendingOperation {
|
|
|
79
100
|
type: 'put' | 'delete';
|
|
80
101
|
path: string;
|
|
81
102
|
data?: string;
|
|
103
|
+
updatedAt?: number;
|
|
82
104
|
createdAt: number;
|
|
83
105
|
retries: number;
|
|
84
106
|
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { encrypt, decrypt, prepareAuthPassword } from './crypto.js';
|
|
1
|
+
import { encrypt, decrypt, prepareAuthPassword, decryptFromServer } from './crypto.js';
|
|
2
|
+
|
|
3
|
+
export { encrypt, decrypt, decryptFromServer };
|
|
2
4
|
|
|
3
5
|
const DEFAULT_SERVER = 'https://api.syncvault.dev';
|
|
4
6
|
|
|
@@ -107,15 +109,24 @@ export class SyncVault {
|
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
111
|
* Store encrypted data
|
|
112
|
+
* @param {string} path - File path
|
|
113
|
+
* @param {any} data - Data to encrypt and store
|
|
114
|
+
* @param {Object} options - Optional settings
|
|
115
|
+
* @param {number} options.updatedAt - Timestamp for LWW conflict resolution
|
|
110
116
|
*/
|
|
111
|
-
async put(path, data) {
|
|
117
|
+
async put(path, data, options = {}) {
|
|
112
118
|
this._checkAuth();
|
|
113
119
|
|
|
114
120
|
const encrypted = await encrypt(data, this.password);
|
|
121
|
+
const body = { path, data: encrypted };
|
|
122
|
+
|
|
123
|
+
if (options.updatedAt) {
|
|
124
|
+
body.updatedAt = new Date(options.updatedAt).toISOString();
|
|
125
|
+
}
|
|
115
126
|
|
|
116
127
|
const response = await this._request('/api/sync/put', {
|
|
117
128
|
method: 'POST',
|
|
118
|
-
body: JSON.stringify(
|
|
129
|
+
body: JSON.stringify(body)
|
|
119
130
|
});
|
|
120
131
|
|
|
121
132
|
return response;
|
|
@@ -215,6 +226,63 @@ export class SyncVault {
|
|
|
215
226
|
return this._request('/api/sync/quota');
|
|
216
227
|
}
|
|
217
228
|
|
|
229
|
+
// --- Shared Vaults ---
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get all shared vaults the user has access to in this app
|
|
233
|
+
*/
|
|
234
|
+
async getSharedVaults() {
|
|
235
|
+
this._checkAuth();
|
|
236
|
+
|
|
237
|
+
return this._request('/api/sync/shared/vaults');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* List files in a shared vault
|
|
242
|
+
*/
|
|
243
|
+
async listShared(vaultId) {
|
|
244
|
+
this._checkAuth();
|
|
245
|
+
|
|
246
|
+
const response = await this._request(`/api/sync/shared/${vaultId}/list`);
|
|
247
|
+
return response.files;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Store encrypted data in a shared vault
|
|
252
|
+
*/
|
|
253
|
+
async putShared(vaultId, path, data, sharedPassword) {
|
|
254
|
+
this._checkAuth();
|
|
255
|
+
|
|
256
|
+
const encrypted = await encrypt(data, sharedPassword || this.password);
|
|
257
|
+
|
|
258
|
+
return this._request(`/api/sync/shared/${vaultId}/put`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
body: JSON.stringify({ path, data: encrypted })
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Retrieve and decrypt data from a shared vault
|
|
266
|
+
*/
|
|
267
|
+
async getShared(vaultId, path, sharedPassword) {
|
|
268
|
+
this._checkAuth();
|
|
269
|
+
|
|
270
|
+
const response = await this._request(`/api/sync/shared/${vaultId}/get?path=${encodeURIComponent(path)}`);
|
|
271
|
+
return decrypt(response.data, sharedPassword || this.password);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Delete a file from a shared vault
|
|
276
|
+
*/
|
|
277
|
+
async deleteShared(vaultId, path) {
|
|
278
|
+
this._checkAuth();
|
|
279
|
+
|
|
280
|
+
return this._request(`/api/sync/shared/${vaultId}/delete`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
body: JSON.stringify({ path })
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
218
286
|
/**
|
|
219
287
|
* Check if user is authenticated
|
|
220
288
|
*/
|
|
@@ -279,5 +347,154 @@ export class SyncVault {
|
|
|
279
347
|
}
|
|
280
348
|
}
|
|
281
349
|
|
|
282
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Server-side client for app backends to write data on behalf of users.
|
|
352
|
+
* Requires server_write OAuth scope to be granted by users.
|
|
353
|
+
*
|
|
354
|
+
* Data is encrypted with the user's public key (RSA-OAEP),
|
|
355
|
+
* so only the user can decrypt it with their private key.
|
|
356
|
+
*/
|
|
357
|
+
export class SyncVaultServer {
|
|
358
|
+
constructor(options = {}) {
|
|
359
|
+
if (!options.appToken) {
|
|
360
|
+
throw new Error('appToken is required');
|
|
361
|
+
}
|
|
362
|
+
if (!options.secretToken) {
|
|
363
|
+
throw new Error('secretToken is required for server-side operations');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.appToken = options.appToken;
|
|
367
|
+
this.secretToken = options.secretToken;
|
|
368
|
+
this.serverUrl = options.serverUrl || DEFAULT_SERVER;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get user's public key for encryption
|
|
373
|
+
*/
|
|
374
|
+
async getUserPublicKey(userId) {
|
|
375
|
+
return this._request(`/api/server/user/${userId}/public-key`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Encrypt data with user's public key (RSA-OAEP + AES-GCM hybrid)
|
|
380
|
+
*/
|
|
381
|
+
async encryptForUser(data, publicKeyPem) {
|
|
382
|
+
// Parse PEM to get raw key bytes
|
|
383
|
+
const pemBody = publicKeyPem
|
|
384
|
+
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
|
385
|
+
.replace(/-----END PUBLIC KEY-----/, '')
|
|
386
|
+
.replace(/\s+/g, '');
|
|
387
|
+
const publicKeyBuffer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
|
|
388
|
+
|
|
389
|
+
const publicKey = await crypto.subtle.importKey(
|
|
390
|
+
'spki',
|
|
391
|
+
publicKeyBuffer,
|
|
392
|
+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
|
393
|
+
false,
|
|
394
|
+
['encrypt']
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const encoder = new TextEncoder();
|
|
398
|
+
const dataBuffer = encoder.encode(JSON.stringify(data));
|
|
399
|
+
|
|
400
|
+
// Generate random AES key
|
|
401
|
+
const aesKey = await crypto.subtle.generateKey(
|
|
402
|
+
{ name: 'AES-GCM', length: 256 },
|
|
403
|
+
true,
|
|
404
|
+
['encrypt']
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
408
|
+
const encryptedData = await crypto.subtle.encrypt(
|
|
409
|
+
{ name: 'AES-GCM', iv },
|
|
410
|
+
aesKey,
|
|
411
|
+
dataBuffer
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Web Crypto returns ciphertext with authTag appended
|
|
415
|
+
// We need to separate them for our format
|
|
416
|
+
const encryptedArray = new Uint8Array(encryptedData);
|
|
417
|
+
const authTag = encryptedArray.slice(-16);
|
|
418
|
+
const ciphertext = encryptedArray.slice(0, -16);
|
|
419
|
+
|
|
420
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
421
|
+
const encryptedAesKey = await crypto.subtle.encrypt(
|
|
422
|
+
{ name: 'RSA-OAEP' },
|
|
423
|
+
publicKey,
|
|
424
|
+
rawAesKey
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Pack: encryptedAesKey (256 bytes) + iv (12 bytes) + authTag (16 bytes) + ciphertext
|
|
428
|
+
const result = new Uint8Array(
|
|
429
|
+
encryptedAesKey.byteLength + iv.byteLength + authTag.byteLength + ciphertext.byteLength
|
|
430
|
+
);
|
|
431
|
+
let offset = 0;
|
|
432
|
+
result.set(new Uint8Array(encryptedAesKey), offset);
|
|
433
|
+
offset += encryptedAesKey.byteLength;
|
|
434
|
+
result.set(iv, offset);
|
|
435
|
+
offset += iv.byteLength;
|
|
436
|
+
result.set(authTag, offset);
|
|
437
|
+
offset += authTag.byteLength;
|
|
438
|
+
result.set(ciphertext, offset);
|
|
439
|
+
|
|
440
|
+
return btoa(String.fromCharCode(...result));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Write pre-encrypted data to user's storage
|
|
445
|
+
*/
|
|
446
|
+
async putForUser(userId, path, encryptedData) {
|
|
447
|
+
return this._request(`/api/server/user/${userId}/put`, {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body: JSON.stringify({ path, data: encryptedData })
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Encrypt and store data for user in one call
|
|
455
|
+
*/
|
|
456
|
+
async writeForUser(userId, path, data) {
|
|
457
|
+
const { publicKey } = await this.getUserPublicKey(userId);
|
|
458
|
+
const encrypted = await this.encryptForUser(data, publicKey);
|
|
459
|
+
return this.putForUser(userId, path, encrypted);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* List files in user's storage for this app
|
|
464
|
+
*/
|
|
465
|
+
async listForUser(userId) {
|
|
466
|
+
return this._request(`/api/server/user/${userId}/list`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async _request(path, options = {}) {
|
|
470
|
+
const url = `${this.serverUrl}${path}`;
|
|
471
|
+
|
|
472
|
+
const headers = {
|
|
473
|
+
'Content-Type': 'application/json',
|
|
474
|
+
'X-App-Token': this.appToken,
|
|
475
|
+
'X-Secret-Token': this.secretToken
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const response = await fetch(url, {
|
|
479
|
+
...options,
|
|
480
|
+
headers: {
|
|
481
|
+
...headers,
|
|
482
|
+
...options.headers
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const responseData = await response.json();
|
|
487
|
+
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
throw new SyncVaultError(
|
|
490
|
+
responseData.error || 'Request failed',
|
|
491
|
+
response.status,
|
|
492
|
+
responseData
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return responseData;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
283
500
|
export { OfflineSyncVault, OfflineStore, createOfflineClient } from './offline.js';
|
package/src/offline.js
CHANGED
|
@@ -219,15 +219,16 @@ export class OfflineSyncVault {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
/**
|
|
222
|
-
* Put with offline support
|
|
222
|
+
* Put with offline support (LWW enabled)
|
|
223
223
|
*/
|
|
224
224
|
async put(path, data) {
|
|
225
225
|
await this.init();
|
|
226
226
|
|
|
227
|
+
const timestamp = Date.now();
|
|
227
228
|
const encrypted = await encrypt(data, this.client.password);
|
|
228
229
|
|
|
229
230
|
try {
|
|
230
|
-
const result = await this.client.put(path, data);
|
|
231
|
+
const result = await this.client.put(path, data, { updatedAt: timestamp });
|
|
231
232
|
await this.store.setCache(path, encrypted);
|
|
232
233
|
return result;
|
|
233
234
|
} catch (error) {
|
|
@@ -236,7 +237,8 @@ export class OfflineSyncVault {
|
|
|
236
237
|
await this.store.queueOperation({
|
|
237
238
|
type: 'put',
|
|
238
239
|
path,
|
|
239
|
-
data: encrypted
|
|
240
|
+
data: encrypted,
|
|
241
|
+
updatedAt: timestamp
|
|
240
242
|
});
|
|
241
243
|
return { queued: true, path };
|
|
242
244
|
}
|
|
@@ -391,10 +393,31 @@ export class OfflineSyncVault {
|
|
|
391
393
|
}
|
|
392
394
|
|
|
393
395
|
async _syncPut(op) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
body
|
|
397
|
-
}
|
|
396
|
+
const body = { path: op.path, data: op.data };
|
|
397
|
+
if (op.updatedAt) {
|
|
398
|
+
body.updatedAt = new Date(op.updatedAt).toISOString();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
await this.client._request('/api/sync/put', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
body: JSON.stringify(body)
|
|
405
|
+
});
|
|
406
|
+
} catch (error) {
|
|
407
|
+
// LWW conflict - server has newer data, discard local change
|
|
408
|
+
if (error.statusCode === 409 && error.data?.code === 'CONFLICT_STALE') {
|
|
409
|
+
// Fetch fresh data from server to update cache
|
|
410
|
+
try {
|
|
411
|
+
const result = await this.client.get(op.path);
|
|
412
|
+
const encrypted = await encrypt(result, this.client.password);
|
|
413
|
+
await this.store.setCache(op.path, encrypted);
|
|
414
|
+
} catch (cacheErr) {
|
|
415
|
+
console.warn('Failed to update cache after LWW conflict:', cacheErr.message);
|
|
416
|
+
}
|
|
417
|
+
return; // Consider this "synced" - we accepted server's version
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
398
421
|
}
|
|
399
422
|
|
|
400
423
|
async _syncDelete(op) {
|