@verdant-web/store 4.1.2 → 4.1.4

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 (54) hide show
  1. package/dist/bundle/index.js +5 -5
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/esm/__tests__/fixtures/testStorage.js +5 -0
  4. package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
  5. package/dist/esm/__tests__/queries.test.js +51 -2
  6. package/dist/esm/__tests__/queries.test.js.map +1 -1
  7. package/dist/esm/__tests__/schema.test.d.ts +1 -0
  8. package/dist/esm/__tests__/schema.test.js +64 -0
  9. package/dist/esm/__tests__/schema.test.js.map +1 -0
  10. package/dist/esm/client/Client.d.ts +3 -1
  11. package/dist/esm/client/Client.js +8 -1
  12. package/dist/esm/client/Client.js.map +1 -1
  13. package/dist/esm/client/ClientDescriptor.d.ts +6 -2
  14. package/dist/esm/client/ClientDescriptor.js +7 -6
  15. package/dist/esm/client/ClientDescriptor.js.map +1 -1
  16. package/dist/esm/context/context.d.ts +17 -3
  17. package/dist/esm/entities/Entity.d.ts +2 -2
  18. package/dist/esm/entities/Entity.js +5 -1
  19. package/dist/esm/entities/Entity.js.map +1 -1
  20. package/dist/esm/entities/types.d.ts +41 -0
  21. package/dist/esm/files/FileManager.d.ts +3 -1
  22. package/dist/esm/files/FileManager.js +9 -2
  23. package/dist/esm/files/FileManager.js.map +1 -1
  24. package/dist/esm/persistence/PersistenceFiles.js +3 -2
  25. package/dist/esm/persistence/PersistenceFiles.js.map +1 -1
  26. package/dist/esm/persistence/idb/files/IdbPersistenceFileDb.d.ts +2 -2
  27. package/dist/esm/persistence/idb/files/IdbPersistenceFileDb.js +12 -4
  28. package/dist/esm/persistence/idb/files/IdbPersistenceFileDb.js.map +1 -1
  29. package/dist/esm/persistence/persistence.js +3 -3
  30. package/dist/esm/persistence/persistence.js.map +1 -1
  31. package/dist/esm/queries/BaseQuery.js +2 -1
  32. package/dist/esm/queries/BaseQuery.js.map +1 -1
  33. package/dist/esm/queries/QueryCache.d.ts +8 -1
  34. package/dist/esm/queries/QueryCache.js +33 -2
  35. package/dist/esm/queries/QueryCache.js.map +1 -1
  36. package/dist/esm/sync/FileSync.d.ts +1 -1
  37. package/dist/esm/sync/FileSync.js +2 -1
  38. package/dist/esm/sync/FileSync.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/fixtures/testStorage.ts +7 -1
  41. package/src/__tests__/queries.test.ts +70 -3
  42. package/src/__tests__/schema.test.ts +85 -0
  43. package/src/client/Client.ts +13 -7
  44. package/src/client/ClientDescriptor.ts +14 -8
  45. package/src/context/context.ts +18 -3
  46. package/src/entities/Entity.ts +8 -4
  47. package/src/entities/types.ts +57 -24
  48. package/src/files/FileManager.ts +15 -2
  49. package/src/persistence/PersistenceFiles.ts +11 -6
  50. package/src/persistence/idb/files/IdbPersistenceFileDb.ts +22 -9
  51. package/src/persistence/persistence.ts +4 -2
  52. package/src/queries/BaseQuery.ts +3 -1
  53. package/src/queries/QueryCache.ts +56 -2
  54. package/src/sync/FileSync.ts +3 -2
@@ -65,10 +65,10 @@ export class PersistenceFiles {
65
65
  // and must be uploaded, even if it is cloned from an uploaded file.
66
66
  file.remote = false;
67
67
 
68
- // fire event for processing immediately
69
- this.context.internalEvents.emit('fileAdded', file);
70
68
  // store in persistence db
71
69
  await this.db.add(file);
70
+ // fire event for sync to pick up and upload the file
71
+ this.context.internalEvents.emit('fileAdded', file);
72
72
  this.context.globalEvents.emit('fileSaved', file);
73
73
  this.context.log(
74
74
  'debug',
@@ -79,10 +79,10 @@ export class PersistenceFiles {
79
79
  file.file
80
80
  ? 'with binary file'
81
81
  : file.url
82
- ? 'with url'
83
- : file.localPath
84
- ? 'with local path'
85
- : 'with no data',
82
+ ? 'with url'
83
+ : file.localPath
84
+ ? 'with local path'
85
+ : 'with no data',
86
86
  );
87
87
  return file;
88
88
  };
@@ -206,6 +206,11 @@ export class PersistenceFiles {
206
206
  }, 1000);
207
207
  });
208
208
  } else {
209
+ this.context.log(
210
+ 'error',
211
+ `Failed to download file after ${maxRetries} retries`,
212
+ err,
213
+ );
209
214
  throw new Error(`Failed to download file after ${maxRetries} retries`, {
210
215
  cause: err,
211
216
  });
@@ -1,4 +1,5 @@
1
1
  import { FileData } from '@verdant-web/common';
2
+ import { Context } from '../../../internal.js';
2
3
  import {
3
4
  AbstractTransaction,
4
5
  PersistedFileData,
@@ -6,7 +7,6 @@ import {
6
7
  } from '../../interfaces.js';
7
8
  import { IdbService } from '../IdbService.js';
8
9
  import { getAllFromObjectStores, getSizeOfObjectStore } from '../util.js';
9
- import { Context } from '../../../internal.js';
10
10
 
11
11
  /**
12
12
  * When stored in IDB, replace the file blob with an array buffer
@@ -24,9 +24,7 @@ export class IdbPersistenceFileDb
24
24
  extends IdbService
25
25
  implements PersistenceFileDb
26
26
  {
27
- add = async (
28
- file: FileData,
29
- ): Promise<void> => {
27
+ add = async (file: FileData): Promise<void> => {
30
28
  let buffer = file.file ? await fileToArrayBuffer(file.file) : undefined;
31
29
 
32
30
  await this.run(
@@ -50,6 +48,10 @@ export class IdbPersistenceFileDb
50
48
  );
51
49
  };
52
50
  markUploaded = async (id: string): Promise<void> => {
51
+ if (this.disposed) {
52
+ return;
53
+ }
54
+
53
55
  const current = await this.getFileRaw(id);
54
56
 
55
57
  if (!current) {
@@ -185,19 +187,21 @@ export class IdbPersistenceFileDb
185
187
  if (file.url) {
186
188
  const response = await ctx.environment.fetch(file.url);
187
189
  if (!response.ok) {
188
- throw new Error(`Failed to download file: ${response.statusText}`);
190
+ throw new Error(
191
+ `Failed to download file ${file.url}: ${response.statusText}`,
192
+ );
189
193
  }
190
194
  return response.blob();
191
195
  }
192
196
  throw new Error('File is missing url, file, and localPath');
193
- }
197
+ };
194
198
 
195
199
  private hydrateFileData = (raw: StoredFileData): PersistedFileData => {
196
200
  (raw as any).remote = raw.remote === 'true';
197
201
  const buffer = raw.buffer;
198
202
  delete raw.buffer;
199
203
  (raw as unknown as FileData).file = buffer
200
- ? arrayBufferToBlob(buffer, raw.type)
204
+ ? arrayBufferToBlob(buffer, raw.type, raw.name)
201
205
  : undefined;
202
206
  return raw as unknown as PersistedFileData;
203
207
  };
@@ -206,6 +210,9 @@ export class IdbPersistenceFileDb
206
210
  id: string,
207
211
  { transaction }: { transaction?: AbstractTransaction } = {},
208
212
  ): Promise<StoredFileData | undefined> => {
213
+ if (this.disposed) {
214
+ return undefined;
215
+ }
209
216
  const raw = await this.run<StoredFileData>(
210
217
  'files',
211
218
  (store) => {
@@ -220,8 +227,14 @@ export class IdbPersistenceFileDb
220
227
  };
221
228
  }
222
229
 
223
- export function arrayBufferToBlob(buffer: ArrayBuffer, type: string) {
224
- return new Blob([buffer], { type });
230
+ export function arrayBufferToBlob(
231
+ buffer: ArrayBuffer,
232
+ type: string,
233
+ name?: string,
234
+ ) {
235
+ return new File([new Blob([buffer], { type })], name ?? 'blob', {
236
+ type,
237
+ });
225
238
  }
226
239
 
227
240
  function fileToArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
@@ -1,12 +1,12 @@
1
1
  import { EventSubscriber, getOidRoot, VerdantError } from '@verdant-web/common';
2
2
  import { Context, InitialContext } from '../context/context.js';
3
+ import { ShutdownHandler } from '../context/ShutdownHandler.js';
3
4
  import { getWipNamespace } from '../utils/wip.js';
4
5
  import { ExportedData } from './interfaces.js';
6
+ import { migrate } from './migration/migrate.js';
5
7
  import { PersistenceFiles } from './PersistenceFiles.js';
6
8
  import { PersistenceMetadata } from './PersistenceMetadata.js';
7
9
  import { PersistenceDocuments } from './PersistenceQueries.js';
8
- import { migrate } from './migration/migrate.js';
9
- import { ShutdownHandler } from '../context/ShutdownHandler.js';
10
10
 
11
11
  export async function initializePersistence(
12
12
  ctx: InitialContext,
@@ -130,6 +130,8 @@ export async function importPersistence(
130
130
  originalNamespace: importedNamespace,
131
131
  // no-op entity events -- don't need to inform queries of changes.
132
132
  entityEvents: new EventSubscriber(),
133
+ internalEvents: new EventSubscriber(),
134
+ globalEvents: new EventSubscriber(),
133
135
  config: {
134
136
  ...ctx.config,
135
137
  persistence: {
@@ -57,7 +57,9 @@ export abstract class BaseQuery<T> extends Disposable {
57
57
  this.isListQuery = Array.isArray(initial);
58
58
  this._events = new EventSubscriber<BaseQueryEvents>(
59
59
  (event: keyof BaseQueryEvents) => {
60
- if (event === 'change') this._allUnsubscribedHandler?.(this);
60
+ if (event === 'change') {
61
+ this._allUnsubscribedHandler?.(this);
62
+ }
61
63
  },
62
64
  );
63
65
  this.context = context;
@@ -6,6 +6,8 @@ export class QueryCache extends Disposable {
6
6
  private _cache: Map<string, BaseQuery<any>> = new Map();
7
7
  private _evictionTime;
8
8
  private context;
9
+ /** A set of query keys to keep alive even if they unsubscribe */
10
+ private _holds = new Set<string>();
9
11
 
10
12
  constructor({
11
13
  evictionTime = 5 * 1000,
@@ -26,13 +28,22 @@ export class QueryCache extends Disposable {
26
28
  );
27
29
  }
28
30
 
31
+ get activeKeys() {
32
+ return Array.from(this._cache.keys());
33
+ }
34
+
29
35
  get<T extends BaseQuery<any>>(key: string): T | null {
30
36
  return (this._cache.get(key) as T) || null;
31
37
  }
32
38
 
33
39
  set<V extends BaseQuery<any>>(value: V) {
34
40
  this._cache.set(value.key, value);
35
- value[ON_ALL_UNSUBSCRIBED](this.onQueryUnsubscribed);
41
+ value[ON_ALL_UNSUBSCRIBED](this.enqueueQueryEviction);
42
+ // immediately enqueue a check to see if this query should be evicted --
43
+ // this basically gives code X seconds to subscribe to the query before
44
+ // it gets evicted.
45
+ this.enqueueQueryEviction(value);
46
+
36
47
  return value;
37
48
  }
38
49
 
@@ -50,13 +61,24 @@ export class QueryCache extends Disposable {
50
61
  return this.set(create());
51
62
  }
52
63
 
53
- private onQueryUnsubscribed = (query: BaseQuery<any>) => {
64
+ private enqueueQueryEviction = (query: BaseQuery<any>) => {
54
65
  setTimeout(() => {
55
66
  if (query.subscribed) return;
67
+
68
+ if (this._holds.has(query.key)) {
69
+ this.context.log(
70
+ 'debug',
71
+ 'QueryCache: keepAlive hold on query preserves after unsubscribe',
72
+ query.key,
73
+ );
74
+ return;
75
+ }
76
+
56
77
  // double check before evicting... possible the cache
57
78
  // got a different version of this query.
58
79
  if (this._cache.get(query.key) === query) {
59
80
  this._cache.delete(query.key);
81
+ this.context.log('debug', 'QueryCache: evicted query', query.key);
60
82
  }
61
83
  }, this._evictionTime);
62
84
  };
@@ -81,4 +103,36 @@ export class QueryCache extends Disposable {
81
103
  );
82
104
  this._cache.forEach((q) => q.execute());
83
105
  };
106
+
107
+ keepAlive(key: string) {
108
+ this._holds.add(key);
109
+ this.context.log('debug', 'QueryCache: keepAlive', key);
110
+ }
111
+
112
+ dropKeepAlive(key: string) {
113
+ this._holds.delete(key);
114
+ const cached = this.get(key);
115
+ if (!cached) return;
116
+ if (!cached.subscribed) {
117
+ this.context.log(
118
+ 'debug',
119
+ 'QueryCache: dropKeepAlive on unsubscribed query; queuing eviction',
120
+ key,
121
+ );
122
+ this.enqueueQueryEviction(cached);
123
+ }
124
+ }
125
+
126
+ get keepAlives() {
127
+ return this._holds;
128
+ }
84
129
  }
130
+
131
+ export type PublicQueryCacheAPI = Pick<
132
+ QueryCache,
133
+ | 'keepAlive'
134
+ | 'dropKeepAlive'
135
+ | 'keepAlives'
136
+ | 'forceRefreshAll'
137
+ | 'activeKeys'
138
+ >;
@@ -1,7 +1,7 @@
1
1
  import { FileData } from '@verdant-web/common';
2
- import { ServerSyncEndpointProvider } from './ServerSyncEndpointProvider.js';
3
2
  import { Context } from '../context/context.js';
4
3
  import { Disposable } from '../utils/Disposable.js';
4
+ import { ServerSyncEndpointProvider } from './ServerSyncEndpointProvider.js';
5
5
 
6
6
  export interface FileUploadResult {
7
7
  success: boolean;
@@ -42,7 +42,6 @@ export class FileSync extends Disposable {
42
42
  this.ctx.log('debug', 'Uploading file', data.id, data.name);
43
43
  try {
44
44
  await this.uploadFile(data);
45
- this.ctx.internalEvents.emit(`fileUploaded:${data.id}`, data);
46
45
  } catch (e) {
47
46
  this.ctx.log('error', 'File upload failed', e);
48
47
  }
@@ -83,6 +82,8 @@ export class FileSync extends Disposable {
83
82
  );
84
83
 
85
84
  if (response.ok) {
85
+ this.ctx.internalEvents.emit(`fileUploaded:${data.id}`, data);
86
+ this.ctx.internalEvents.emit('fileUploaded', data);
86
87
  this.ctx.log('info', 'File upload successful');
87
88
  return {
88
89
  success: true,