@verdant-web/store 5.4.3 → 5.5.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 (36) hide show
  1. package/dist/bundle/index.js +8 -8
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/esm/__tests__/QueryBatcher.test.d.ts +1 -0
  4. package/dist/esm/__tests__/QueryBatcher.test.js +17 -0
  5. package/dist/esm/__tests__/QueryBatcher.test.js.map +1 -0
  6. package/dist/esm/__tests__/entities.test.js +3 -3
  7. package/dist/esm/__tests__/entities.test.js.map +1 -1
  8. package/dist/esm/__tests__/queries.test.js +16 -0
  9. package/dist/esm/__tests__/queries.test.js.map +1 -1
  10. package/dist/esm/entities/Entity.d.ts +2 -3
  11. package/dist/esm/entities/Entity.js +37 -17
  12. package/dist/esm/entities/Entity.js.map +1 -1
  13. package/dist/esm/entities/EntityStore.js +1 -1
  14. package/dist/esm/entities/EntityStore.js.map +1 -1
  15. package/dist/esm/files/EntityFile.d.ts +22 -7
  16. package/dist/esm/files/EntityFile.js +30 -6
  17. package/dist/esm/files/EntityFile.js.map +1 -1
  18. package/dist/esm/files/FileManager.d.ts +5 -4
  19. package/dist/esm/files/FileManager.js +35 -16
  20. package/dist/esm/files/FileManager.js.map +1 -1
  21. package/dist/esm/queries/BaseQuery.d.ts +4 -1
  22. package/dist/esm/queries/BaseQuery.js +22 -11
  23. package/dist/esm/queries/BaseQuery.js.map +1 -1
  24. package/dist/esm/queries/QueryBatcher.d.ts +2 -0
  25. package/dist/esm/queries/QueryBatcher.js +39 -0
  26. package/dist/esm/queries/QueryBatcher.js.map +1 -0
  27. package/package.json +2 -2
  28. package/src/__tests__/QueryBatcher.test.ts +21 -0
  29. package/src/__tests__/entities.test.ts +3 -3
  30. package/src/__tests__/queries.test.ts +19 -0
  31. package/src/entities/Entity.ts +30 -8
  32. package/src/entities/EntityStore.ts +1 -1
  33. package/src/files/EntityFile.ts +52 -10
  34. package/src/files/FileManager.ts +45 -20
  35. package/src/queries/BaseQuery.ts +25 -12
  36. package/src/queries/QueryBatcher.ts +54 -0
@@ -27,7 +27,7 @@ import {
27
27
  validateEntityField,
28
28
  } from '@verdant-web/common';
29
29
  import { Context } from '../context/context.js';
30
- import { CHILD_FILE_CHANGED } from '../files/EntityFile.js';
30
+ import { EntityFileContext } from '../files/EntityFile.js';
31
31
  import { FileManager } from '../files/FileManager.js';
32
32
  import { processValueFiles } from '../files/utils.js';
33
33
  import { ClientWithCollections, EntityFile } from '../index.js';
@@ -751,16 +751,38 @@ export class Entity<
751
751
  this.emit('changeDeep', target, ev);
752
752
  this.parent?.deepChange(target, ev);
753
753
  };
754
- [CHILD_FILE_CHANGED] = (file: EntityFile) => {
755
- // consistent with prior behavior, but kind of arbitrary.
756
- this.deepChange(this, { isLocal: false, oid: this.oid });
757
- };
754
+ private createFileContext = (key: any): EntityFileContext => ({
755
+ oid: this.oid,
756
+ key,
757
+ getFileRef: () => {
758
+ const file = this.rawView?.[key];
759
+ return isFileRef(file) ? file : null;
760
+ },
761
+ applyFileRef: (file) => {
762
+ if (this.readonlyKeys.includes(key as string)) {
763
+ throw new Error(`Cannot set readonly key ${key.toString()}`);
764
+ }
765
+ this.addPendingOperations(
766
+ this.isList
767
+ ? this.patchCreator.createListSet(this.oid, key, file)
768
+ : this.patchCreator.createSet(this.oid, key, file),
769
+ );
770
+ },
771
+ subscribe: (callback) => this.subscribe('change', callback),
772
+ onChange: () => this.deepChange(this, { isLocal: false, oid: this.oid }),
773
+ });
758
774
 
759
775
  private getChild = (key: any, oid: ObjectIdentifier) => {
760
776
  const schema = getChildFieldSchema(this.schema, key);
761
777
  if (!schema) {
778
+ let schemaString = '';
779
+ try {
780
+ schemaString = JSON.stringify(this.schema);
781
+ } catch {
782
+ schemaString = '<unable to stringify schema>';
783
+ }
762
784
  throw new Error(
763
- `No schema for key ${String(key)} in ${JSON.stringify(this.schema)}`,
785
+ `No schema for key ${String(key)} in ${schemaString} (${this.oid})`,
764
786
  );
765
787
  }
766
788
  return this.entityFamily.get({
@@ -828,7 +850,7 @@ export class Entity<
828
850
  const file = this.files.get(child.id, {
829
851
  downloadRemote: !!fieldSchema.downloadRemote,
830
852
  ctx: this.ctx,
831
- parent: this,
853
+ fileContext: this.createFileContext(key),
832
854
  });
833
855
 
834
856
  return file as KeyValue[Key];
@@ -1013,7 +1035,7 @@ export class Entity<
1013
1035
  });
1014
1036
  }
1015
1037
  }
1016
- return processValueFiles(value, (file) => this.files.add(file, this));
1038
+ return processValueFiles(value, (file) => this.files.add(file));
1017
1039
  };
1018
1040
 
1019
1041
  private getDeleteMode = (key: any) => {
@@ -389,7 +389,7 @@ export class EntityStore extends Disposable {
389
389
  'files to new entity',
390
390
  oid,
391
391
  );
392
- fileRefs.forEach((file) => this.files.add(file, entity));
392
+ fileRefs.forEach((file) => this.files.add(file));
393
393
  return entity;
394
394
  };
395
395
 
@@ -1,6 +1,11 @@
1
- import { EventSubscriber, FileData } from '@verdant-web/common';
1
+ import {
2
+ EventSubscriber,
3
+ FileData,
4
+ FileRef,
5
+ ObjectIdentifier,
6
+ PropertyName,
7
+ } from '@verdant-web/common';
2
8
  import { Context } from '../context/context.js';
3
- import { Entity } from '../entities/Entity.js';
4
9
 
5
10
  export type EntityFileEvents = {
6
11
  change: () => void;
@@ -9,12 +14,23 @@ export type EntityFileEvents = {
9
14
  export const UPDATE = Symbol('entity-file-update');
10
15
  export const MARK_FAILED = Symbol('entity-file-mark-failed');
11
16
 
12
- // this one goes on Entity
13
- export const CHILD_FILE_CHANGED = Symbol('child-file-changed');
17
+ export interface EntityFileContext {
18
+ readonly oid: ObjectIdentifier;
19
+ readonly key: PropertyName;
20
+ getFileRef(): FileRef | null;
21
+ applyFileRef(file: FileRef): void;
22
+ subscribe(callback: () => void): () => void;
23
+ onChange(): void;
24
+ }
14
25
 
15
26
  export type EntityFileSnapshot = {
16
27
  id: string;
17
28
  url?: string | null;
29
+ name: string;
30
+ remote: boolean;
31
+ type: string;
32
+ file?: Blob | null;
33
+ alt: string | null;
18
34
  };
19
35
 
20
36
  /**
@@ -30,26 +46,29 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
30
46
  private _failedReason: string | undefined;
31
47
  private _downloadRemote = false;
32
48
  private _uploaded = false;
49
+ private _alt: string | null = null;
33
50
  private ctx: Context;
34
51
  private unsubscribes: (() => void)[] = [];
35
- private parent: Entity;
52
+ private fileContext: EntityFileContext;
36
53
 
37
54
  constructor(
38
55
  public readonly id: string,
39
56
  {
40
57
  downloadRemote = false,
41
58
  ctx,
42
- parent,
59
+ fileContext,
43
60
  }: {
44
61
  downloadRemote?: boolean;
45
62
  ctx: Context;
46
- parent: Entity;
63
+ fileContext: EntityFileContext;
47
64
  },
48
65
  ) {
49
66
  super();
50
67
  this.ctx = ctx;
51
- this.parent = parent;
68
+ this.fileContext = fileContext;
52
69
  this._downloadRemote = downloadRemote;
70
+ this._alt = fileContext.getFileRef()?.alt ?? null;
71
+ this.unsubscribes.push(fileContext.subscribe(this.onContextChange));
53
72
 
54
73
  this.unsubscribes.push(
55
74
  this.ctx.internalEvents.subscribe(`fileUploaded:${id}`, this.onUploaded),
@@ -68,9 +87,31 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
68
87
  get error() {
69
88
  return this._failedReason || null;
70
89
  }
90
+ get alt() {
91
+ return this._alt;
92
+ }
93
+
94
+ setAlt = (alt: string | null) => {
95
+ const file = this.fileContext.getFileRef();
96
+ if (!file || file.id !== this.id) {
97
+ throw new Error(
98
+ 'Cannot set alt text on a file which is no longer present',
99
+ );
100
+ }
101
+ if ((file.alt ?? null) === alt) return;
102
+ this.fileContext.applyFileRef({ ...file, alt });
103
+ };
104
+
105
+ private onContextChange = () => {
106
+ const nextAlt = this.fileContext.getFileRef()?.alt ?? null;
107
+ if (nextAlt !== this._alt) {
108
+ this._alt = nextAlt;
109
+ this.emitChange();
110
+ }
111
+ };
71
112
 
72
113
  private emitChange() {
73
- this.parent[CHILD_FILE_CHANGED](this);
114
+ this.fileContext.onChange();
74
115
  this.emit('change');
75
116
  }
76
117
 
@@ -135,7 +176,7 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
135
176
  this.dispose();
136
177
  };
137
178
 
138
- getSnapshot(): FileData {
179
+ getSnapshot(): EntityFileSnapshot {
139
180
  return {
140
181
  id: this.id,
141
182
  url: this._fileData?.url ?? this._objectUrl ?? undefined,
@@ -143,6 +184,7 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
143
184
  remote: this._fileData?.remote ?? false,
144
185
  type: this.type ?? '',
145
186
  file: this._fileData?.file,
187
+ alt: this.alt,
146
188
  };
147
189
  }
148
190
  }
@@ -1,15 +1,20 @@
1
1
  import { FileData } from '@verdant-web/common';
2
2
  import { Context } from '../context/context.js';
3
- import { Entity } from '../entities/Entity.js';
4
3
  import { Disposable } from '../internal.js';
5
4
  import { Sync } from '../sync/Sync.js';
6
- import { EntityFile, MARK_FAILED, UPDATE } from './EntityFile.js';
5
+ import {
6
+ EntityFile,
7
+ EntityFileContext,
8
+ MARK_FAILED,
9
+ UPDATE,
10
+ } from './EntityFile.js';
7
11
 
8
12
  export class FileManager extends Disposable {
9
13
  private sync;
10
14
  private context;
11
15
 
12
- private cache = new Map<string, EntityFile>();
16
+ private cache = new Map<string, WeakRef<EntityFile>>();
17
+ private fileData = new Map<string, WeakRef<FileData>>();
13
18
 
14
19
  constructor({ sync, context }: { sync: Sync; context: Context }) {
15
20
  super();
@@ -23,24 +28,17 @@ export class FileManager extends Disposable {
23
28
  );
24
29
  }
25
30
 
26
- add = async (file: FileData, parent: Entity) => {
27
- // immediately cache the file
28
- let entityFile = this.cache.get(file.id);
29
- if (!entityFile) {
30
- entityFile = new EntityFile(file.id, { ctx: this.context, parent });
31
- this.cache.set(file.id, entityFile);
32
- }
33
-
31
+ add = async (file: FileData) => {
34
32
  if (!file.remote) {
35
33
  // immediately update local files.
36
- entityFile[UPDATE](file);
34
+ this.updateFileData(file);
37
35
  }
38
36
  // this will download any original remote file and trigger a re-upload to the
39
37
  // new file's identity, in addition to storing it on disk
40
38
  const processedFile = await (
41
39
  await this.context.files
42
40
  ).add(file, { cloneRemote: true });
43
- entityFile[UPDATE](processedFile);
41
+ this.updateFileData(processedFile);
44
42
  };
45
43
 
46
44
  /**
@@ -49,22 +47,49 @@ export class FileManager extends Disposable {
49
47
  */
50
48
  get = (
51
49
  id: string,
52
- options: { downloadRemote?: boolean; ctx: Context; parent: Entity },
50
+ options: {
51
+ downloadRemote?: boolean;
52
+ ctx: Context;
53
+ fileContext: EntityFileContext;
54
+ },
53
55
  ) => {
54
- if (this.cache.has(id)) {
55
- return this.cache.get(id)!;
56
+ const cacheKey = `${options.fileContext.oid}:${String(options.fileContext.key)}:${id}`;
57
+ const cachedFile = this.cache.get(cacheKey)?.deref();
58
+ if (cachedFile) {
59
+ return cachedFile;
56
60
  }
61
+ this.cache.delete(cacheKey);
57
62
  const file = new EntityFile(id, options);
58
- this.cache.set(id, file);
59
- this.load(file);
63
+ this.cache.set(cacheKey, this.context.weakRef(file));
64
+ const data = this.fileData.get(id)?.deref();
65
+ if (data) {
66
+ file[UPDATE](data);
67
+ } else {
68
+ this.fileData.delete(id);
69
+ this.load(file);
70
+ }
60
71
  return file;
61
72
  };
62
73
 
74
+ private updateFileData = (data: FileData) => {
75
+ this.fileData.set(data.id, this.context.weakRef(data));
76
+ for (const [key, fileRef] of this.cache) {
77
+ const file = fileRef.deref();
78
+ if (!file) {
79
+ this.cache.delete(key);
80
+ continue;
81
+ }
82
+ if (file.id === data.id) {
83
+ file[UPDATE](data);
84
+ }
85
+ }
86
+ };
87
+
63
88
  private load = async (file: EntityFile) => {
64
89
  const fileData = await (await this.context.files).get(file.id);
65
90
  // immediately apply any file data we have
66
91
  if (fileData) {
67
- file[UPDATE](fileData);
92
+ this.updateFileData(fileData);
68
93
  }
69
94
 
70
95
  if (!fileData?.url && (!fileData || fileData.remote)) {
@@ -96,7 +121,7 @@ export class FileManager extends Disposable {
96
121
  url: result.data.url,
97
122
  };
98
123
  await (await this.context.files).update(copyWithUrl);
99
- file[UPDATE](result.data);
124
+ this.updateFileData(result.data);
100
125
  } else {
101
126
  this.context.log('error', 'Failed to load file', result);
102
127
  if (!fileData) {
@@ -1,5 +1,5 @@
1
1
  import { EventSubscriber } from '@verdant-web/common';
2
- import { Context } from '../context/context.js';
2
+ import type { Context } from '../context/context.js';
3
3
  import { Entity } from '../entities/Entity.js';
4
4
  import { Disposable } from '../utils/Disposable.js';
5
5
  import {
@@ -8,6 +8,7 @@ import {
8
8
  QueryRun,
9
9
  QueryTiming,
10
10
  } from './diagnostics.js';
11
+ import { enqueueQueryRun } from './QueryBatcher.js';
11
12
  import { filterResultSet } from './utils.js';
12
13
 
13
14
  export type BaseQueryEvents = {
@@ -28,11 +29,7 @@ export type QueryStatus = 'initial' | 'initializing' | 'revalidating' | 'ready';
28
29
  export const ON_ALL_UNSUBSCRIBED = Symbol('ON_ALL_UNSUBSCRIBED');
29
30
  export const UPDATE = Symbol('UPDATE');
30
31
 
31
- // export interface BaseQuery<T> {
32
- // subscribe(event: 'change', callback: (value: T) => void): () => void;
33
- // subscribe(event: 'statusChange', callback: (status: QueryStatus) => void): () => void;
34
- // subscribe(callback: (value: T) => void): () => void;
35
- // }
32
+ const REVALIDATION_DEBOUNCE = 50;
36
33
 
37
34
  export abstract class BaseQuery<T> extends Disposable {
38
35
  private _rawValue;
@@ -50,6 +47,8 @@ export abstract class BaseQuery<T> extends Disposable {
50
47
  };
51
48
  private _startedAt: number | null = null;
52
49
  private _timingHistory: QueryRun[] = [];
50
+ private _revalidationTimeout: ReturnType<typeof setTimeout> | undefined;
51
+ private _needsTrailingRevalidation = false;
53
52
 
54
53
  protected context;
55
54
 
@@ -80,6 +79,7 @@ export abstract class BaseQuery<T> extends Disposable {
80
79
  this.key = key;
81
80
  this.collection = collection;
82
81
  this.type = this.constructor.name;
82
+ this.addDispose(() => clearTimeout(this._revalidationTimeout));
83
83
  const shouldUpdateFn =
84
84
  shouldUpdate ||
85
85
  ((collections: string[]) => collections.includes(collection));
@@ -88,11 +88,7 @@ export abstract class BaseQuery<T> extends Disposable {
88
88
  'collectionsChanged',
89
89
  (collections) => {
90
90
  if (shouldUpdateFn(collections)) {
91
- this.context.log('info', 'Updating query', this.key);
92
- // immediately refilter the result set - deleted
93
- // entities will already be nulled out
94
- // this.refreshValue();
95
- this.execute();
91
+ this.revalidate();
96
92
  }
97
93
  },
98
94
  ),
@@ -100,6 +96,23 @@ export abstract class BaseQuery<T> extends Disposable {
100
96
  // TODO: subscribe to document changes and update if necessary.
101
97
  }
102
98
 
99
+ private revalidate = () => {
100
+ if (this._revalidationTimeout !== undefined) {
101
+ this._needsTrailingRevalidation = true;
102
+ clearTimeout(this._revalidationTimeout);
103
+ } else {
104
+ this.execute();
105
+ }
106
+
107
+ this._revalidationTimeout = setTimeout(() => {
108
+ this._revalidationTimeout = undefined;
109
+ if (this._needsTrailingRevalidation) {
110
+ this._needsTrailingRevalidation = false;
111
+ this.execute();
112
+ }
113
+ }, REVALIDATION_DEBOUNCE);
114
+ };
115
+
103
116
  get current() {
104
117
  return this._value;
105
118
  }
@@ -249,7 +262,7 @@ export abstract class BaseQuery<T> extends Disposable {
249
262
  }
250
263
  // no status change needed if already in a 'running' status.
251
264
 
252
- this._executionPromise = this.run()
265
+ this._executionPromise = enqueueQueryRun(this.context, this.run)
253
266
  .then(() => this._value)
254
267
  .catch((err) => {
255
268
  if (err instanceof Error) {
@@ -0,0 +1,54 @@
1
+ import type { Context } from '../context/context.js';
2
+
3
+ const BATCH_SIZE = 20;
4
+
5
+ type QueuedRun = {
6
+ run: () => Promise<any>;
7
+ resolve: (value: any) => void;
8
+ reject: (reason?: any) => void;
9
+ };
10
+
11
+ class QueryBatcher {
12
+ private queue: QueuedRun[] = [];
13
+ private scheduled = false;
14
+
15
+ enqueue<T>(run: () => Promise<T>): Promise<T> {
16
+ const promise = new Promise<T>((resolve, reject) => {
17
+ this.queue.push({ run, resolve, reject });
18
+ });
19
+
20
+ if (!this.scheduled) {
21
+ this.scheduled = true;
22
+ queueMicrotask(this.flush);
23
+ }
24
+
25
+ return promise;
26
+ }
27
+
28
+ private flush = () => {
29
+ const batch = this.queue.splice(0, BATCH_SIZE);
30
+ for (const { run, resolve, reject } of batch) {
31
+ run().then(resolve, reject);
32
+ }
33
+
34
+ if (this.queue.length) {
35
+ setTimeout(this.flush, 0);
36
+ } else {
37
+ this.scheduled = false;
38
+ }
39
+ };
40
+ }
41
+
42
+ const batchers = new WeakMap<Context, QueryBatcher>();
43
+
44
+ export function enqueueQueryRun<T>(
45
+ context: Context,
46
+ run: () => Promise<T>,
47
+ ): Promise<T> {
48
+ let batcher = batchers.get(context);
49
+ if (!batcher) {
50
+ batcher = new QueryBatcher();
51
+ batchers.set(context, batcher);
52
+ }
53
+ return batcher.enqueue(run);
54
+ }