@verdant-web/store 3.2.2 → 3.3.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.
Files changed (48) hide show
  1. package/dist/bundle/index.js +6 -6
  2. package/dist/bundle/index.js.map +3 -3
  3. package/dist/cjs/backup.d.ts +10 -0
  4. package/dist/cjs/backup.js +58 -0
  5. package/dist/cjs/backup.js.map +1 -0
  6. package/dist/cjs/client/Client.d.ts +28 -8
  7. package/dist/cjs/client/Client.js +76 -16
  8. package/dist/cjs/client/Client.js.map +1 -1
  9. package/dist/cjs/files/FileManager.d.ts +2 -0
  10. package/dist/cjs/files/FileManager.js +25 -0
  11. package/dist/cjs/files/FileManager.js.map +1 -1
  12. package/dist/cjs/files/FileStorage.d.ts +11 -0
  13. package/dist/cjs/files/FileStorage.js +5 -0
  14. package/dist/cjs/files/FileStorage.js.map +1 -1
  15. package/dist/cjs/sync/PushPullSync.js +1 -1
  16. package/dist/cjs/sync/PushPullSync.js.map +1 -1
  17. package/dist/cjs/sync/ServerSyncEndpointProvider.js +1 -1
  18. package/dist/cjs/sync/ServerSyncEndpointProvider.js.map +1 -1
  19. package/dist/cjs/sync/Sync.d.ts +11 -3
  20. package/dist/cjs/sync/Sync.js.map +1 -1
  21. package/dist/esm/backup.d.ts +10 -0
  22. package/dist/esm/backup.js +49 -0
  23. package/dist/esm/backup.js.map +1 -0
  24. package/dist/esm/client/Client.d.ts +28 -8
  25. package/dist/esm/client/Client.js +76 -16
  26. package/dist/esm/client/Client.js.map +1 -1
  27. package/dist/esm/files/FileManager.d.ts +2 -0
  28. package/dist/esm/files/FileManager.js +26 -1
  29. package/dist/esm/files/FileManager.js.map +1 -1
  30. package/dist/esm/files/FileStorage.d.ts +11 -0
  31. package/dist/esm/files/FileStorage.js +5 -0
  32. package/dist/esm/files/FileStorage.js.map +1 -1
  33. package/dist/esm/sync/PushPullSync.js +1 -1
  34. package/dist/esm/sync/PushPullSync.js.map +1 -1
  35. package/dist/esm/sync/ServerSyncEndpointProvider.js +1 -1
  36. package/dist/esm/sync/ServerSyncEndpointProvider.js.map +1 -1
  37. package/dist/esm/sync/Sync.d.ts +11 -3
  38. package/dist/esm/sync/Sync.js.map +1 -1
  39. package/dist/tsconfig-cjs.tsbuildinfo +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +6 -1
  42. package/src/backup.ts +60 -0
  43. package/src/client/Client.ts +105 -17
  44. package/src/files/FileManager.ts +35 -1
  45. package/src/files/FileStorage.ts +7 -1
  46. package/src/sync/PushPullSync.ts +1 -1
  47. package/src/sync/ServerSyncEndpointProvider.ts +1 -1
  48. package/src/sync/Sync.ts +12 -7
@@ -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
- return Buffer.from(JSON.stringify(metaExport));
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
- import = async (buffer: Buffer) => {
285
- this.context.log('Importing data...');
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
- const metaExport = JSON.parse(buffer.toString()) as ExportData;
290
- await this.meta.resetFrom(metaExport);
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 = metaExport.schema.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
- this.context.schema = metaExport.schema;
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: metaExport.operations,
317
- baselines: metaExport.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
  }
@@ -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 { FileStorage, ReturnedFileData } from './FileStorage.js';
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) {
@@ -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) {
@@ -34,7 +34,7 @@ export class PushPullSync
34
34
  presence,
35
35
  interval = 15 * 1000,
36
36
  log = () => {},
37
- fetch = window.fetch,
37
+ fetch = window.fetch.bind(window),
38
38
  }: {
39
39
  endpointProvider: ServerSyncEndpointProvider;
40
40
  meta: Metadata;
@@ -48,7 +48,7 @@ export class ServerSyncEndpointProvider {
48
48
  if (this.config.fetchAuth) {
49
49
  result = await this.config.fetchAuth();
50
50
  } else {
51
- const fetchImpl = this.config.fetch || fetch;
51
+ const fetchImpl = this.config.fetch || fetch.bind(window);
52
52
  result = await fetchImpl(this.config.authEndpoint!, {
53
53
  credentials: 'include',
54
54
  }).then((res) => {
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> extends SyncTransport {
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>