@verdant-web/store 3.2.1 → 3.3.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/dist/bundle/index.js +6 -6
- package/dist/bundle/index.js.map +3 -3
- package/dist/cjs/backup.d.ts +10 -0
- package/dist/cjs/backup.js +58 -0
- package/dist/cjs/backup.js.map +1 -0
- package/dist/cjs/client/Client.d.ts +28 -8
- package/dist/cjs/client/Client.js +76 -16
- package/dist/cjs/client/Client.js.map +1 -1
- package/dist/cjs/files/FileManager.d.ts +2 -0
- package/dist/cjs/files/FileManager.js +25 -0
- package/dist/cjs/files/FileManager.js.map +1 -1
- package/dist/cjs/files/FileStorage.d.ts +11 -0
- package/dist/cjs/files/FileStorage.js +5 -0
- package/dist/cjs/files/FileStorage.js.map +1 -1
- package/dist/cjs/sync/PushPullSync.d.ts +3 -1
- package/dist/cjs/sync/PushPullSync.js +3 -2
- package/dist/cjs/sync/PushPullSync.js.map +1 -1
- package/dist/cjs/sync/ServerSyncEndpointProvider.d.ts +8 -2
- package/dist/cjs/sync/ServerSyncEndpointProvider.js +2 -1
- package/dist/cjs/sync/ServerSyncEndpointProvider.js.map +1 -1
- package/dist/cjs/sync/Sync.d.ts +12 -4
- package/dist/cjs/sync/Sync.js +3 -1
- package/dist/cjs/sync/Sync.js.map +1 -1
- package/dist/esm/backup.d.ts +10 -0
- package/dist/esm/backup.js +49 -0
- package/dist/esm/backup.js.map +1 -0
- package/dist/esm/client/Client.d.ts +28 -8
- package/dist/esm/client/Client.js +76 -16
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/files/FileManager.d.ts +2 -0
- package/dist/esm/files/FileManager.js +26 -1
- package/dist/esm/files/FileManager.js.map +1 -1
- package/dist/esm/files/FileStorage.d.ts +11 -0
- package/dist/esm/files/FileStorage.js +5 -0
- package/dist/esm/files/FileStorage.js.map +1 -1
- package/dist/esm/sync/PushPullSync.d.ts +3 -1
- package/dist/esm/sync/PushPullSync.js +3 -2
- package/dist/esm/sync/PushPullSync.js.map +1 -1
- package/dist/esm/sync/ServerSyncEndpointProvider.d.ts +8 -2
- package/dist/esm/sync/ServerSyncEndpointProvider.js +2 -1
- package/dist/esm/sync/ServerSyncEndpointProvider.js.map +1 -1
- package/dist/esm/sync/Sync.d.ts +12 -4
- package/dist/esm/sync/Sync.js +3 -1
- package/dist/esm/sync/Sync.js.map +1 -1
- package/dist/tsconfig-cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -1
- package/src/backup.ts +60 -0
- package/src/client/Client.ts +105 -17
- package/src/files/FileManager.ts +35 -1
- package/src/files/FileStorage.ts +7 -1
- package/src/sync/PushPullSync.ts +5 -1
- package/src/sync/ServerSyncEndpointProvider.ts +10 -3
- package/src/sync/Sync.ts +15 -7
package/src/client/Client.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { EntityStore } from '../entities/EntityStore.js';
|
|
|
21
21
|
import { NoSync, ServerSync, ServerSyncOptions, Sync } from '../sync/Sync.js';
|
|
22
22
|
import { CollectionQueries } from '../queries/CollectionQueries.js';
|
|
23
23
|
import { QueryCache } from '../queries/QueryCache.js';
|
|
24
|
+
import { ReturnedFileData } from '../files/FileStorage.js';
|
|
24
25
|
|
|
25
26
|
interface ClientConfig<Presence = any> {
|
|
26
27
|
syncConfig?: ServerSyncOptions<Presence>;
|
|
@@ -182,13 +183,6 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
182
183
|
return this.context.undoHistory;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
/**
|
|
186
|
-
* @deprecated - use client.sync.presence instead
|
|
187
|
-
*/
|
|
188
|
-
get presence() {
|
|
189
|
-
return this.sync.presence;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
186
|
/**
|
|
193
187
|
* Batch multiple operations together to be executed in a single transaction.
|
|
194
188
|
* The changes made will not be included in the same undo history step as
|
|
@@ -276,21 +270,88 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
276
270
|
await deleteAllDatabases(this.namespace, indexedDB);
|
|
277
271
|
};
|
|
278
272
|
|
|
279
|
-
export = async (
|
|
273
|
+
export = async (
|
|
274
|
+
{ downloadRemoteFiles }: { downloadRemoteFiles?: boolean } = {
|
|
275
|
+
downloadRemoteFiles: true,
|
|
276
|
+
},
|
|
277
|
+
) => {
|
|
278
|
+
this.context.log('info', 'Exporting data...');
|
|
280
279
|
const metaExport = await this.meta.export();
|
|
281
|
-
|
|
280
|
+
const filesExport = await this._fileManager.exportAll(downloadRemoteFiles);
|
|
281
|
+
// split files into data and files
|
|
282
|
+
const fileData: Array<Omit<ReturnedFileData, 'file'>> = [];
|
|
283
|
+
const files: Array<File> = [];
|
|
284
|
+
|
|
285
|
+
for (const fileExport of filesExport) {
|
|
286
|
+
const file = fileExport.file;
|
|
287
|
+
delete fileExport.file;
|
|
288
|
+
fileData.push(fileExport);
|
|
289
|
+
if (file) {
|
|
290
|
+
// rename with ID
|
|
291
|
+
const asFile = new File(
|
|
292
|
+
[file],
|
|
293
|
+
this.getFileExportName(fileExport.name, fileExport.id),
|
|
294
|
+
{
|
|
295
|
+
type: fileExport.type,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
files.push(asFile);
|
|
299
|
+
} else {
|
|
300
|
+
this.context.log(
|
|
301
|
+
'warn',
|
|
302
|
+
`File ${fileExport.id} was could not be loaded locally or from the server. It will be missing in the export.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
data: metaExport,
|
|
308
|
+
fileData,
|
|
309
|
+
files,
|
|
310
|
+
};
|
|
282
311
|
};
|
|
283
312
|
|
|
284
|
-
|
|
285
|
-
|
|
313
|
+
private getFileExportName = (originalFileName: string, id: string) => {
|
|
314
|
+
return `${id}___${originalFileName}`;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
private parseFileExportname = (name: string) => {
|
|
318
|
+
const [id, originalFileName] = name.split('___');
|
|
319
|
+
return { id, originalFileName };
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
import = async ({
|
|
323
|
+
data,
|
|
324
|
+
fileData,
|
|
325
|
+
files,
|
|
326
|
+
}: {
|
|
327
|
+
data: ExportData;
|
|
328
|
+
fileData: Array<Omit<ReturnedFileData, 'file'>>;
|
|
329
|
+
files: File[];
|
|
330
|
+
}) => {
|
|
331
|
+
this.context.log('info', 'Importing data...');
|
|
286
332
|
// close the document DB
|
|
287
333
|
await closeDatabase(this.context.documentDb);
|
|
288
334
|
|
|
289
|
-
|
|
290
|
-
|
|
335
|
+
await this.meta.resetFrom(data);
|
|
336
|
+
// re-attach files to their file data and import
|
|
337
|
+
const fileToIdMap = new Map(
|
|
338
|
+
files.map((file) => {
|
|
339
|
+
const { id } = this.parseFileExportname(file.name);
|
|
340
|
+
return [id, file];
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
const importedFiles: ReturnedFileData[] = fileData.map((fileData) => {
|
|
344
|
+
const file = fileToIdMap.get(fileData.id);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
...fileData,
|
|
348
|
+
file,
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
await this._fileManager.importAll(importedFiles);
|
|
291
352
|
// now delete the document DB, open it to the specified version
|
|
292
353
|
// and run migrations to get it to the latest version
|
|
293
|
-
const version =
|
|
354
|
+
const version = data.schema.version;
|
|
294
355
|
const deleteReq = indexedDB.deleteDatabase(
|
|
295
356
|
[this.namespace, 'collections'].join('_'),
|
|
296
357
|
);
|
|
@@ -300,7 +361,18 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
300
361
|
});
|
|
301
362
|
// reset our context to the imported schema for now
|
|
302
363
|
const currentSchema = this.context.schema;
|
|
303
|
-
|
|
364
|
+
if (currentSchema.version !== version) {
|
|
365
|
+
// TODO: support importing older schema data - this will
|
|
366
|
+
// require being able to migrate that data, which requires
|
|
367
|
+
// a "live" schema for that version. the client does not currently
|
|
368
|
+
// receive historical schemas, although they should be available
|
|
369
|
+
// if the CLI was used.
|
|
370
|
+
// importing from older versions is also tricky because
|
|
371
|
+
// migration shortcuts mean that versions could get marooned.
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Only exports from the current schema version can be imported`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
304
376
|
// now open the document DB empty at the specified version
|
|
305
377
|
// and initialize it from the meta DB
|
|
306
378
|
this.context.documentDb = await openDocumentDatabase({
|
|
@@ -313,8 +385,8 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
313
385
|
// re-initialize data
|
|
314
386
|
this.context.log('Re-initializing data from imported data...');
|
|
315
387
|
await this._entities.addData({
|
|
316
|
-
operations:
|
|
317
|
-
baselines:
|
|
388
|
+
operations: data.operations,
|
|
389
|
+
baselines: data.baselines,
|
|
318
390
|
reset: true,
|
|
319
391
|
});
|
|
320
392
|
// close the database and reopen to latest version, applying
|
|
@@ -331,4 +403,20 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
331
403
|
});
|
|
332
404
|
this.context.internalEvents.emit('documentDbChanged', this.documentDb);
|
|
333
405
|
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Export all data, then re-import it. This might resolve
|
|
409
|
+
* some issues with the local database, but it should
|
|
410
|
+
* only be done as a second-to-last resort. The last resort
|
|
411
|
+
* would be __dangerous__resetLocal on ClientDescriptor, which
|
|
412
|
+
* clears all local data.
|
|
413
|
+
*
|
|
414
|
+
* Unlike __dangerous__resetLocal, this method allows local-only
|
|
415
|
+
* clients to recover data, whereas __dangerous__resetLocal only
|
|
416
|
+
* lets networked clients recover from the server.
|
|
417
|
+
*/
|
|
418
|
+
__dangerous__hardReset = async () => {
|
|
419
|
+
const exportData = await this.export();
|
|
420
|
+
await this.import(exportData);
|
|
421
|
+
};
|
|
334
422
|
}
|
package/src/files/FileManager.ts
CHANGED
|
@@ -3,7 +3,11 @@ import { Context } from '../context.js';
|
|
|
3
3
|
import { Metadata } from '../metadata/Metadata.js';
|
|
4
4
|
import { Sync } from '../sync/Sync.js';
|
|
5
5
|
import { EntityFile, MARK_FAILED, UPDATE } from './EntityFile.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
FileStorage,
|
|
8
|
+
ReturnedFileData,
|
|
9
|
+
StoredFileData,
|
|
10
|
+
} from './FileStorage.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Default: if file was deleted > 3 days ago
|
|
@@ -153,6 +157,36 @@ export class FileManager {
|
|
|
153
157
|
return this.storage.listUnsynced();
|
|
154
158
|
};
|
|
155
159
|
|
|
160
|
+
exportAll = async (downloadRemote = false) => {
|
|
161
|
+
const storedFiles = await this.storage.getAll();
|
|
162
|
+
if (downloadRemote) {
|
|
163
|
+
for (const storedFile of storedFiles) {
|
|
164
|
+
// if it doesn't have a buffer, we need to read
|
|
165
|
+
// one from the server
|
|
166
|
+
if (!storedFile.file && storedFile.url) {
|
|
167
|
+
try {
|
|
168
|
+
const blob = await fetch(storedFile.url, {
|
|
169
|
+
method: 'GET',
|
|
170
|
+
credentials: 'include',
|
|
171
|
+
}).then((r) => r.blob());
|
|
172
|
+
storedFile.file = blob;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.context.log(
|
|
175
|
+
'error',
|
|
176
|
+
"Failed to download file to cache it locally. The file will still be available using its URL. Check the file server's CORS configuration.",
|
|
177
|
+
err,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return storedFiles;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
importAll = async (files: ReturnedFileData[]) => {
|
|
187
|
+
await Promise.all(files.map((file) => this.add(file)));
|
|
188
|
+
};
|
|
189
|
+
|
|
156
190
|
private onOnlineChange = async (online: boolean) => {
|
|
157
191
|
// if online, try to upload any unsynced files
|
|
158
192
|
if (online) {
|
package/src/files/FileStorage.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { FileData } from '@verdant-web/common';
|
|
2
2
|
import { IDBService } from '../IDBService.js';
|
|
3
3
|
import { fileToArrayBuffer } from './utils.js';
|
|
4
|
+
import { getAllFromObjectStores } from '../idb.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* When stored in IDB, replace the file blob with an array buffer
|
|
7
8
|
* since it's more compatible, and replace remote boolean with
|
|
8
9
|
* a string since IDB doesn't support boolean indexes.
|
|
9
10
|
*/
|
|
10
|
-
interface StoredFileData extends Omit<FileData, 'remote' | 'file'> {
|
|
11
|
+
export interface StoredFileData extends Omit<FileData, 'remote' | 'file'> {
|
|
11
12
|
remote: 'true' | 'false';
|
|
12
13
|
buffer?: ArrayBuffer;
|
|
13
14
|
deletedAt: number | null;
|
|
@@ -180,6 +181,11 @@ export class FileStorage extends IDBService {
|
|
|
180
181
|
{ mode: 'readwrite', transaction },
|
|
181
182
|
);
|
|
182
183
|
};
|
|
184
|
+
|
|
185
|
+
getAll = async () => {
|
|
186
|
+
const [files] = await getAllFromObjectStores(this.db, ['files']);
|
|
187
|
+
return files.map(this.hydrateFileData);
|
|
188
|
+
};
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
export function arrayBufferToBlob(buffer: ArrayBuffer, type: string) {
|
package/src/sync/PushPullSync.ts
CHANGED
|
@@ -19,6 +19,7 @@ export class PushPullSync
|
|
|
19
19
|
readonly presence: PresenceManager;
|
|
20
20
|
private endpointProvider;
|
|
21
21
|
private heartbeat;
|
|
22
|
+
private fetch;
|
|
22
23
|
|
|
23
24
|
readonly mode = 'pull';
|
|
24
25
|
private log;
|
|
@@ -33,18 +34,21 @@ export class PushPullSync
|
|
|
33
34
|
presence,
|
|
34
35
|
interval = 15 * 1000,
|
|
35
36
|
log = () => {},
|
|
37
|
+
fetch = window.fetch,
|
|
36
38
|
}: {
|
|
37
39
|
endpointProvider: ServerSyncEndpointProvider;
|
|
38
40
|
meta: Metadata;
|
|
39
41
|
presence: PresenceManager;
|
|
40
42
|
interval?: number;
|
|
41
43
|
log?: (...args: any[]) => any;
|
|
44
|
+
fetch?: typeof window.fetch;
|
|
42
45
|
}) {
|
|
43
46
|
super();
|
|
44
47
|
this.log = log;
|
|
45
48
|
this.meta = meta;
|
|
46
49
|
this.presence = presence;
|
|
47
50
|
this.endpointProvider = endpointProvider;
|
|
51
|
+
this.fetch = fetch;
|
|
48
52
|
|
|
49
53
|
this.heartbeat = new Heartbeat({
|
|
50
54
|
interval,
|
|
@@ -65,7 +69,7 @@ export class PushPullSync
|
|
|
65
69
|
this.log('Sending sync request', messages);
|
|
66
70
|
try {
|
|
67
71
|
const { http: host, token } = await this.endpointProvider.getEndpoints();
|
|
68
|
-
const response = await fetch(host, {
|
|
72
|
+
const response = await this.fetch(host, {
|
|
69
73
|
method: 'POST',
|
|
70
74
|
headers: {
|
|
71
75
|
'Content-Type': 'application/json',
|
|
@@ -8,12 +8,18 @@ export interface ServerSyncEndpointProviderConfig {
|
|
|
8
8
|
*/
|
|
9
9
|
authEndpoint?: string;
|
|
10
10
|
/**
|
|
11
|
-
* A custom
|
|
12
|
-
* data.
|
|
11
|
+
* A custom function to retrieve authorization
|
|
12
|
+
* data. Use whatever fetching mechanism you want.
|
|
13
13
|
*/
|
|
14
14
|
fetchAuth?: () => Promise<{
|
|
15
15
|
accessToken: string;
|
|
16
16
|
}>;
|
|
17
|
+
/**
|
|
18
|
+
* A spec-compliant fetch implementation. If not provided,
|
|
19
|
+
* the global fetch will be used. authEndpoint will
|
|
20
|
+
* be used to fetch the token.
|
|
21
|
+
*/
|
|
22
|
+
fetch?: typeof fetch;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export class ServerSyncEndpointProvider {
|
|
@@ -42,7 +48,8 @@ export class ServerSyncEndpointProvider {
|
|
|
42
48
|
if (this.config.fetchAuth) {
|
|
43
49
|
result = await this.config.fetchAuth();
|
|
44
50
|
} else {
|
|
45
|
-
|
|
51
|
+
const fetchImpl = this.config.fetch || fetch;
|
|
52
|
+
result = await fetchImpl(this.config.authEndpoint!, {
|
|
46
53
|
credentials: 'include',
|
|
47
54
|
}).then((res) => {
|
|
48
55
|
if (!res.ok) {
|
package/src/sync/Sync.ts
CHANGED
|
@@ -26,12 +26,7 @@ export type SyncTransportEvents = SyncEvents & {
|
|
|
26
26
|
message: (message: ServerMessage) => void;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
export interface SyncTransport {
|
|
30
|
-
subscribe(
|
|
31
|
-
event: 'onlineChange',
|
|
32
|
-
handler: (online: boolean) => void,
|
|
33
|
-
): () => void;
|
|
34
|
-
|
|
29
|
+
export interface SyncTransport extends EventSubscriber<SyncTransportEvents> {
|
|
35
30
|
readonly presence: PresenceManager;
|
|
36
31
|
|
|
37
32
|
readonly mode: SyncTransportMode;
|
|
@@ -50,13 +45,23 @@ export interface SyncTransport {
|
|
|
50
45
|
readonly status: 'active' | 'paused';
|
|
51
46
|
}
|
|
52
47
|
|
|
53
|
-
export interface Sync<Presence = any, Profile = any>
|
|
48
|
+
export interface Sync<Presence = any, Profile = any>
|
|
49
|
+
extends EventSubscriber<SyncEvents> {
|
|
54
50
|
setMode(mode: SyncTransportMode): void;
|
|
55
51
|
setPullInterval(interval: number): void;
|
|
56
52
|
readonly pullInterval: number;
|
|
57
53
|
uploadFile(data: FileData): Promise<FileUploadResult>;
|
|
58
54
|
getFile(fileId: string): Promise<FilePullResult>;
|
|
59
55
|
readonly presence: PresenceManager<Profile, Presence>;
|
|
56
|
+
send(message: ClientMessage): void;
|
|
57
|
+
start(): void;
|
|
58
|
+
stop(): void;
|
|
59
|
+
ignoreIncoming(): void;
|
|
60
|
+
destroy(): void;
|
|
61
|
+
reconnect(): void;
|
|
62
|
+
readonly isConnected: boolean;
|
|
63
|
+
readonly status: 'active' | 'paused';
|
|
64
|
+
readonly mode: SyncTransportMode;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export class NoSync<Presence = any, Profile = any>
|
|
@@ -193,6 +198,7 @@ export class ServerSync<Presence = any, Profile = any>
|
|
|
193
198
|
{
|
|
194
199
|
authEndpoint,
|
|
195
200
|
fetchAuth,
|
|
201
|
+
fetch,
|
|
196
202
|
initialPresence,
|
|
197
203
|
automaticTransportSelection = true,
|
|
198
204
|
autoStart,
|
|
@@ -229,6 +235,7 @@ export class ServerSync<Presence = any, Profile = any>
|
|
|
229
235
|
this.endpointProvider = new ServerSyncEndpointProvider({
|
|
230
236
|
authEndpoint,
|
|
231
237
|
fetchAuth,
|
|
238
|
+
fetch,
|
|
232
239
|
});
|
|
233
240
|
|
|
234
241
|
this.webSocketSync = new WebSocketSync({
|
|
@@ -243,6 +250,7 @@ export class ServerSync<Presence = any, Profile = any>
|
|
|
243
250
|
presence: this.presence,
|
|
244
251
|
log: this.log,
|
|
245
252
|
interval: pullInterval,
|
|
253
|
+
fetch,
|
|
246
254
|
});
|
|
247
255
|
this.fileSync = new FileSync({
|
|
248
256
|
endpointProvider: this.endpointProvider,
|