@verdant-web/store 3.8.4 → 3.9.0-next.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.
Files changed (104) 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 +23 -1
  15. package/dist/esm/client/Client.js +22 -2
  16. package/dist/esm/client/Client.js.map +1 -1
  17. package/dist/esm/{DocumentManager.d.ts → entities/DocumentManager.d.ts} +12 -6
  18. package/dist/esm/entities/DocumentManager.js +77 -0
  19. package/dist/esm/entities/DocumentManager.js.map +1 -0
  20. package/dist/esm/entities/Entity.d.ts +8 -0
  21. package/dist/esm/entities/Entity.js +23 -3
  22. package/dist/esm/entities/Entity.js.map +1 -1
  23. package/dist/esm/entities/EntityMetadata.d.ts +1 -0
  24. package/dist/esm/entities/EntityMetadata.js +18 -3
  25. package/dist/esm/entities/EntityMetadata.js.map +1 -1
  26. package/dist/esm/entities/EntityStore.d.ts +6 -4
  27. package/dist/esm/entities/EntityStore.js +21 -10
  28. package/dist/esm/entities/EntityStore.js.map +1 -1
  29. package/dist/esm/entities/types.d.ts +1 -0
  30. package/dist/esm/files/EntityFile.d.ts +1 -1
  31. package/dist/esm/files/EntityFile.js +7 -1
  32. package/dist/esm/files/EntityFile.js.map +1 -1
  33. package/dist/esm/files/FileManager.d.ts +11 -2
  34. package/dist/esm/files/FileManager.js +45 -8
  35. package/dist/esm/files/FileManager.js.map +1 -1
  36. package/dist/esm/files/FileStorage.d.ts +6 -0
  37. package/dist/esm/files/FileStorage.js +6 -1
  38. package/dist/esm/files/FileStorage.js.map +1 -1
  39. package/dist/esm/files/utils.d.ts +1 -2
  40. package/dist/esm/files/utils.js +11 -5
  41. package/dist/esm/files/utils.js.map +1 -1
  42. package/dist/esm/index.d.ts +1 -0
  43. package/dist/esm/index.js +1 -0
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/metadata/LocalReplicaStore.d.ts +1 -0
  46. package/dist/esm/metadata/LocalReplicaStore.js +1 -0
  47. package/dist/esm/metadata/LocalReplicaStore.js.map +1 -1
  48. package/dist/esm/metadata/MessageCreator.js +4 -16
  49. package/dist/esm/metadata/MessageCreator.js.map +1 -1
  50. package/dist/esm/metadata/Metadata.d.ts +8 -0
  51. package/dist/esm/metadata/Metadata.js +32 -0
  52. package/dist/esm/metadata/Metadata.js.map +1 -1
  53. package/dist/esm/metadata/OperationsStore.js +3 -3
  54. package/dist/esm/metadata/OperationsStore.js.map +1 -1
  55. package/dist/esm/migration/engine.js +12 -2
  56. package/dist/esm/migration/engine.js.map +1 -1
  57. package/dist/esm/queries/CollectionQueries.d.ts +8 -2
  58. package/dist/esm/queries/CollectionQueries.js +2 -1
  59. package/dist/esm/queries/CollectionQueries.js.map +1 -1
  60. package/dist/esm/sync/FileSync.d.ts +1 -0
  61. package/dist/esm/sync/FileSync.js +5 -2
  62. package/dist/esm/sync/FileSync.js.map +1 -1
  63. package/dist/esm/sync/PushPullSync.d.ts +2 -1
  64. package/dist/esm/sync/PushPullSync.js +10 -6
  65. package/dist/esm/sync/PushPullSync.js.map +1 -1
  66. package/dist/esm/sync/ServerSyncEndpointProvider.d.ts +10 -1
  67. package/dist/esm/sync/ServerSyncEndpointProvider.js +13 -2
  68. package/dist/esm/sync/ServerSyncEndpointProvider.js.map +1 -1
  69. package/dist/esm/sync/Sync.d.ts +5 -4
  70. package/dist/esm/sync/Sync.js +22 -7
  71. package/dist/esm/sync/Sync.js.map +1 -1
  72. package/dist/esm/sync/WebSocketSync.d.ts +2 -1
  73. package/dist/esm/sync/WebSocketSync.js +5 -2
  74. package/dist/esm/sync/WebSocketSync.js.map +1 -1
  75. package/package.json +6 -6
  76. package/src/IDBService.ts +8 -4
  77. package/src/__tests__/entities.test.ts +29 -5
  78. package/src/__tests__/fixtures/testStorage.ts +3 -0
  79. package/src/authorization.ts +6 -0
  80. package/src/client/Client.ts +28 -6
  81. package/src/entities/DocumentManager.ts +154 -0
  82. package/src/entities/Entity.ts +26 -2
  83. package/src/entities/EntityMetadata.ts +22 -0
  84. package/src/entities/EntityStore.ts +28 -11
  85. package/src/entities/types.ts +1 -0
  86. package/src/files/EntityFile.ts +6 -2
  87. package/src/files/FileManager.ts +57 -9
  88. package/src/files/FileStorage.ts +7 -1
  89. package/src/files/utils.ts +17 -8
  90. package/src/index.ts +1 -0
  91. package/src/metadata/LocalReplicaStore.ts +2 -0
  92. package/src/metadata/MessageCreator.ts +4 -15
  93. package/src/metadata/Metadata.ts +37 -0
  94. package/src/metadata/OperationsStore.ts +3 -3
  95. package/src/migration/engine.ts +14 -2
  96. package/src/queries/CollectionQueries.ts +23 -4
  97. package/src/sync/FileSync.ts +6 -7
  98. package/src/sync/PushPullSync.ts +7 -2
  99. package/src/sync/ServerSyncEndpointProvider.ts +22 -2
  100. package/src/sync/Sync.ts +27 -6
  101. package/src/sync/WebSocketSync.ts +6 -2
  102. package/dist/esm/DocumentManager.js +0 -46
  103. package/dist/esm/DocumentManager.js.map +0 -1
  104. 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';
@@ -112,11 +112,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
112
112
  this._queryCache = new QueryCache({
113
113
  context,
114
114
  });
115
- this._documentManager = new DocumentManager(
116
- this.meta,
117
- this.schema,
118
- this._entities,
119
- );
115
+ this._documentManager = new DocumentManager(this.schema, this._entities);
120
116
 
121
117
  const notifyFutureSeen = debounce(() => {
122
118
  this.emit('futureSeen');
@@ -231,6 +227,8 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
231
227
  ? await navigator.storage.estimate()
232
228
  : undefined;
233
229
 
230
+ const files = await this._fileManager.stats();
231
+
234
232
  // determine data:metadata ratio for total size of all collections vs metadata
235
233
  const totalCollectionsSize = Object.values(collections).reduce(
236
234
  (acc, { size }) => acc + size,
@@ -246,6 +244,7 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
246
244
  totalMetaSize,
247
245
  totalCollectionsSize,
248
246
  metaToDataRatio,
247
+ files,
249
248
  quotaUsage:
250
249
  storage?.usage && storage?.quota
251
250
  ? storage.usage / storage.quota
@@ -433,6 +432,26 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
433
432
  const exportData = await this.export();
434
433
  await this.import(exportData);
435
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();
436
455
  }
437
456
 
438
457
  export interface ClientStats {
@@ -441,6 +460,9 @@ export interface ClientStats {
441
460
  baselinesSize: { count: number; size: number };
442
461
  operationsSize: { count: number; size: number };
443
462
  };
463
+ files: {
464
+ size: { count: number; size: number };
465
+ };
444
466
  storage: StorageEstimate | undefined;
445
467
  totalMetaSize: number;
446
468
  totalCollectionsSize: number;
@@ -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;
@@ -320,7 +326,7 @@ export class EntityStore extends Disposable {
320
326
  create = async (
321
327
  initial: any,
322
328
  oid: ObjectIdentifier,
323
- { undoable = true }: { undoable?: boolean } = {},
329
+ { undoable = true, access }: EntityCreateOptions = {},
324
330
  ) => {
325
331
  this.ctx.log('debug', 'Creating new entity', oid);
326
332
  const { collection } = decomposeOid(oid);
@@ -340,17 +346,23 @@ export class EntityStore extends Disposable {
340
346
  }
341
347
 
342
348
  const operations = this.meta.patchCreator.createInitialize(processed, oid);
349
+ if (access) {
350
+ operations.forEach((op) => {
351
+ op.authz = access;
352
+ });
353
+ }
343
354
  await this.batcher.commitOperations(operations, {
344
355
  undoable: !!undoable,
345
356
  source: entity,
346
357
  });
347
358
 
348
- // 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
349
360
  // exists?
361
+ // A: it will overwrite the existing entity
350
362
 
351
363
  // we still need to synchronously add the initial operations to the Entity
352
364
  // even though they are flowing through the system
353
- // TODO: this could be better aligned to avoid grouping here
365
+ // FIXME: this could be better aligned to avoid grouping here
354
366
  const operationsGroupedByOid = groupPatchesByOid(operations);
355
367
  this.events.add.invoke(this, {
356
368
  operations: operationsGroupedByOid,
@@ -372,12 +384,7 @@ export class EntityStore extends Disposable {
372
384
  'Only root documents may be deleted via client methods',
373
385
  );
374
386
 
375
- const allOids = await Promise.all(
376
- oids.flatMap(async (oid) => {
377
- const entity = await this.hydrate(oid);
378
- return entity?.__getFamilyOids__() ?? [];
379
- }),
380
- );
387
+ const entities = await Promise.all(oids.map((oid) => this.hydrate(oid)));
381
388
 
382
389
  // remove the entities from cache
383
390
  oids.forEach((oid) => {
@@ -385,8 +392,18 @@ export class EntityStore extends Disposable {
385
392
  this.ctx.log('debug', 'Deleted document from cache', oid);
386
393
  });
387
394
 
388
- // create the delete patches and wait for them to be applied
389
- 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
+
390
407
  await this.batcher.commitOperations(operations, {
391
408
  undoable: options?.undoable === undefined ? true : options.undoable,
392
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
  }