@verdant-web/store 2.5.7 → 2.6.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.
Files changed (86) hide show
  1. package/dist/bundle/index.js +15 -10
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/cjs/__tests__/queries.test.js +2 -0
  4. package/dist/cjs/__tests__/queries.test.js.map +1 -1
  5. package/dist/cjs/client/Client.js +23 -11
  6. package/dist/cjs/client/Client.js.map +1 -1
  7. package/dist/cjs/client/ClientDescriptor.d.ts +3 -0
  8. package/dist/cjs/client/ClientDescriptor.js +100 -36
  9. package/dist/cjs/client/ClientDescriptor.js.map +1 -1
  10. package/dist/cjs/entities/DocumentFamiliyCache.d.ts +20 -1
  11. package/dist/cjs/entities/DocumentFamiliyCache.js +33 -18
  12. package/dist/cjs/entities/DocumentFamiliyCache.js.map +1 -1
  13. package/dist/cjs/entities/Entity.js +17 -0
  14. package/dist/cjs/entities/Entity.js.map +1 -1
  15. package/dist/cjs/entities/EntityStore.d.ts +2 -1
  16. package/dist/cjs/entities/EntityStore.js +36 -7
  17. package/dist/cjs/entities/EntityStore.js.map +1 -1
  18. package/dist/cjs/idb.d.ts +2 -0
  19. package/dist/cjs/idb.js +9 -1
  20. package/dist/cjs/idb.js.map +1 -1
  21. package/dist/cjs/metadata/Metadata.d.ts +3 -1
  22. package/dist/cjs/metadata/Metadata.js +3 -1
  23. package/dist/cjs/metadata/Metadata.js.map +1 -1
  24. package/dist/cjs/metadata/openMetadataDatabase.d.ts +11 -2
  25. package/dist/cjs/metadata/openMetadataDatabase.js +56 -3
  26. package/dist/cjs/metadata/openMetadataDatabase.js.map +1 -1
  27. package/dist/cjs/migration/db.d.ts +1 -1
  28. package/dist/cjs/migration/db.js +5 -2
  29. package/dist/cjs/migration/db.js.map +1 -1
  30. package/dist/cjs/migration/openDatabase.d.ts +8 -0
  31. package/dist/cjs/migration/openDatabase.js +228 -148
  32. package/dist/cjs/migration/openDatabase.js.map +1 -1
  33. package/dist/cjs/queries/BaseQuery.js +12 -1
  34. package/dist/cjs/queries/BaseQuery.js.map +1 -1
  35. package/dist/cjs/sync/WebSocketSync.js +4 -3
  36. package/dist/cjs/sync/WebSocketSync.js.map +1 -1
  37. package/dist/esm/__tests__/queries.test.js +2 -0
  38. package/dist/esm/__tests__/queries.test.js.map +1 -1
  39. package/dist/esm/client/Client.js +23 -11
  40. package/dist/esm/client/Client.js.map +1 -1
  41. package/dist/esm/client/ClientDescriptor.d.ts +3 -0
  42. package/dist/esm/client/ClientDescriptor.js +104 -40
  43. package/dist/esm/client/ClientDescriptor.js.map +1 -1
  44. package/dist/esm/entities/DocumentFamiliyCache.d.ts +20 -1
  45. package/dist/esm/entities/DocumentFamiliyCache.js +33 -18
  46. package/dist/esm/entities/DocumentFamiliyCache.js.map +1 -1
  47. package/dist/esm/entities/Entity.js +17 -0
  48. package/dist/esm/entities/Entity.js.map +1 -1
  49. package/dist/esm/entities/EntityStore.d.ts +2 -1
  50. package/dist/esm/entities/EntityStore.js +36 -7
  51. package/dist/esm/entities/EntityStore.js.map +1 -1
  52. package/dist/esm/idb.d.ts +2 -0
  53. package/dist/esm/idb.js +6 -0
  54. package/dist/esm/idb.js.map +1 -1
  55. package/dist/esm/metadata/Metadata.d.ts +3 -1
  56. package/dist/esm/metadata/Metadata.js +3 -1
  57. package/dist/esm/metadata/Metadata.js.map +1 -1
  58. package/dist/esm/metadata/openMetadataDatabase.d.ts +11 -2
  59. package/dist/esm/metadata/openMetadataDatabase.js +54 -2
  60. package/dist/esm/metadata/openMetadataDatabase.js.map +1 -1
  61. package/dist/esm/migration/db.d.ts +1 -1
  62. package/dist/esm/migration/db.js +5 -2
  63. package/dist/esm/migration/db.js.map +1 -1
  64. package/dist/esm/migration/openDatabase.d.ts +8 -0
  65. package/dist/esm/migration/openDatabase.js +225 -146
  66. package/dist/esm/migration/openDatabase.js.map +1 -1
  67. package/dist/esm/queries/BaseQuery.js +12 -1
  68. package/dist/esm/queries/BaseQuery.js.map +1 -1
  69. package/dist/esm/sync/WebSocketSync.js +4 -3
  70. package/dist/esm/sync/WebSocketSync.js.map +1 -1
  71. package/dist/tsconfig-cjs.tsbuildinfo +1 -1
  72. package/dist/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +2 -2
  74. package/src/__tests__/queries.test.ts +3 -0
  75. package/src/client/Client.ts +26 -12
  76. package/src/client/ClientDescriptor.ts +145 -49
  77. package/src/entities/DocumentFamiliyCache.ts +59 -19
  78. package/src/entities/Entity.ts +17 -0
  79. package/src/entities/EntityStore.ts +45 -6
  80. package/src/idb.ts +10 -0
  81. package/src/metadata/Metadata.ts +6 -1
  82. package/src/metadata/openMetadataDatabase.ts +96 -13
  83. package/src/migration/db.ts +14 -1
  84. package/src/migration/openDatabase.ts +357 -194
  85. package/src/queries/BaseQuery.ts +14 -1
  86. package/src/sync/WebSocketSync.ts +1 -0
@@ -14,6 +14,7 @@ import type { EntityStore } from './EntityStore.js';
14
14
  import { WeakRef } from './FakeWeakRef.js';
15
15
  import { Context } from '../context.js';
16
16
  import { TaggedOperation } from '../types.js';
17
+ import { Resolvable } from '../utils/Resolvable.js';
17
18
 
18
19
  /**
19
20
  * Local operations: operations on this client that haven't
@@ -44,6 +45,14 @@ export class DocumentFamilyCache extends EventSubscriber<
44
45
  private context;
45
46
  private storeTools: StoreTools;
46
47
 
48
+ private _initialized = new Resolvable<boolean>();
49
+ get initializedPromise() {
50
+ return this._initialized.promise;
51
+ }
52
+ setInitialized = () => {
53
+ this._initialized.resolve(true);
54
+ };
55
+
47
56
  constructor({
48
57
  oid,
49
58
  store,
@@ -236,6 +245,14 @@ export class DocumentFamilyCache extends EventSubscriber<
236
245
  };
237
246
 
238
247
  computeView = (oid: ObjectIdentifier) => {
248
+ if (
249
+ this.baselinesMap.size === 0 &&
250
+ this.operationsMap.size === 0 &&
251
+ this.localOperationsMap.size === 0
252
+ ) {
253
+ this.context.log('debug', `Entity ${oid} accessed with no data at all`);
254
+ return { view: null, deleted: true, lastTimestamp: null };
255
+ }
239
256
  const confirmed = this.computeConfirmedView(oid);
240
257
  const unconfirmedOperations = this.localOperationsMap.get(oid) || [];
241
258
  if (confirmed.empty && !unconfirmedOperations.length) {
@@ -331,33 +348,56 @@ export class DocumentFamilyCache extends EventSubscriber<
331
348
  this.entities.clear();
332
349
  };
333
350
 
334
- reset = (
335
- operations: TaggedOperation[],
336
- baselines: DocumentBaseline[],
351
+ reset = ({
352
+ operations,
353
+ baselines,
354
+ dropExistingUnconfirmed: dropUnconfirmed = false,
355
+ unconfirmedOperations,
356
+ dropAll,
357
+ }: {
358
+ operations: TaggedOperation[];
359
+ unconfirmedOperations?: Operation[];
360
+ baselines: DocumentBaseline[];
337
361
  /**
338
362
  * Whether to drop operations which are only in-memory. Unconfirmed operations
339
363
  * will not be restored from storage until they are persisted, so it's not advisable
340
364
  * to use this unless the intention is to completely clear the entities.
341
365
  */
342
- dropUnconfirmed = false,
343
- ) => {
344
- const info = { isLocal: false, affectedOids: new Set<ObjectIdentifier>() };
345
- this.baselinesMap = new Map(
346
- baselines.map((baseline) => [baseline.oid, baseline]),
366
+ dropExistingUnconfirmed?: boolean;
367
+ /**
368
+ * Drop unconfirmed and confirmed data before resetting to incoming data.
369
+ * This is dangerous due to race conditions. Only use when a full reset is
370
+ * required.
371
+ */
372
+ dropAll?: boolean;
373
+ }) => {
374
+ this.context.log(
375
+ 'debug',
376
+ `Resetting cache for ${this.oid} with ${operations.length} ops and ${baselines.length} baselines, dropUnconfirmed=${dropUnconfirmed}`,
347
377
  );
348
- if (dropUnconfirmed) {
349
- this.operationsMap = new Map();
350
- } else {
351
- // clear out all confirmed operations, leaving only unconfirmed
352
- // which have been added in memory but not yet persisted in storage
353
- for (const oid of this.operationsMap.keys()) {
354
- this.operationsMap.set(
355
- oid,
356
- this.operationsMap.get(oid)?.filter((op) => !op.confirmed) ?? [],
357
- );
378
+ const info = { isLocal: false, affectedOids: new Set<ObjectIdentifier>() };
379
+
380
+ // NOTE: not clearing these maps... there are even more
381
+ // race conditions where we begin opening a cache, queue up a
382
+ // reset, then receive incoming operations before the reset
383
+ // actually hits this function, so those incoming ops are
384
+ // dropped.
385
+ // FIXME: include this in a future refactor of this
386
+ // whole system.
387
+
388
+ if (dropAll) this.baselinesMap.clear();
389
+ this.insertBaselines(baselines, info);
390
+
391
+ if (dropAll) this.operationsMap.clear();
392
+ this.insertOperations(operations, info);
393
+
394
+ if (unconfirmedOperations || dropUnconfirmed) {
395
+ this.localOperationsMap.clear();
396
+ if (unconfirmedOperations) {
397
+ this.insertLocalOperations(unconfirmedOperations);
358
398
  }
359
399
  }
360
- this.insertOperations(operations, info);
400
+
361
401
  for (const oid of this.entities.keys()) {
362
402
  const entityRef = this.entities.get(oid);
363
403
  const entity = entityRef?.deref();
@@ -347,6 +347,23 @@ export class Entity<
347
347
  };
348
348
 
349
349
  protected processInputValue = (value: any, key: any) => {
350
+ // disassociate incoming OIDs on values and generally break object
351
+ // references. cloning doesn't work on files so those are
352
+ // filtered out.
353
+ // The goal here is to be safe about a bunch of cases that could
354
+ // result in corrupt data, like...
355
+ // ent1.set('objField', ent2.get('objField'))
356
+ // or
357
+ // var shared = { foo: 'bar' };
358
+ // ent1.set('objField', shared);
359
+ // ent2.set('objField', shared);
360
+ // ... each of these would result in the same object being
361
+ // referenced in multiple entities, which could mean introduction
362
+ // of foreign OIDs, or one object being assigned different OIDs
363
+ // with unexpected results.
364
+ if (!(value instanceof File)) {
365
+ value = cloneDeep(value, false);
366
+ }
350
367
  const fieldSchema = this.getChildFieldSchema(key);
351
368
  if (fieldSchema) {
352
369
  traverseCollectionFieldsAndApplyDefaults(value, fieldSchema);
@@ -113,9 +113,16 @@ export class EntityStore {
113
113
  private refreshFamilyCache = async (
114
114
  familyCache: DocumentFamilyCache,
115
115
  dropUnconfirmed = false,
116
+ dropAll = false,
116
117
  ) => {
117
118
  // avoid writing to disposed db
118
- if (this._disposed) return;
119
+ if (this._disposed) {
120
+ this.context.log(
121
+ 'debug',
122
+ `EntityStore is disposed, not refreshing ${familyCache.oid} cache`,
123
+ );
124
+ return;
125
+ }
119
126
 
120
127
  // metadata must be loaded from database to initialize family cache
121
128
  const transaction = this.meta.createTransaction([
@@ -146,21 +153,40 @@ export class EntityStore {
146
153
  { transaction, mode: 'readwrite' },
147
154
  ),
148
155
  ]);
149
- familyCache.reset(operations, baselines, dropUnconfirmed);
156
+ familyCache.reset({
157
+ operations,
158
+ baselines,
159
+ dropExistingUnconfirmed: dropUnconfirmed,
160
+ dropAll,
161
+ });
150
162
  };
151
163
 
152
164
  private openFamilyCache = async (oid: ObjectIdentifier) => {
153
165
  const documentOid = getOidRoot(oid);
154
166
  let familyCache = this.documentFamilyCaches.get(documentOid);
155
167
  if (!familyCache) {
168
+ this.context.log('debug', 'opening family cache for', documentOid);
156
169
  // metadata must be loaded from database to initialize family cache
157
170
  familyCache = new DocumentFamilyCache({
158
171
  oid: documentOid,
159
172
  store: this,
160
173
  context: this.context,
161
174
  });
175
+
176
+ // PROBLEM: because the next line is async, it yields to
177
+ // queued promises which may need data from this cache,
178
+ // but the cache is empty. But if we move the set to
179
+ // after the async, we can clobber an existing cache
180
+ // with race conditions...
181
+ // So as an attempt to fix that, I've added a promise
182
+ // on DocumentFamilyCache which I manually resolve
183
+ // with setInitialized, then await initializedPromise
184
+ // further down even if there was a cache hit.
185
+ // Surely there is a better pattern for this.
186
+ // FIXME:
162
187
  this.documentFamilyCaches.set(documentOid, familyCache);
163
188
  await this.refreshFamilyCache(familyCache);
189
+ familyCache.setInitialized();
164
190
 
165
191
  // this.unsubscribes.push(
166
192
  // familyCache.subscribe('change:*', this.onEntityChange),
@@ -168,6 +194,7 @@ export class EntityStore {
168
194
 
169
195
  // TODO: cleanup cache when all documents are disposed
170
196
  }
197
+ await familyCache.initializedPromise;
171
198
 
172
199
  return familyCache;
173
200
  };
@@ -384,6 +411,10 @@ export class EntityStore {
384
411
  baselines: DocumentBaseline[];
385
412
  reset?: boolean;
386
413
  }) => {
414
+ if (this._disposed) {
415
+ this.log('warn', 'EntityStore is disposed, not adding data');
416
+ return;
417
+ }
387
418
  // convert operations to tagged operations with confirmed = false
388
419
  // while we process and store them. this is in-place so as to
389
420
  // not allocate a bunch of objects...
@@ -427,7 +458,7 @@ export class EntityStore {
427
458
  await this.meta.insertRemoteOperations(operations);
428
459
 
429
460
  if (reset) {
430
- await this.refreshAllCaches(true);
461
+ await this.refreshAllCaches(true, true);
431
462
  }
432
463
 
433
464
  // recompute all affected documents for querying
@@ -511,6 +542,10 @@ export class EntityStore {
511
542
  await this.operationBatcher.flush(this.currentBatchKey);
512
543
  };
513
544
 
545
+ flushAllBatches = async () => {
546
+ await Promise.all(this.operationBatcher.flushAll());
547
+ };
548
+
514
549
  private flushOperations = async (
515
550
  operations: Operation[],
516
551
  batchKey: string,
@@ -632,7 +667,7 @@ export class EntityStore {
632
667
  // );
633
668
  };
634
669
 
635
- destroy = () => {
670
+ destroy = async () => {
636
671
  this._disposed = true;
637
672
  for (const unsubscribe of this.unsubscribes) {
638
673
  unsubscribe();
@@ -641,6 +676,7 @@ export class EntityStore {
641
676
  cache.dispose();
642
677
  }
643
678
  this.documentFamilyCaches.clear();
679
+ await this.flushAllBatches();
644
680
  };
645
681
 
646
682
  private handleRebase = (baselines: DocumentBaseline[]) => {
@@ -661,9 +697,12 @@ export class EntityStore {
661
697
  }
662
698
  };
663
699
 
664
- private refreshAllCaches = async (dropUnconfirmed = false) => {
700
+ private refreshAllCaches = async (
701
+ dropUnconfirmed = false,
702
+ dropAll = false,
703
+ ) => {
665
704
  for (const [_, cache] of this.documentFamilyCaches) {
666
- await this.refreshFamilyCache(cache, dropUnconfirmed);
705
+ await this.refreshFamilyCache(cache, dropUnconfirmed, dropAll);
667
706
  }
668
707
  };
669
708
  }
package/src/idb.ts CHANGED
@@ -105,3 +105,13 @@ export async function deleteAllDatabases(
105
105
  ]);
106
106
  window.location.reload();
107
107
  }
108
+
109
+ export function deleteDatabase(name: string, indexedDB = window.indexedDB) {
110
+ return storeRequestPromise(indexedDB.deleteDatabase(name));
111
+ }
112
+
113
+ export async function getAllDatabaseNamesAndVersions(
114
+ indexedDB: IDBFactory = window.indexedDB,
115
+ ) {
116
+ return indexedDB.databases();
117
+ }
@@ -141,7 +141,10 @@ export class Metadata extends EventSubscriber<{
141
141
  return Array.from(oids);
142
142
  };
143
143
 
144
- getDocumentSnapshot = async (oid: ObjectIdentifier) => {
144
+ getDocumentSnapshot = async (
145
+ oid: ObjectIdentifier,
146
+ options: { to?: string } = {},
147
+ ) => {
145
148
  const documentOid = getOidRoot(oid);
146
149
  assert(documentOid === oid, 'Must be root document OID');
147
150
  const transaction = this.db.transaction(
@@ -170,6 +173,8 @@ export class Metadata extends EventSubscriber<{
170
173
  },
171
174
  {
172
175
  transaction,
176
+ // only apply operations up to the current time
177
+ to: options.to || this.now,
173
178
  },
174
179
  );
175
180
  const root = objectMap.get(documentOid);
@@ -1,20 +1,19 @@
1
+ import { closeDatabase, storeRequestPromise } from '../idb.js';
2
+
1
3
  const migrations = [version1, version2, version3, version4];
2
4
 
3
- export function openMetadataDatabase(
4
- namespace: string,
5
- {
6
- indexedDB = window.indexedDB,
7
- databaseName,
8
- log,
9
- }: {
10
- indexedDB?: IDBFactory;
11
- databaseName: string;
12
- log?: (...args: any[]) => void;
13
- },
14
- ): Promise<{ wasInitialized: boolean; db: IDBDatabase }> {
5
+ export function openMetadataDatabase({
6
+ indexedDB = window.indexedDB,
7
+ namespace,
8
+ log,
9
+ }: {
10
+ indexedDB?: IDBFactory;
11
+ namespace: string;
12
+ log?: (...args: any[]) => void;
13
+ }): Promise<{ wasInitialized: boolean; db: IDBDatabase }> {
15
14
  return new Promise<{ wasInitialized: boolean; db: IDBDatabase }>(
16
15
  (resolve, reject) => {
17
- const request = indexedDB.open(databaseName, 4);
16
+ const request = indexedDB.open(`${namespace}_meta`, 4);
18
17
  let wasInitialized = false;
19
18
  request.onupgradeneeded = async (event) => {
20
19
  const db = request.result;
@@ -40,6 +39,90 @@ export function openMetadataDatabase(
40
39
  );
41
40
  }
42
41
 
42
+ export async function openWIPMetadataDatabase({
43
+ wipNamespace,
44
+ namespace,
45
+ indexedDB,
46
+ log,
47
+ }: {
48
+ indexedDB?: IDBFactory;
49
+ namespace: string;
50
+ wipNamespace: string;
51
+ log?: (...args: any[]) => void;
52
+ }): Promise<{ wasInitialized: boolean; db: IDBDatabase }> {
53
+ const result = await openMetadataDatabase({
54
+ namespace: wipNamespace,
55
+ indexedDB,
56
+ log,
57
+ });
58
+
59
+ // this WIP database was already set up.
60
+ if (!result.wasInitialized) {
61
+ return result;
62
+ }
63
+
64
+ log?.('debug', 'Beginning copy of production metadata database to WIP');
65
+ // copy all data from production metadata database
66
+ const { db: prodDb } = await openMetadataDatabase({
67
+ namespace,
68
+ indexedDB,
69
+ log,
70
+ });
71
+
72
+ const tx = prodDb.transaction(
73
+ ['baselines', 'operations', 'info'],
74
+ 'readonly',
75
+ );
76
+ const [baselines, operations, info] = await Promise.all([
77
+ storeRequestPromise(tx.objectStore('baselines').getAll()),
78
+ storeRequestPromise(tx.objectStore('operations').getAll()),
79
+ storeRequestPromise(tx.objectStore('info').getAll()),
80
+ ]);
81
+
82
+ const wipTx = result.db.transaction(
83
+ ['baselines', 'operations', 'info'],
84
+ 'readwrite',
85
+ );
86
+ const wipBaselines = wipTx.objectStore('baselines');
87
+ const wipOperations = wipTx.objectStore('operations');
88
+ const wipInfo = wipTx.objectStore('info');
89
+
90
+ for (const baseline of baselines) {
91
+ wipBaselines.put(baseline);
92
+ }
93
+ for (const operation of operations) {
94
+ wipOperations.put(operation);
95
+ }
96
+ for (const infoItem of info) {
97
+ wipInfo.put(infoItem);
98
+ }
99
+
100
+ await new Promise<void>((resolve, reject) => {
101
+ wipTx.oncomplete = () => {
102
+ resolve();
103
+ };
104
+ wipTx.onerror = (event) => {
105
+ reject(event);
106
+ };
107
+ wipTx.onabort = (event) => {
108
+ reject(event);
109
+ };
110
+ });
111
+
112
+ await closeDatabase(prodDb);
113
+
114
+ log?.(
115
+ 'debug',
116
+ 'Finished copy of production metadata database to WIP. Copied:',
117
+ baselines.length,
118
+ 'baselines,',
119
+ operations.length,
120
+ 'operations',
121
+ );
122
+
123
+ return result;
124
+ }
125
+
43
126
  async function version1(db: IDBDatabase, tx: IDBTransaction) {
44
127
  const baselinesStore = db.createObjectStore('baselines', {
45
128
  keyPath: 'oid',
@@ -114,7 +114,9 @@ export async function openDatabase(
114
114
  indexedDb: IDBFactory,
115
115
  namespace: string,
116
116
  version: number,
117
+ log?: (...args: any[]) => void,
117
118
  ): Promise<IDBDatabase> {
119
+ log?.('debug', 'Opening database', namespace, 'at version', version);
118
120
  const db = await new Promise<IDBDatabase>((resolve, reject) => {
119
121
  const request = indexedDb.open(
120
122
  [namespace, 'collections'].join('_'),
@@ -124,8 +126,19 @@ export async function openDatabase(
124
126
  const transaction = request.transaction!;
125
127
  transaction.abort();
126
128
 
129
+ log?.(
130
+ 'error',
131
+ 'Database upgrade needed, but not expected',
132
+ 'Expected',
133
+ version,
134
+ 'Got',
135
+ request.result.version,
136
+ );
127
137
  reject(
128
- new Error('Migration error: database version changed while migrating'),
138
+ request.error ||
139
+ new Error(
140
+ `Migration error: database version changed unexpectedly when reading current data. Expected ${version}, got ${request.result.version}`,
141
+ ),
129
142
  );
130
143
  };
131
144
  request.onsuccess = (event) => {