@verdant-web/store 3.11.1 → 3.12.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.
@@ -2,6 +2,7 @@ import {
2
2
  debounce,
3
3
  DocumentBaseline,
4
4
  EventSubscriber,
5
+ getTimestampSchemaVersion,
5
6
  Migration,
6
7
  Operation,
7
8
  } from '@verdant-web/common';
@@ -15,11 +16,16 @@ import {
15
16
  deleteAllDatabases,
16
17
  getSizeOfObjectStore,
17
18
  } from '../idb.js';
18
- import { ExportData, Metadata } from '../metadata/Metadata.js';
19
+ import {
20
+ ExportData,
21
+ Metadata,
22
+ supportLegacyExport,
23
+ } from '../metadata/Metadata.js';
19
24
  import { openQueryDatabase } from '../migration/openQueryDatabase.js';
20
25
  import { CollectionQueries } from '../queries/CollectionQueries.js';
21
26
  import { QueryCache } from '../queries/QueryCache.js';
22
27
  import { NoSync, ServerSync, ServerSyncOptions, Sync } from '../sync/Sync.js';
28
+ import { getLatestVersion } from '../utils/versions.js';
23
29
 
24
30
  interface ClientConfig<Presence = any> {
25
31
  syncConfig?: ServerSyncOptions<Presence>;
@@ -50,6 +56,12 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
50
56
  * been offline for too long and reconnects.
51
57
  */
52
58
  resetToServer: () => void;
59
+ /**
60
+ * These are errors that, as a developer, you should subscribe to
61
+ * and prompt users to contact you for resolution. Usually these errors
62
+ * indicate the client is in an unrecoverable state.
63
+ */
64
+ developerError: (err: Error) => void;
53
65
  }> {
54
66
  readonly meta: Metadata;
55
67
  private _entities: EntityStore;
@@ -104,14 +116,14 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
104
116
  config: this.config.files,
105
117
  meta: this.meta,
106
118
  });
119
+ this._queryCache = new QueryCache({
120
+ context,
121
+ });
107
122
  this._entities = new EntityStore({
108
123
  ctx: this.context,
109
124
  meta: this.meta,
110
125
  files: this._fileManager,
111
126
  });
112
- this._queryCache = new QueryCache({
113
- context,
114
- });
115
127
  this._documentManager = new DocumentManager(this.schema, this._entities);
116
128
 
117
129
  const notifyFutureSeen = debounce(() => {
@@ -122,16 +134,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
122
134
  this.emit('resetToServer');
123
135
  });
124
136
 
125
- this.documentDb.addEventListener('versionchange', () => {
126
- this.context.log?.(
127
- 'warn',
128
- `Another tab has requested a version change for ${this.namespace}`,
129
- );
130
- this.documentDb.close();
131
- if (typeof window !== 'undefined') {
132
- window.location.reload();
133
- }
134
- });
137
+ this.watchForVersionChange();
135
138
 
136
139
  this.metaDb.addEventListener('versionchange', () => {
137
140
  this.context.log?.(
@@ -160,12 +163,81 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
160
163
  }
161
164
  }
162
165
 
163
- private addData = (data: {
166
+ private watchForVersionChange = () => {
167
+ this.documentDb.addEventListener('versionchange', () => {
168
+ this.context.log?.(
169
+ 'warn',
170
+ `Another tab has requested a version change for ${this.namespace}`,
171
+ );
172
+ this.documentDb.close();
173
+ if (typeof window !== 'undefined') {
174
+ window.location.reload();
175
+ }
176
+ });
177
+ };
178
+
179
+ private importingPromise = Promise.resolve();
180
+ private addData = async (data: {
164
181
  operations: Operation[];
165
182
  baselines?: DocumentBaseline[];
166
183
  reset?: boolean;
167
184
  }) => {
168
- return this._entities.addData(data);
185
+ // always wait for an ongoing import to complete before handling data.
186
+ await this.importingPromise;
187
+
188
+ try {
189
+ const schemaVersion = data.reset
190
+ ? getLatestVersion(data)
191
+ : this.schema.version;
192
+
193
+ if (schemaVersion < this.schema.version) {
194
+ /**
195
+ * Edge case: the server has an older version of the library
196
+ * than the client schema, but it wants the client to reset.
197
+ *
198
+ * This happens when a truant or new client loads up newest client
199
+ * code with a new schema version, but the last sync to the
200
+ * server was from an old version. It's particularly a problem
201
+ * if the new schema drops collections, since the IDB table for
202
+ * that collection will no longer exist, so loading in old data
203
+ * will result in an error.
204
+ *
205
+ * To handle this, we treat the reset data as if it were an import
206
+ * of exported data. The import procedure handles older
207
+ * schema versions by resetting the database to the imported
208
+ * version, then migrating up to the current version.
209
+ */
210
+ this.context.log(
211
+ 'warn',
212
+ 'Incoming reset sync data is from an old schema version',
213
+ schemaVersion,
214
+ `(current ${this.schema.version})`,
215
+ );
216
+ // run through the import flow to properly handle old versions
217
+ return await this.import({
218
+ data: {
219
+ operations: data.operations,
220
+ baselines: data.baselines ?? [],
221
+ // keep existing
222
+ localReplica: undefined,
223
+ schemaVersion,
224
+ },
225
+ fileData: [],
226
+ files: [],
227
+ });
228
+ } else {
229
+ return await this._entities.addData(data);
230
+ }
231
+ } catch (err) {
232
+ this.context.log('critical', 'Sync failed', err);
233
+ this.emit(
234
+ 'developerError',
235
+ new Error('Sync failed, see logs or cause', {
236
+ cause: err,
237
+ }),
238
+ );
239
+ throw err;
240
+ }
169
241
  };
170
242
 
171
243
  get documentDb() {
@@ -335,7 +407,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
335
407
  };
336
408
 
337
409
  import = async ({
338
- data,
410
+ data: rawData,
339
411
  fileData,
340
412
  files,
341
413
  }: {
@@ -343,6 +415,27 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
343
415
  fileData: Array<Omit<ReturnedFileData, 'file'>>;
344
416
  files: File[];
345
417
  }) => {
418
+ /**
419
+ * Importing is a pretty involved procedure because of the possibility of
420
+ * importing an export from an older version of the schema. We can't add
421
+ * data from older schemas because the indexes may have changed or whole
422
+ * collections may have been since deleted, leaving no corresponding IDB
423
+ * tables.
424
+ *
425
+ * Since IDB doesn't allow us to go backwards, and we are resetting all
426
+ * data anyways, the import procedure blows away the current queryable DB
427
+ * and restarts from the imported schema version. It then migrates up
428
+ * to the latest (current) version. These migrations are added to the imported
429
+ * data to produce the final state.
430
+ */
431
+
432
+ // register importing promise to halt other data handling
433
+ let resolve = () => {};
434
+ this.importingPromise = new Promise<void>((res) => {
435
+ resolve = res;
436
+ });
437
+
438
+ const data = supportLegacyExport(rawData);
346
439
  this.context.log('info', 'Importing data...');
347
440
  // close the document DB
348
441
  await closeDatabase(this.context.documentDb);
@@ -366,7 +459,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
366
459
  await this._fileManager.importAll(importedFiles);
367
460
  // now delete the document DB, open it to the specified version
368
461
  // and run migrations to get it to the latest version
369
- const version = data.schema.version;
462
+ const version = data.schemaVersion;
370
463
  const deleteReq = indexedDB.deleteDatabase(
371
464
  [this.namespace, 'collections'].join('_'),
372
465
  );
@@ -377,16 +470,18 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
377
470
  // reset our context to the imported schema for now
378
471
  const currentSchema = this.context.schema;
379
472
  if (currentSchema.version !== version) {
380
- // TODO: support importing older schema data - this will
381
- // require being able to migrate that data, which requires
382
- // a "live" schema for that version. the client does not currently
383
- // receive historical schemas, although they should be available
384
- // if the CLI was used.
385
- // importing from older versions is also tricky because
386
- // migration shortcuts mean that versions could get marooned.
387
- throw new Error(
388
- `Only exports from the current schema version can be imported`,
473
+ const oldSchema = this.context.oldSchemas?.find(
474
+ (s) => s.version === version,
389
475
  );
476
+ if (!oldSchema) {
477
+ this.emit(
478
+ 'developerError',
479
+ new Error(`Could not find schema for version ${version}`),
480
+ );
481
+ throw new Error(`Could not find schema for version ${version}`);
482
+ }
483
+
484
+ this.context.schema = oldSchema;
390
485
  }
391
486
  // now open the document DB empty at the specified version
392
487
  // and initialize it from the meta DB
@@ -417,6 +512,15 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
417
512
  version: currentSchema.version,
418
513
  });
419
514
  this.context.internalEvents.emit('documentDbChanged', this.documentDb);
515
+ // re-establish watcher on database
516
+ this.watchForVersionChange();
517
+
518
+ // finally... clear out memory cache of entities and
519
+ // re-run all active queries.
520
+ this.entities.clearCache();
521
+ this._queryCache.forceRefreshAll();
522
+
523
+ resolve();
420
524
  };
421
525
 
422
526
  /**
@@ -490,6 +490,20 @@ export class EntityStore extends Disposable {
490
490
  entity: Entity,
491
491
  opts?: { abort: AbortSignal },
492
492
  ): Promise<Entity | null> => {
493
+ await this.loadEntityData(entity, opts);
494
+
495
+ // only set the cache after loading.
496
+ // TODO: is this cache/promise stuff redundant?
497
+ this.cache.set(entity.oid, this.ctx.weakRef(entity));
498
+ this.entityFinalizationRegistry.register(entity, entity.oid);
499
+
500
+ return entity;
501
+ };
502
+
503
+ private loadEntityData = async (
504
+ entity: Entity,
505
+ opts?: { abort: AbortSignal },
506
+ ) => {
493
507
  const { operations, baselines } = await this.meta.getDocumentData(
494
508
  entity.oid,
495
509
  opts,
@@ -509,11 +523,14 @@ export class EntityStore extends Disposable {
509
523
  isLocal: false,
510
524
  });
511
525
 
512
- // only set the cache after loading.
513
- // TODO: is this cache/promise stuff redundant?
514
- this.cache.set(entity.oid, this.ctx.weakRef(entity));
515
- this.entityFinalizationRegistry.register(entity, entity.oid);
516
-
517
526
  return entity;
518
527
  };
528
+
529
+ /**
530
+ * Drops all entities from the cache. Any entities
531
+ * referenced will go 'dead'...
532
+ */
533
+ clearCache = () => {
534
+ this.cache.clear();
535
+ };
519
536
  }
@@ -73,7 +73,7 @@ export interface BaseEntity<
73
73
  ) => void,
74
74
  ): () => void;
75
75
  get<Key extends keyof Value>(key: Key): Value[Key];
76
- getAll(): Value;
76
+ getAll(): Readonly<Value>;
77
77
  getSnapshot(): Snapshot;
78
78
  getFieldSchema<FieldName extends keyof Value>(
79
79
  key: FieldName,
@@ -33,8 +33,8 @@ import { Context } from '../context.js';
33
33
  export interface ExportData {
34
34
  operations: Operation[];
35
35
  baselines: DocumentBaseline[];
36
- localReplica: LocalReplicaInfo;
37
- schema: StorageSchema;
36
+ localReplica?: LocalReplicaInfo;
37
+ schemaVersion: number;
38
38
  }
39
39
 
40
40
  export class Metadata extends EventSubscriber<{
@@ -635,7 +635,7 @@ export class Metadata extends EventSubscriber<{
635
635
  operations,
636
636
  baselines,
637
637
  localReplica,
638
- schema,
638
+ schemaVersion: schema.version,
639
639
  };
640
640
  };
641
641
 
@@ -653,13 +653,15 @@ export class Metadata extends EventSubscriber<{
653
653
  await storeRequestPromise(transaction.objectStore('baselines').clear());
654
654
  await storeRequestPromise(transaction.objectStore('operations').clear());
655
655
  await storeRequestPromise(transaction.objectStore('info').clear());
656
- await this.localReplica.update(
657
- {
658
- ackedLogicalTime: data.localReplica.ackedLogicalTime,
659
- lastSyncedLogicalTime: data.localReplica.lastSyncedLogicalTime,
660
- },
661
- { transaction },
662
- );
656
+ if (data.localReplica) {
657
+ await this.localReplica.update(
658
+ {
659
+ ackedLogicalTime: data.localReplica.ackedLogicalTime,
660
+ lastSyncedLogicalTime: data.localReplica.lastSyncedLogicalTime,
661
+ },
662
+ { transaction },
663
+ );
664
+ }
663
665
  };
664
666
 
665
667
  stats = async () => {
@@ -673,3 +675,11 @@ export class Metadata extends EventSubscriber<{
673
675
  };
674
676
  };
675
677
  }
678
+
679
+ export function supportLegacyExport(exportData: any): ExportData {
680
+ if (exportData.schema) {
681
+ exportData.schemaVersion = exportData.schema.version;
682
+ delete exportData.schema;
683
+ }
684
+ return exportData;
685
+ }
@@ -58,4 +58,8 @@ export class QueryCache extends Disposable {
58
58
  this._cache.forEach((query) => query.dispose());
59
59
  this._cache.clear();
60
60
  };
61
+
62
+ forceRefreshAll = () => {
63
+ this._cache.forEach((q) => q.execute());
64
+ };
61
65
  }
@@ -0,0 +1,23 @@
1
+ import {
2
+ DocumentBaseline,
3
+ getTimestampSchemaVersion,
4
+ Operation,
5
+ } from '@verdant-web/common';
6
+
7
+ export function getLatestVersion(data: {
8
+ operations: Operation[];
9
+ baselines?: DocumentBaseline[];
10
+ }) {
11
+ const timestamps = data.operations
12
+ .map((op) => op.timestamp)
13
+ .concat(data.baselines?.map((b) => b.timestamp) ?? []);
14
+ const latestVersion = timestamps.reduce((v, ts) => {
15
+ const tsVersion = getTimestampSchemaVersion(ts);
16
+ if (tsVersion > v) {
17
+ return tsVersion;
18
+ }
19
+ return v;
20
+ }, 0);
21
+
22
+ return latestVersion;
23
+ }