@verdant-web/store 3.8.3 → 3.9.0-next.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 (106) hide show
  1. package/dist/bundle/index.js +8 -8
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/esm/IDBService.d.ts +1 -1
  4. package/dist/esm/IDBService.js +7 -2
  5. package/dist/esm/IDBService.js.map +1 -1
  6. package/dist/esm/__tests__/entities.test.js +21 -16
  7. package/dist/esm/__tests__/entities.test.js.map +1 -1
  8. package/dist/esm/__tests__/fixtures/testStorage.d.ts +4 -0
  9. package/dist/esm/__tests__/fixtures/testStorage.js +3 -0
  10. package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
  11. package/dist/esm/authorization.d.ts +4 -0
  12. package/dist/esm/authorization.js +6 -0
  13. package/dist/esm/authorization.js.map +1 -0
  14. package/dist/esm/client/Client.d.ts +29 -1
  15. package/dist/esm/client/Client.js +25 -2
  16. package/dist/esm/client/Client.js.map +1 -1
  17. package/dist/esm/context.d.ts +6 -0
  18. package/dist/esm/{DocumentManager.d.ts → entities/DocumentManager.d.ts} +12 -6
  19. package/dist/esm/entities/DocumentManager.js +77 -0
  20. package/dist/esm/entities/DocumentManager.js.map +1 -0
  21. package/dist/esm/entities/Entity.d.ts +8 -0
  22. package/dist/esm/entities/Entity.js +23 -3
  23. package/dist/esm/entities/Entity.js.map +1 -1
  24. package/dist/esm/entities/EntityMetadata.d.ts +1 -0
  25. package/dist/esm/entities/EntityMetadata.js +18 -3
  26. package/dist/esm/entities/EntityMetadata.js.map +1 -1
  27. package/dist/esm/entities/EntityStore.d.ts +6 -4
  28. package/dist/esm/entities/EntityStore.js +22 -10
  29. package/dist/esm/entities/EntityStore.js.map +1 -1
  30. package/dist/esm/entities/types.d.ts +1 -0
  31. package/dist/esm/files/EntityFile.d.ts +1 -1
  32. package/dist/esm/files/EntityFile.js +7 -1
  33. package/dist/esm/files/EntityFile.js.map +1 -1
  34. package/dist/esm/files/FileManager.d.ts +11 -2
  35. package/dist/esm/files/FileManager.js +45 -8
  36. package/dist/esm/files/FileManager.js.map +1 -1
  37. package/dist/esm/files/FileStorage.d.ts +6 -0
  38. package/dist/esm/files/FileStorage.js +6 -1
  39. package/dist/esm/files/FileStorage.js.map +1 -1
  40. package/dist/esm/files/utils.d.ts +1 -2
  41. package/dist/esm/files/utils.js +11 -5
  42. package/dist/esm/files/utils.js.map +1 -1
  43. package/dist/esm/index.d.ts +1 -0
  44. package/dist/esm/index.js +1 -0
  45. package/dist/esm/index.js.map +1 -1
  46. package/dist/esm/metadata/LocalReplicaStore.d.ts +1 -0
  47. package/dist/esm/metadata/LocalReplicaStore.js +1 -0
  48. package/dist/esm/metadata/LocalReplicaStore.js.map +1 -1
  49. package/dist/esm/metadata/MessageCreator.js +4 -16
  50. package/dist/esm/metadata/MessageCreator.js.map +1 -1
  51. package/dist/esm/metadata/Metadata.d.ts +8 -0
  52. package/dist/esm/metadata/Metadata.js +32 -0
  53. package/dist/esm/metadata/Metadata.js.map +1 -1
  54. package/dist/esm/metadata/OperationsStore.js +3 -3
  55. package/dist/esm/metadata/OperationsStore.js.map +1 -1
  56. package/dist/esm/migration/engine.js +12 -2
  57. package/dist/esm/migration/engine.js.map +1 -1
  58. package/dist/esm/queries/CollectionQueries.d.ts +8 -2
  59. package/dist/esm/queries/CollectionQueries.js +2 -1
  60. package/dist/esm/queries/CollectionQueries.js.map +1 -1
  61. package/dist/esm/sync/FileSync.d.ts +1 -0
  62. package/dist/esm/sync/FileSync.js +5 -2
  63. package/dist/esm/sync/FileSync.js.map +1 -1
  64. package/dist/esm/sync/PushPullSync.d.ts +2 -1
  65. package/dist/esm/sync/PushPullSync.js +10 -6
  66. package/dist/esm/sync/PushPullSync.js.map +1 -1
  67. package/dist/esm/sync/ServerSyncEndpointProvider.d.ts +10 -1
  68. package/dist/esm/sync/ServerSyncEndpointProvider.js +13 -2
  69. package/dist/esm/sync/ServerSyncEndpointProvider.js.map +1 -1
  70. package/dist/esm/sync/Sync.d.ts +5 -4
  71. package/dist/esm/sync/Sync.js +22 -7
  72. package/dist/esm/sync/Sync.js.map +1 -1
  73. package/dist/esm/sync/WebSocketSync.d.ts +2 -1
  74. package/dist/esm/sync/WebSocketSync.js +5 -2
  75. package/dist/esm/sync/WebSocketSync.js.map +1 -1
  76. package/package.json +6 -6
  77. package/src/IDBService.ts +8 -4
  78. package/src/__tests__/entities.test.ts +29 -5
  79. package/src/__tests__/fixtures/testStorage.ts +3 -0
  80. package/src/authorization.ts +6 -0
  81. package/src/client/Client.ts +37 -6
  82. package/src/context.ts +6 -0
  83. package/src/entities/DocumentManager.ts +154 -0
  84. package/src/entities/Entity.ts +26 -2
  85. package/src/entities/EntityMetadata.ts +22 -0
  86. package/src/entities/EntityStore.ts +29 -11
  87. package/src/entities/types.ts +1 -0
  88. package/src/files/EntityFile.ts +6 -2
  89. package/src/files/FileManager.ts +57 -9
  90. package/src/files/FileStorage.ts +7 -1
  91. package/src/files/utils.ts +17 -8
  92. package/src/index.ts +1 -0
  93. package/src/metadata/LocalReplicaStore.ts +2 -0
  94. package/src/metadata/MessageCreator.ts +4 -15
  95. package/src/metadata/Metadata.ts +37 -0
  96. package/src/metadata/OperationsStore.ts +3 -3
  97. package/src/migration/engine.ts +14 -2
  98. package/src/queries/CollectionQueries.ts +23 -4
  99. package/src/sync/FileSync.ts +6 -7
  100. package/src/sync/PushPullSync.ts +7 -2
  101. package/src/sync/ServerSyncEndpointProvider.ts +22 -2
  102. package/src/sync/Sync.ts +27 -6
  103. package/src/sync/WebSocketSync.ts +6 -2
  104. package/dist/esm/DocumentManager.js +0 -46
  105. package/dist/esm/DocumentManager.js.map +0 -1
  106. package/src/DocumentManager.ts +0 -97
package/src/IDBService.ts CHANGED
@@ -81,7 +81,7 @@ export class IDBService extends Disposable {
81
81
  iterate = async <T>(
82
82
  storeName: string,
83
83
  getRequest: (store: IDBObjectStore) => IDBRequest | IDBRequest[],
84
- iterator: (value: T, store: IDBObjectStore) => void,
84
+ iterator: (value: T, store: IDBObjectStore) => boolean | void,
85
85
  opts?: {
86
86
  mode?: 'readonly' | 'readwrite';
87
87
  transaction?: IDBTransaction;
@@ -117,10 +117,14 @@ export class IDBService extends Disposable {
117
117
  }
118
118
  return new Promise<void>((resolve, reject) => {
119
119
  request.onsuccess = () => {
120
- const cursor = request.result;
120
+ const cursor = request.result as IDBCursorWithValue | null;
121
121
  if (cursor) {
122
- iterator(cursor.value, store);
123
- cursor.continue();
122
+ const stop = iterator(cursor.value, store);
123
+ if (stop) {
124
+ resolve();
125
+ } else {
126
+ cursor.continue();
127
+ }
124
128
  } else {
125
129
  resolve();
126
130
  }
@@ -492,8 +492,7 @@ describe('entities', () => {
492
492
  ],
493
493
  });
494
494
 
495
- const { id, ...snapshot } = item1.getSnapshot();
496
- const item2 = await storage.todos.put(snapshot);
495
+ const item2 = await storage.todos.clone(item1);
497
496
 
498
497
  expect(item2.get('tags').length).toBe(1);
499
498
  expect(item2.get('attachments').length).toBe(1);
@@ -501,6 +500,7 @@ describe('entities', () => {
501
500
  item2.get('attachments').get(0).set('name', 'attachment 2');
502
501
 
503
502
  expect(item1.get('attachments').get(0).get('name')).toBe('attachment 1');
503
+ expect(item1.uid).not.toBe(item2.uid);
504
504
  });
505
505
 
506
506
  it('should not allow modifying the primary key', async () => {
@@ -634,13 +634,37 @@ describe('entities', () => {
634
634
 
635
635
  it('should error on invalid values passed to initialization', async () => {
636
636
  const storage = await createTestStorage();
637
- expect(() => {
638
- storage.todos.put({
637
+ expect(async () => {
638
+ await storage.todos.put({
639
639
  content: { invalid: 'value' },
640
640
  });
641
+ }).rejects.toThrowErrorMatchingInlineSnapshot(
642
+ `[Error: Validation error: Expected string for field content, got [object Object]]`,
643
+ );
644
+ });
645
+
646
+ it('should only allow valid values for file fields', async () => {
647
+ const storage = await createTestStorage();
648
+ const weird = await storage.weirds.put({});
649
+
650
+ expect(() => {
651
+ weird.set('file', { invalid: 'value' });
641
652
  }).toThrowErrorMatchingInlineSnapshot(
642
- `[Error: Validation error: Expected string for field content, got [object Object]]`,
653
+ `[Error: Validation error: Expected file or null for field file, got [object Object]]`,
643
654
  );
655
+
656
+ // valid options
657
+ weird.set(
658
+ 'file',
659
+ new window.File(['d(⌐□_□)b'], 'test.txt', { type: 'text/plain' }),
660
+ );
661
+ weird.set('file', {
662
+ id: 'abc',
663
+ type: 'text/plain',
664
+ name: 'foo.txt',
665
+ url: 'http://example.com/foo.txt',
666
+ remote: true,
667
+ });
644
668
  });
645
669
 
646
670
  it('should allow subscribing to one field', async () => {
@@ -83,6 +83,9 @@ export const weirdCollection = schema.collection({
83
83
  items: schema.fields.string(),
84
84
  }),
85
85
  }),
86
+ file: schema.fields.file({
87
+ nullable: true,
88
+ }),
86
89
  },
87
90
  });
88
91
 
@@ -0,0 +1,6 @@
1
+ import { authz } from '@verdant-web/common';
2
+
3
+ export const authorization = {
4
+ private: authz.onlyMe(),
5
+ public: undefined,
6
+ };
@@ -6,7 +6,7 @@ import {
6
6
  Operation,
7
7
  } from '@verdant-web/common';
8
8
  import { Context } from '../context.js';
9
- import { DocumentManager } from '../DocumentManager.js';
9
+ import { DocumentManager } from '../entities/DocumentManager.js';
10
10
  import { EntityStore } from '../entities/EntityStore.js';
11
11
  import { FileManager, FileManagerConfig } from '../files/FileManager.js';
12
12
  import { ReturnedFileData } from '../files/FileStorage.js';
@@ -44,6 +44,12 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
44
44
  * This event may be called multiple times.
45
45
  */
46
46
  futureSeen: () => void;
47
+ /**
48
+ * The server requested this replica reset its state
49
+ * completely. This can happen when the replica has
50
+ * been offline for too long and reconnects.
51
+ */
52
+ resetToServer: () => void;
47
53
  }> {
48
54
  readonly meta: Metadata;
49
55
  private _entities: EntityStore;
@@ -106,16 +112,15 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
106
112
  this._queryCache = new QueryCache({
107
113
  context,
108
114
  });
109
- this._documentManager = new DocumentManager(
110
- this.meta,
111
- this.schema,
112
- this._entities,
113
- );
115
+ this._documentManager = new DocumentManager(this.schema, this._entities);
114
116
 
115
117
  const notifyFutureSeen = debounce(() => {
116
118
  this.emit('futureSeen');
117
119
  }, 300);
118
120
  this.context.globalEvents.subscribe('futureSeen', notifyFutureSeen);
121
+ this.context.globalEvents.subscribe('resetToServer', () => {
122
+ this.emit('resetToServer');
123
+ });
119
124
 
120
125
  this.documentDb.addEventListener('versionchange', () => {
121
126
  this.context.log?.(
@@ -222,6 +227,8 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
222
227
  ? await navigator.storage.estimate()
223
228
  : undefined;
224
229
 
230
+ const files = await this._fileManager.stats();
231
+
225
232
  // determine data:metadata ratio for total size of all collections vs metadata
226
233
  const totalCollectionsSize = Object.values(collections).reduce(
227
234
  (acc, { size }) => acc + size,
@@ -237,6 +244,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
237
244
  totalMetaSize,
238
245
  totalCollectionsSize,
239
246
  metaToDataRatio,
247
+ files,
240
248
  quotaUsage:
241
249
  storage?.usage && storage?.quota
242
250
  ? storage.usage / storage.quota
@@ -424,6 +432,26 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
424
432
  const exportData = await this.export();
425
433
  await this.import(exportData);
426
434
  };
435
+
436
+ /**
437
+ * Immediately runs the file deletion process. This is useful
438
+ * for testing, mostly. Or if your client is long-lived, since
439
+ * normally this cleanup only runs on startup.
440
+ *
441
+ * Note this still follows the file deletion heuristic configured
442
+ * on the client. So if you clean up files 3 days after delete,
443
+ * invoking this manually will not skip that 3 day waiting period.
444
+ */
445
+ __cleanupFilesImmediately = () => {
446
+ return this._fileManager.tryCleanupDeletedFiles();
447
+ };
448
+
449
+ /**
450
+ * Manually triggers storage rebasing. Follows normal
451
+ * rebasing rules. Rebases already happen automatically
452
+ * during normal operation, so you probably don't need this.
453
+ */
454
+ __manualRebase = () => this.meta.manualRebase();
427
455
  }
428
456
 
429
457
  export interface ClientStats {
@@ -432,6 +460,9 @@ export interface ClientStats {
432
460
  baselinesSize: { count: number; size: number };
433
461
  operationsSize: { count: number; size: number };
434
462
  };
463
+ files: {
464
+ size: { count: number; size: number };
465
+ };
435
466
  storage: StorageEstimate | undefined;
436
467
  totalMetaSize: number;
437
468
  totalCollectionsSize: number;
package/src/context.ts CHANGED
@@ -36,6 +36,12 @@ export interface Context {
36
36
  * The parameter is the timestamp of the future change.
37
37
  */
38
38
  futureSeen: (timestamp: string) => void;
39
+ /**
40
+ * The server requested this replica reset its state
41
+ * completely. This can happen when the replica has
42
+ * been offline for too long and reconnects.
43
+ */
44
+ resetToServer: () => void;
39
45
  }>;
40
46
  weakRef<T extends object>(value: T): WeakRef<T>;
41
47
  migrations: Migration<any>[];
@@ -0,0 +1,154 @@
1
+ import {
2
+ addFieldDefaults,
3
+ constrainEntity,
4
+ assert,
5
+ createOid,
6
+ SchemaCollection,
7
+ StorageCollectionSchema,
8
+ StorageDocument,
9
+ StorageSchema,
10
+ isRootOid,
11
+ AuthorizationKey,
12
+ } from '@verdant-web/common';
13
+ import { EntityCreateOptions, EntityStore } from '../entities/EntityStore.js';
14
+ import { Metadata } from '../metadata/Metadata.js';
15
+ import { Sync } from '../sync/Sync.js';
16
+ import { Context } from '../context.js';
17
+ import { ObjectEntity } from '../index.js';
18
+
19
+ /**
20
+ * Exposes functionality for creating documents,
21
+ * the only mutation which is available as an entry
22
+ * point in the storage system.
23
+ */
24
+ export class DocumentManager<Schema extends StorageSchema<any>> {
25
+ constructor(
26
+ private schema: Schema,
27
+ private entities: EntityStore,
28
+ ) {}
29
+
30
+ private getOid = (collection: string, init: any) => {
31
+ const primaryKeyName = this.schema.collections[collection]
32
+ .primaryKey as Exclude<
33
+ keyof StorageDocument<SchemaCollection<Schema, any>>,
34
+ symbol
35
+ >;
36
+ const primaryKey = init[primaryKeyName];
37
+ assert(
38
+ primaryKey,
39
+ `Document must have a primary key: ${primaryKeyName.toString()} (got: ${JSON.stringify(
40
+ init,
41
+ )})`,
42
+ );
43
+ return createOid(collection, primaryKey);
44
+ };
45
+
46
+ private addDefaults = (collectionName: string, init: any) => {
47
+ const collection = this.schema.collections[
48
+ collectionName
49
+ ] as StorageCollectionSchema;
50
+ return addFieldDefaults(collection, init);
51
+ };
52
+
53
+ private validate = (collectionName: string, init: any) => {
54
+ const collection = this.schema.collections[
55
+ collectionName
56
+ ] as StorageCollectionSchema;
57
+ return constrainEntity(collection.fields, init);
58
+ };
59
+
60
+ create = async (
61
+ collection: string,
62
+ init: any,
63
+ options: {
64
+ undoable?: boolean;
65
+ access?: AuthorizationKey;
66
+ silenceAccessControlWithPrimaryKeyWarning?: boolean;
67
+ } = {},
68
+ ) => {
69
+ const widenedOptions = options as EntityCreateOptions;
70
+ const defaulted = this.addDefaults(collection, init);
71
+ const validated = this.validate(collection, defaulted);
72
+ const oid = this.getOid(collection, validated);
73
+
74
+ if (options.access) {
75
+ const collectionSchema = this.schema.collections[collection];
76
+ if (
77
+ options.access !== 'shared' &&
78
+ init[collectionSchema.primaryKey] &&
79
+ !options.silenceAccessControlWithPrimaryKeyWarning
80
+ ) {
81
+ // using a custom primary key with access control is not supported.
82
+ // resulting docs could collide with existing docs with different permissions,
83
+ // leading to confusing results. this logs a warning.
84
+ console.warn(
85
+ `Using a custom primary key with access control is not supported. This may result in corrupted documents. Read more about why: https://verdant.dev/docs/sync/access#a-warning-about-custom-primaryKey`,
86
+ );
87
+ }
88
+ widenedOptions.access = options.access;
89
+ }
90
+
91
+ // documents are always objects at the root
92
+ return this.entities.create(validated, oid, widenedOptions) as any;
93
+ };
94
+
95
+ delete = async (
96
+ collection: string,
97
+ primaryKey: string,
98
+ options: { undoable?: boolean } = {},
99
+ ) => {
100
+ const oid = createOid(collection, primaryKey);
101
+ return this.entities.delete(oid, options);
102
+ };
103
+
104
+ deleteAll = async (
105
+ ids: [string, string][],
106
+ options: { undoable?: boolean } = {},
107
+ ) => {
108
+ return this.entities.deleteAll(
109
+ ids.map(([collection, primaryKey]) => createOid(collection, primaryKey)),
110
+ options,
111
+ );
112
+ };
113
+
114
+ deleteAllFromCollection = async (
115
+ collection: string,
116
+ ids: string[],
117
+ options: { undoable?: boolean } = {},
118
+ ) => {
119
+ return this.entities.deleteAll(
120
+ ids.map((primaryKey) => createOid(collection, primaryKey)),
121
+ options,
122
+ );
123
+ };
124
+
125
+ clone = async (
126
+ collection: string,
127
+ entity: ObjectEntity<any, any>,
128
+ options: {
129
+ undoable?: boolean;
130
+ access?: AuthorizationKey;
131
+ primaryKey?: string;
132
+ } = {},
133
+ ) => {
134
+ if (!isRootOid(entity.uid)) {
135
+ throw new Error('Cannot clone non-root documents');
136
+ }
137
+ // take the entity snapshot
138
+ const snapshot = entity.getSnapshot();
139
+ // remove the primary key
140
+ const collectionSchema = this.schema.collections[collection];
141
+ delete snapshot[collectionSchema.primaryKey];
142
+ // if collection schema's primary key doesn't have a default value,
143
+ // a user-supplied value is required
144
+ if (!collectionSchema.fields[collectionSchema.primaryKey].default) {
145
+ if (!options.primaryKey) {
146
+ throw new Error(
147
+ `Error cloning document from collection ${collection}: collection does not have a default on primary key. You must supply a value to options.primaryKey for the clone.`,
148
+ );
149
+ }
150
+ snapshot[collectionSchema.primaryKey] = options.primaryKey;
151
+ }
152
+ return this.create(collection, snapshot, options);
153
+ };
154
+ }
@@ -17,6 +17,7 @@ import {
17
17
  getDefault,
18
18
  hasDefault,
19
19
  isFileRef,
20
+ isFile,
20
21
  isNullable,
21
22
  isObject,
22
23
  isObjectRef,
@@ -29,7 +30,7 @@ import {
29
30
  } from '@verdant-web/common';
30
31
  import { Context } from '../context.js';
31
32
  import { FileManager } from '../files/FileManager.js';
32
- import { isFile, processValueFiles } from '../files/utils.js';
33
+ import { processValueFiles } from '../files/utils.js';
33
34
  import { EntityFile } from '../index.js';
34
35
  import { EntityCache } from './EntityCache.js';
35
36
  import { EntityFamilyMetadata, EntityMetadataView } from './EntityMetadata.js';
@@ -382,6 +383,19 @@ export class Entity<
382
383
  return this.ctx.namespace;
383
384
  }
384
385
 
386
+ /**
387
+ * The authz string signifying the permissions this entity has.
388
+ * On the client (where we are) it's only ever possible to see
389
+ * an entity with either full access or access for the current
390
+ * user.
391
+ */
392
+ get access() {
393
+ return this.viewData.authz;
394
+ }
395
+ get isAuthorized() {
396
+ return !!this.access;
397
+ }
398
+
385
399
  /**
386
400
  * Pruning - when entities have invalid children, we 'prune' that
387
401
  * data up to the nearest prunable point - a nullable field,
@@ -443,6 +457,14 @@ export class Entity<
443
457
  // change management methods (internal use only)
444
458
  private addPendingOperations = (operations: Operation[]) => {
445
459
  this.ctx.log('debug', 'Entity: adding pending operations', this.oid);
460
+
461
+ // apply authz to all operations
462
+ if (this.access) {
463
+ for (const op of operations) {
464
+ op.authz = this.access;
465
+ }
466
+ }
467
+
446
468
  const changes = this.metadataFamily.addPendingData(operations);
447
469
  for (const change of changes) {
448
470
  this.change(change);
@@ -708,7 +730,9 @@ export class Entity<
708
730
  if (validationError) {
709
731
  // TODO: is it a good idea to throw an error here? a runtime error won't be that helpful,
710
732
  // but also we don't really want invalid data supplied.
711
- throw new Error(validationError.message);
733
+ throw new Error(`Validation error: ${validationError.message}`, {
734
+ cause: validationError,
735
+ });
712
736
  }
713
737
  }
714
738
  return processValueFiles(value, this.files.add);
@@ -20,6 +20,7 @@ export type EntityMetadataView = {
20
20
  empty: boolean;
21
21
  updatedAt: number;
22
22
  latestTimestamp: string | null;
23
+ authz?: string;
23
24
  };
24
25
 
25
26
  export class EntityMetadata {
@@ -63,6 +64,11 @@ export class EntityMetadata {
63
64
  computeView = (omitPending = false): EntityMetadataView => {
64
65
  const base = cloneDeep(this.baseline?.snapshot ?? undefined);
65
66
  const baselineTimestamp = this.baseline?.timestamp ?? null;
67
+
68
+ // start with the baseline authz, if any. further init ops
69
+ // may overwrite this.
70
+ let authz = this.baseline?.authz;
71
+
66
72
  const confirmedResult = this.applyOperations(
67
73
  // apply ops to baseline
68
74
  base,
@@ -79,6 +85,10 @@ export class EntityMetadata {
79
85
  if (confirmedResult.futureSeen) {
80
86
  this.ctx.globalEvents.emit('futureSeen', confirmedResult.futureSeen);
81
87
  }
88
+ if (confirmedResult.authz) {
89
+ authz = confirmedResult.authz;
90
+ }
91
+
82
92
  const pendingResult = omitPending
83
93
  ? confirmedResult
84
94
  : this.applyOperations(
@@ -92,6 +102,10 @@ export class EntityMetadata {
92
102
  // logically in the future
93
103
  null,
94
104
  );
105
+ if (pendingResult.authz) {
106
+ authz = pendingResult.authz;
107
+ }
108
+
95
109
  // before letting this data out into the wild, we need
96
110
  // to associate its oid
97
111
  if (pendingResult.view) {
@@ -138,6 +152,7 @@ export class EntityMetadata {
138
152
  fromOlderVersion,
139
153
  updatedAt,
140
154
  latestTimestamp: updatedAtTimestamp,
155
+ authz,
141
156
  };
142
157
  };
143
158
 
@@ -207,8 +222,11 @@ export class EntityMetadata {
207
222
  latestTimestamp: string | null;
208
223
  deleted: boolean;
209
224
  futureSeen: string | undefined;
225
+ authz?: string;
210
226
  } => {
211
227
  let futureSeen: string | undefined = undefined;
228
+ let authz: string | undefined = undefined;
229
+
212
230
  const now = this.ctx.getNow();
213
231
  for (const op of operations) {
214
232
  // ignore ops before our after cutoff
@@ -229,6 +247,9 @@ export class EntityMetadata {
229
247
  base = applyPatch(base, op.data);
230
248
  if (op.data.op === 'initialize') {
231
249
  deleted = false;
250
+ if (op.authz) {
251
+ authz = op.authz;
252
+ }
232
253
  }
233
254
  }
234
255
 
@@ -242,6 +263,7 @@ export class EntityMetadata {
242
263
  latestTimestamp: latestTimestamp ?? null,
243
264
  deleted,
244
265
  futureSeen,
266
+ authz,
245
267
  };
246
268
  };
247
269
  }
@@ -13,6 +13,7 @@ import {
13
13
  groupPatchesByRootOid,
14
14
  isRootOid,
15
15
  removeOidsFromAllSubObjects,
16
+ AuthorizationKey,
16
17
  } from '@verdant-web/common';
17
18
  import { Context } from '../context.js';
18
19
  import { Metadata } from '../metadata/Metadata.js';
@@ -49,6 +50,11 @@ export interface IncomingData {
49
50
  isLocal?: boolean;
50
51
  }
51
52
 
53
+ export interface EntityCreateOptions {
54
+ undoable?: boolean;
55
+ access?: AuthorizationKey;
56
+ }
57
+
52
58
  export class EntityStore extends Disposable {
53
59
  private ctx;
54
60
  private meta;
@@ -123,6 +129,7 @@ export class EntityStore extends Disposable {
123
129
  this.abortDataQueueController = new AbortController();
124
130
  this.ongoingResetPromise = this.resetData().finally(() => {
125
131
  this.ongoingResetPromise = null;
132
+ this.ctx.globalEvents.emit('resetToServer');
126
133
  });
127
134
  }
128
135
 
@@ -319,7 +326,7 @@ export class EntityStore extends Disposable {
319
326
  create = async (
320
327
  initial: any,
321
328
  oid: ObjectIdentifier,
322
- { undoable = true }: { undoable?: boolean } = {},
329
+ { undoable = true, access }: EntityCreateOptions = {},
323
330
  ) => {
324
331
  this.ctx.log('debug', 'Creating new entity', oid);
325
332
  const { collection } = decomposeOid(oid);
@@ -339,17 +346,23 @@ export class EntityStore extends Disposable {
339
346
  }
340
347
 
341
348
  const operations = this.meta.patchCreator.createInitialize(processed, oid);
349
+ if (access) {
350
+ operations.forEach((op) => {
351
+ op.authz = access;
352
+ });
353
+ }
342
354
  await this.batcher.commitOperations(operations, {
343
355
  undoable: !!undoable,
344
356
  source: entity,
345
357
  });
346
358
 
347
- // TODO: what happens if you create an entity with an OID that already
359
+ // TODONE: what happens if you create an entity with an OID that already
348
360
  // exists?
361
+ // A: it will overwrite the existing entity
349
362
 
350
363
  // we still need to synchronously add the initial operations to the Entity
351
364
  // even though they are flowing through the system
352
- // TODO: this could be better aligned to avoid grouping here
365
+ // FIXME: this could be better aligned to avoid grouping here
353
366
  const operationsGroupedByOid = groupPatchesByOid(operations);
354
367
  this.events.add.invoke(this, {
355
368
  operations: operationsGroupedByOid,
@@ -371,12 +384,7 @@ export class EntityStore extends Disposable {
371
384
  'Only root documents may be deleted via client methods',
372
385
  );
373
386
 
374
- const allOids = await Promise.all(
375
- oids.flatMap(async (oid) => {
376
- const entity = await this.hydrate(oid);
377
- return entity?.__getFamilyOids__() ?? [];
378
- }),
379
- );
387
+ const entities = await Promise.all(oids.map((oid) => this.hydrate(oid)));
380
388
 
381
389
  // remove the entities from cache
382
390
  oids.forEach((oid) => {
@@ -384,8 +392,18 @@ export class EntityStore extends Disposable {
384
392
  this.ctx.log('debug', 'Deleted document from cache', oid);
385
393
  });
386
394
 
387
- // create the delete patches and wait for them to be applied
388
- const operations = this.meta.patchCreator.createDeleteAll(allOids.flat());
395
+ const operations: Operation[] = [];
396
+ for (const entity of entities) {
397
+ if (entity) {
398
+ const oids = entity.__getFamilyOids__();
399
+ const deletes = this.meta.patchCreator.createDeleteAll(oids);
400
+ for (const op of deletes) {
401
+ op.authz = entity.access;
402
+ }
403
+ operations.push(...deletes);
404
+ }
405
+ }
406
+
389
407
  await this.batcher.commitOperations(operations, {
390
408
  undoable: options?.undoable === undefined ? true : options.undoable,
391
409
  });
@@ -78,6 +78,7 @@ export interface BaseEntity<
78
78
  readonly deleted: boolean;
79
79
  readonly updatedAt: number;
80
80
  readonly uid: string;
81
+ readonly isAuthorized: boolean;
81
82
  }
82
83
 
83
84
  export type DeepPartial<T> = T extends object
@@ -101,10 +101,14 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
101
101
  this.dispose();
102
102
  };
103
103
 
104
- getSnapshot(): EntityFileSnapshot {
104
+ getSnapshot(): FileData {
105
+ if (this._fileData) return this._fileData;
105
106
  return {
106
107
  id: this.id,
107
- url: this.loading || this.failed ? undefined : this.url,
108
+ url: this._objectUrl ?? undefined,
109
+ name: this.name ?? 'unknown-file',
110
+ remote: false,
111
+ type: this.type ?? '',
108
112
  };
109
113
  }
110
114
  }