@verdant-web/store 3.8.4 → 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 (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
@@ -43,6 +43,9 @@ export class FileManager {
43
43
  private config: Required<FileManagerConfig>;
44
44
  private meta: Metadata;
45
45
 
46
+ private maxUploadRetries = 3;
47
+ private maxDownloadRetries = 3;
48
+
46
49
  constructor({
47
50
  db,
48
51
  sync,
@@ -72,8 +75,20 @@ export class FileManager {
72
75
  this.tryCleanupDeletedFiles();
73
76
  }
74
77
 
75
- add = async (fileInput: Omit<FileData, 'remote'>) => {
76
- const file = fileInput as unknown as FileData;
78
+ add = async (file: FileData) => {
79
+ // this method accepts a FileData which refers to a remote
80
+ // file, as well as local files. in the case of a remote file,
81
+ // we actually re-download and upload the file again. this powers
82
+ // the cloning of documents with files; we clone their filedata
83
+ // and re-upload to a new file ID. otherwise, when the cloned
84
+ // filedata was marked deleted, the original file would be deleted
85
+ // and the clone would refer to a missing file.
86
+ if (file.url && !file.file) {
87
+ const blob = await this.downloadRemoteFile(file.url);
88
+ // convert blob to file with name and type
89
+ file.file = new File([blob], file.name, { type: file.type });
90
+ }
91
+
77
92
  file.remote = false;
78
93
  // immediately cache the file
79
94
  if (!this.files.has(file.id)) {
@@ -86,8 +101,8 @@ export class FileManager {
86
101
  // write to local storage and send to sync immediately
87
102
  await this.storage.addFile(file);
88
103
  // send to sync
89
- if (file.file) {
90
- await this.uploadFile(file, 1);
104
+ if (file.file && this.sync.status === 'active') {
105
+ await this.uploadFile(file);
91
106
  }
92
107
  };
93
108
 
@@ -99,20 +114,49 @@ export class FileManager {
99
114
  if (cached) {
100
115
  cached[MARK_UPLOADED]();
101
116
  }
117
+ this.context.log('info', 'File uploaded', file.id);
102
118
  } else {
103
- if (result.retry && retries < 5) {
104
- this.context.log('error', 'Error uploading file, retrying...');
119
+ if (result.retry && retries < this.maxUploadRetries) {
120
+ this.context.log(
121
+ 'error',
122
+ `Error uploading file ${file.id}, retrying...`,
123
+ result.error,
124
+ );
105
125
  // schedule a retry
106
126
  setTimeout(this.uploadFile, 1000, file, retries + 1);
107
127
  } else {
108
128
  this.context.log(
109
129
  'error',
110
- 'Failed to upload file. Not retrying until next sync.',
130
+ `Failed to upload file ${file.id}. Not retrying until next sync.`,
131
+ result.error,
111
132
  );
112
133
  }
113
134
  }
114
135
  };
115
136
 
137
+ private downloadRemoteFile = async (
138
+ url: string,
139
+ retries = 0,
140
+ ): Promise<Blob> => {
141
+ const resp = await fetch(url, {
142
+ method: 'GET',
143
+ credentials: 'include',
144
+ });
145
+ if (!resp.ok) {
146
+ if (retries < this.maxDownloadRetries) {
147
+ return new Promise((resolve, reject) => {
148
+ setTimeout(() => {
149
+ this.downloadRemoteFile(url, retries + 1).then(resolve, reject);
150
+ }, 1000);
151
+ });
152
+ } else {
153
+ throw new Error(`Failed to download file: ${resp.status}`);
154
+ }
155
+ }
156
+ const blob = await resp.blob();
157
+ return blob;
158
+ };
159
+
116
160
  /**
117
161
  * Immediately returns an EntityFile to use, then either loads
118
162
  * the file from cache, local database, or the server.
@@ -128,7 +172,7 @@ export class FileManager {
128
172
  };
129
173
 
130
174
  private load = async (file: EntityFile, retries = 0) => {
131
- if (retries > 5) {
175
+ if (retries > this.maxDownloadRetries) {
132
176
  this.context.log('error', 'Failed to load file after 5 retries');
133
177
  file[MARK_FAILED]();
134
178
  return;
@@ -205,7 +249,7 @@ export class FileManager {
205
249
  }
206
250
  };
207
251
 
208
- private tryCleanupDeletedFiles = async () => {
252
+ tryCleanupDeletedFiles = async () => {
209
253
  let count = 0;
210
254
  let skipCount = 0;
211
255
  await this.storage.iterateOverPendingDelete((fileData, store) => {
@@ -243,4 +287,8 @@ export class FileManager {
243
287
  close = () => {
244
288
  this.storage.dispose();
245
289
  };
290
+
291
+ stats = () => {
292
+ return this.storage.stats();
293
+ };
246
294
  }
@@ -1,7 +1,7 @@
1
1
  import { FileData } from '@verdant-web/common';
2
2
  import { IDBService } from '../IDBService.js';
3
3
  import { fileToArrayBuffer } from './utils.js';
4
- import { getAllFromObjectStores } from '../idb.js';
4
+ import { getAllFromObjectStores, getSizeOfObjectStore } from '../idb.js';
5
5
 
6
6
  /**
7
7
  * When stored in IDB, replace the file blob with an array buffer
@@ -217,6 +217,12 @@ export class FileStorage extends IDBService {
217
217
  const [files] = await getAllFromObjectStores(this.db, ['files']);
218
218
  return files.map(this.hydrateFileData);
219
219
  };
220
+
221
+ stats = async () => {
222
+ return {
223
+ size: await getSizeOfObjectStore(this.db, 'files'),
224
+ };
225
+ };
220
226
  }
221
227
 
222
228
  export function arrayBufferToBlob(buffer: ArrayBuffer, type: string) {
@@ -1,4 +1,9 @@
1
- import { createFileRef, FileData } from '@verdant-web/common';
1
+ import {
2
+ createFileRef,
3
+ FileData,
4
+ isFile,
5
+ isFileData,
6
+ } from '@verdant-web/common';
2
7
  import cuid from 'cuid';
3
8
 
4
9
  export function createFileData(file: File): FileData {
@@ -12,13 +17,6 @@ export function createFileData(file: File): FileData {
12
17
  };
13
18
  }
14
19
 
15
- export function isFile(value: any): value is File {
16
- return (
17
- value instanceof File ||
18
- (typeof Blob !== 'undefined' && value instanceof Blob)
19
- );
20
- }
21
-
22
20
  /**
23
21
  * MUTATES the value.
24
22
  * Replaces File values with refs and returns the normalized value.
@@ -34,6 +32,13 @@ export function processValueFiles(
34
32
  return createFileRef(data.id);
35
33
  }
36
34
 
35
+ if (isFileData(value)) {
36
+ // create a new ID for the file
37
+ const cloned = { ...value, id: cuid() };
38
+ onFileIdentified(cloned);
39
+ return createFileRef(cloned.id);
40
+ }
41
+
37
42
  if (Array.isArray(value)) {
38
43
  for (let i = 0; i < value.length; i++) {
39
44
  value[i] = processValueFiles(value[i], onFileIdentified);
@@ -52,6 +57,10 @@ export function processValueFiles(
52
57
  }
53
58
 
54
59
  export function fileToArrayBuffer(file: File | Blob) {
60
+ // special case for testing...
61
+ if ('__testReadBuffer' in file) {
62
+ return file.__testReadBuffer;
63
+ }
55
64
  return new Promise<ArrayBuffer>((resolve, reject) => {
56
65
  const reader = new FileReader();
57
66
  reader.onload = () => {
package/src/index.ts CHANGED
@@ -55,3 +55,4 @@ export type { CollectionQueries } from './queries/CollectionQueries.js';
55
55
  export { MigrationPathError } from './migration/errors.js';
56
56
  export * from './utils/id.js';
57
57
  export { UndoHistory } from './UndoHistory.js';
58
+ export * from './authorization.js';
@@ -4,6 +4,7 @@ import { IDBService } from '../IDBService.js';
4
4
  export type LocalReplicaInfo = {
5
5
  type: 'localReplicaInfo';
6
6
  id: string;
7
+ userId: string | undefined;
7
8
  ackedLogicalTime: string | null;
8
9
  lastSyncedLogicalTime: string | null;
9
10
  };
@@ -36,6 +37,7 @@ export class LocalReplicaStore extends IDBService {
36
37
  const replicaInfo: LocalReplicaInfo = {
37
38
  type: 'localReplicaInfo',
38
39
  id: replicaId,
40
+ userId: undefined,
39
41
  ackedLogicalTime: null,
40
42
  lastSyncedLogicalTime: null,
41
43
  };
@@ -6,6 +6,7 @@ import {
6
6
  ObjectIdentifier,
7
7
  Operation,
8
8
  OperationMessage,
9
+ pickValidOperationKeys,
9
10
  PresenceUpdateMessage,
10
11
  SyncAckMessage,
11
12
  SyncMessage,
@@ -26,11 +27,7 @@ export class MessageCreator {
26
27
  type: 'op',
27
28
  timestamp: this.meta.now,
28
29
  replicaId: localInfo.id,
29
- operations: init.operations.map((op) => ({
30
- data: op.data,
31
- oid: op.oid,
32
- timestamp: op.timestamp,
33
- })),
30
+ operations: init.operations.map(pickValidOperationKeys),
34
31
  };
35
32
  };
36
33
 
@@ -73,11 +70,7 @@ export class MessageCreator {
73
70
  if (provideChangesSince) {
74
71
  await this.meta.operations.iterateOverAllLocalOperations(
75
72
  (patch) => {
76
- operations.push({
77
- data: patch.data,
78
- oid: patch.oid,
79
- timestamp: patch.timestamp,
80
- });
73
+ operations.push(pickValidOperationKeys(patch));
81
74
  affectedDocs.add(getOidRoot(patch.oid));
82
75
  },
83
76
  {
@@ -91,11 +84,7 @@ export class MessageCreator {
91
84
  // operations
92
85
  await this.meta.operations.iterateOverAllOperations(
93
86
  (patch) => {
94
- operations.push({
95
- data: patch.data,
96
- oid: patch.oid,
97
- timestamp: patch.timestamp,
98
- });
87
+ operations.push(pickValidOperationKeys(patch));
99
88
  affectedDocs.add(getOidRoot(patch.oid));
100
89
  },
101
90
  {
@@ -262,6 +262,25 @@ export class Metadata extends EventSubscriber<{
262
262
  };
263
263
  };
264
264
 
265
+ getDocumentAuthz = async (oid: ObjectIdentifier) => {
266
+ const baseline = await this.baselines.get(oid);
267
+ if (baseline) {
268
+ return baseline.authz;
269
+ }
270
+ let authz;
271
+ await this.operations.iterateOverAllOperationsForEntity(
272
+ oid,
273
+ (op) => {
274
+ if (op.data.op === 'initialize') {
275
+ authz = op.authz;
276
+ return true;
277
+ }
278
+ },
279
+ {},
280
+ );
281
+ return authz;
282
+ };
283
+
265
284
  /**
266
285
  * Methods for writing data
267
286
  */
@@ -487,6 +506,7 @@ export class Metadata extends EventSubscriber<{
487
506
  const baseline = await this.baselines.get(oid, { transaction });
488
507
  let current: any = baseline?.snapshot || undefined;
489
508
  let operationsApplied = 0;
509
+ let authz = baseline?.authz;
490
510
  const deletedRefs: Ref[] = [];
491
511
  await this.operations.iterateOverAllOperationsForEntity(
492
512
  oid,
@@ -495,6 +515,9 @@ export class Metadata extends EventSubscriber<{
495
515
  // but it's here as a safety measure...
496
516
  if (!baseline || patch.timestamp > baseline.timestamp) {
497
517
  current = applyPatch(current, patch.data, deletedRefs);
518
+ if (patch.data.op === 'initialize') {
519
+ authz = patch.authz;
520
+ }
498
521
  }
499
522
  // delete all prior operations to the baseline
500
523
  operationsApplied++;
@@ -512,6 +535,7 @@ export class Metadata extends EventSubscriber<{
512
535
  oid,
513
536
  snapshot: current,
514
537
  timestamp: upTo,
538
+ authz,
515
539
  };
516
540
  if (newBaseline.snapshot) {
517
541
  await this.baselines.set(newBaseline, { transaction });
@@ -583,6 +607,19 @@ export class Metadata extends EventSubscriber<{
583
607
  }
584
608
  };
585
609
 
610
+ /**
611
+ * Manually triggers a storage rebase.
612
+ * Rebases happen automatically as needed, so
613
+ * you probably don't need this.
614
+ */
615
+ manualRebase = async () => {
616
+ if (this._closing || this.disableRebasing) return;
617
+ const ackInfo = await this.ackInfo.getAckInfo();
618
+ if (ackInfo.globalAckTimestamp) {
619
+ this.runRebase(ackInfo.globalAckTimestamp);
620
+ }
621
+ };
622
+
586
623
  export = async (): Promise<ExportData> => {
587
624
  const db = this.db;
588
625
  const [baselines, operations] = await getAllFromObjectStores(db, [
@@ -267,11 +267,11 @@ export class OperationsStore extends IDBService {
267
267
 
268
268
  const range =
269
269
  start && end
270
- ? window.IDBKeyRange.bound(start, end, false, true)
270
+ ? IDBKeyRange.bound(start, end, false, true)
271
271
  : start
272
- ? window.IDBKeyRange.lowerBound(start, false)
272
+ ? IDBKeyRange.lowerBound(start, false)
273
273
  : end
274
- ? window.IDBKeyRange.upperBound(end, true)
274
+ ? IDBKeyRange.upperBound(end, true)
275
275
  : undefined;
276
276
  const index = store.index('timestamp');
277
277
  return index.openCursor(range, 'next');
@@ -12,6 +12,7 @@ import {
12
12
  getOid,
13
13
  initialToPatches,
14
14
  removeOidPropertiesFromAllSubObjects,
15
+ AuthorizationKey,
15
16
  } from '@verdant-web/common';
16
17
  import { Context } from '../context.js';
17
18
  import { Metadata } from '../metadata/Metadata.js';
@@ -31,26 +32,31 @@ function getMigrationMutations({
31
32
  }) {
32
33
  return migration.allCollections.reduce((acc, collectionName) => {
33
34
  acc[collectionName] = {
34
- put: async (doc: any) => {
35
+ put: async (doc: any, options?: { access?: AuthorizationKey }) => {
35
36
  // add defaults
36
37
  addFieldDefaults(migration.newSchema.collections[collectionName], doc);
37
38
  const primaryKey =
38
39
  doc[migration.newSchema.collections[collectionName].primaryKey];
39
40
  const oid = createOid(collectionName, primaryKey);
40
41
  newOids.push(oid);
42
+
41
43
  await meta.insertLocalOperations(
42
- initialToPatches(doc, oid, getMigrationNow),
44
+ initialToPatches(doc, oid, getMigrationNow, undefined, undefined, {
45
+ authz: options?.access,
46
+ }),
43
47
  );
44
48
  return doc;
45
49
  },
46
50
  delete: async (id: string) => {
47
51
  const rootOid = createOid(collectionName, id);
52
+ const authz = await meta.getDocumentAuthz(rootOid);
48
53
  const allOids = await meta.getAllDocumentRelatedOids(rootOid);
49
54
  return meta.insertLocalOperations(
50
55
  allOids.map((oid) => ({
51
56
  oid,
52
57
  timestamp: getMigrationNow(),
53
58
  data: { op: 'delete' },
59
+ authz,
54
60
  })),
55
61
  );
56
62
  },
@@ -164,6 +170,11 @@ export function getMigrationEngine({
164
170
  !!rootOid,
165
171
  `Document is missing an OID: ${JSON.stringify(doc)}`,
166
172
  );
173
+ // FIXME: this could be optimized (making n queries for authz
174
+ // when the snapshots themselves are derived from the same data...)
175
+ // maybe don't use the findAll query, and instead go a level
176
+ // lower to retain access to lower level data here?
177
+ const authz = await meta.getDocumentAuthz(rootOid);
167
178
  const original = cloneDeep(doc);
168
179
  // @ts-ignore - excessive type resolution
169
180
  const newValue = await strategy(doc);
@@ -182,6 +193,7 @@ export function getMigrationEngine({
182
193
  [],
183
194
  {
184
195
  mergeUnknownObjects: true,
196
+ authz,
185
197
  },
186
198
  );
187
199
  if (patches.length > 0) {
@@ -1,4 +1,8 @@
1
- import { CollectionFilter, hashObject } from '@verdant-web/common';
1
+ import {
2
+ AuthorizationKey,
3
+ CollectionFilter,
4
+ hashObject,
5
+ } from '@verdant-web/common';
2
6
  import { Context } from '../context.js';
3
7
  import { EntityStore } from '../entities/EntityStore.js';
4
8
  import { GetQuery } from './GetQuery.js';
@@ -7,8 +11,8 @@ import { FindOneQuery } from './FindOneQuery.js';
7
11
  import { FindPageQuery } from './FindPageQuery.js';
8
12
  import { FindInfiniteQuery } from './FindInfiniteQuery.js';
9
13
  import { FindAllQuery } from './FindAllQuery.js';
10
- import { DocumentManager } from '../DocumentManager.js';
11
- import { ObjectEntity } from '../index.js';
14
+ import { DocumentManager } from '../entities/DocumentManager.js';
15
+ import { Entity, ObjectEntity } from '../index.js';
12
16
  import { UPDATE } from './BaseQuery.js';
13
17
 
14
18
  export class CollectionQueries<
@@ -22,9 +26,20 @@ export class CollectionQueries<
22
26
  private context;
23
27
  private documentManager;
24
28
 
25
- put: (init: Init, options?: { undoable?: boolean }) => Promise<T>;
29
+ put: (
30
+ init: Init,
31
+ options?: { undoable?: boolean; access?: AuthorizationKey },
32
+ ) => Promise<T>;
26
33
  delete: (id: string, options?: { undoable?: boolean }) => Promise<void>;
27
34
  deleteAll: (ids: string[], options?: { undoable?: boolean }) => Promise<void>;
35
+ clone: (
36
+ entity: ObjectEntity<any, any>,
37
+ options?: {
38
+ undoable?: boolean;
39
+ access?: AuthorizationKey;
40
+ primaryKey?: string;
41
+ },
42
+ ) => Promise<T>;
28
43
 
29
44
  constructor({
30
45
  collection,
@@ -57,6 +72,10 @@ export class CollectionQueries<
57
72
  this.documentManager,
58
73
  this.collection,
59
74
  );
75
+ this.clone = this.documentManager.clone.bind(
76
+ this.documentManager,
77
+ this.collection,
78
+ );
60
79
  }
61
80
 
62
81
  private serializeIndex = (index?: CollectionFilter) => {
@@ -4,6 +4,7 @@ import { ServerSyncEndpointProvider } from './ServerSyncEndpointProvider.js';
4
4
  export interface FileUploadResult {
5
5
  success: boolean;
6
6
  retry: boolean;
7
+ error?: string;
7
8
  }
8
9
 
9
10
  export type FilePullResult =
@@ -43,7 +44,7 @@ export class FileSync {
43
44
  const { files: fileEndpoint, token } =
44
45
  await this.endpointProvider.getEndpoints();
45
46
 
46
- const formData = new window.FormData();
47
+ const formData = new FormData();
47
48
  formData.append('file', file);
48
49
 
49
50
  try {
@@ -63,15 +64,12 @@ export class FileSync {
63
64
  retry: false,
64
65
  };
65
66
  } else {
66
- this.log(
67
- 'error',
68
- 'File upload failed',
69
- response.status,
70
- await response.text(),
71
- );
67
+ const responseText = await response.text();
68
+ this.log('error', 'File upload failed', response.status, responseText);
72
69
  return {
73
70
  success: false,
74
71
  retry: response.status >= 500,
72
+ error: `Failed to upload file: ${response.status} ${responseText}`,
75
73
  };
76
74
  }
77
75
  } catch (e) {
@@ -79,6 +77,7 @@ export class FileSync {
79
77
  return {
80
78
  success: false,
81
79
  retry: true,
80
+ error: (e as Error).message,
82
81
  };
83
82
  }
84
83
  };
@@ -68,6 +68,10 @@ export class PushPullSync
68
68
  return this.heartbeat.interval;
69
69
  }
70
70
 
71
+ get hasSynced() {
72
+ return this._hasSynced;
73
+ }
74
+
71
75
  private sendRequest = async (messages: ClientMessage[]) => {
72
76
  this.log('Sending sync request', messages);
73
77
  try {
@@ -169,13 +173,14 @@ export class PushPullSync
169
173
  }
170
174
  };
171
175
 
172
- start(): void {
176
+ start = async () => {
173
177
  if (this.status === 'active') {
174
178
  return;
175
179
  }
180
+ await this.endpointProvider.getEndpoints();
176
181
  this.heartbeat.start(true);
177
182
  this._status = 'active';
178
- }
183
+ };
179
184
  stop(): void {
180
185
  this.heartbeat.stop();
181
186
  this._status = 'paused';
@@ -22,6 +22,15 @@ export interface ServerSyncEndpointProviderConfig {
22
22
  fetch?: typeof fetch;
23
23
  }
24
24
 
25
+ export interface SyncTokenInfo {
26
+ url: string;
27
+ fileUrl: string;
28
+ type: ReplicaType;
29
+ userId: string;
30
+ libraryId: string;
31
+ role?: string;
32
+ }
33
+
25
34
  export class ServerSyncEndpointProvider {
26
35
  private cached = null as {
27
36
  http: string;
@@ -29,7 +38,11 @@ export class ServerSyncEndpointProvider {
29
38
  files: string;
30
39
  token: string;
31
40
  } | null;
32
- type: ReplicaType = ReplicaType.Realtime;
41
+ tokenInfo: SyncTokenInfo | null = null;
42
+
43
+ get type() {
44
+ return this.tokenInfo?.type ?? ReplicaType.Realtime;
45
+ }
33
46
 
34
47
  constructor(private config: ServerSyncEndpointProviderConfig) {
35
48
  if (!config.authEndpoint && !config.fetchAuth) {
@@ -68,7 +81,14 @@ export class ServerSyncEndpointProvider {
68
81
  decoded.type !== undefined,
69
82
  'No replica type provided from auth endpoint',
70
83
  );
71
- this.type = parseInt(decoded.type + '');
84
+ this.tokenInfo = {
85
+ userId: decoded.sub,
86
+ libraryId: decoded.lib,
87
+ url: decoded.url,
88
+ fileUrl: decoded.file,
89
+ role: decoded.role,
90
+ type: parseInt(decoded.type + '') as ReplicaType,
91
+ };
72
92
  const url = new URL(decoded.url);
73
93
  url.protocol = url.protocol.replace('ws', 'http');
74
94
  const httpEndpoint = url.toString();
package/src/sync/Sync.ts CHANGED
@@ -5,7 +5,9 @@ import {
5
5
  FileData,
6
6
  Operation,
7
7
  ReplicaType,
8
+ rewriteAuthzOriginator,
8
9
  ServerMessage,
10
+ VerdantError,
9
11
  } from '@verdant-web/common';
10
12
  import { Metadata } from '../metadata/Metadata.js';
11
13
  import { HANDLE_MESSAGE, PresenceManager } from './PresenceManager.js';
@@ -33,10 +35,11 @@ export interface SyncTransport extends EventSubscriber<SyncTransportEvents> {
33
35
  readonly presence: PresenceManager;
34
36
 
35
37
  readonly mode: SyncTransportMode;
38
+ readonly hasSynced: boolean;
36
39
 
37
40
  send(message: ClientMessage): void;
38
41
 
39
- start(): void;
42
+ start(): Promise<void>;
40
43
  ignoreIncoming(): void;
41
44
  stop(): void;
42
45
 
@@ -57,7 +60,7 @@ export interface Sync<Presence = any, Profile = any>
57
60
  getFile(fileId: string): Promise<FilePullResult>;
58
61
  readonly presence: PresenceManager<Profile, Presence>;
59
62
  send(message: ClientMessage): void;
60
- start(): void;
63
+ start(): Promise<void>;
61
64
  stop(): void;
62
65
  ignoreIncoming(): void;
63
66
  destroy(): void;
@@ -76,7 +79,7 @@ export class NoSync<Presence = any, Profile = any>
76
79
 
77
80
  public send(): void {}
78
81
 
79
- public start(): void {}
82
+ public async start(): Promise<void> {}
80
83
 
81
84
  public stop(): void {}
82
85
 
@@ -257,19 +260,19 @@ export class ServerSync<Presence = any, Profile = any>
257
260
  endpointProvider: this.endpointProvider,
258
261
  meta,
259
262
  presence: this.presence,
260
- log: this.log,
263
+ log: ctx.log,
261
264
  });
262
265
  this.pushPullSync = new PushPullSync({
263
266
  endpointProvider: this.endpointProvider,
264
267
  meta,
265
268
  presence: this.presence,
266
- log: this.log,
269
+ log: ctx.log,
267
270
  interval: pullInterval,
268
271
  fetch,
269
272
  });
270
273
  this.fileSync = new FileSync({
271
274
  endpointProvider: this.endpointProvider,
272
- log: this.log,
275
+ log: ctx.log,
273
276
  });
274
277
  if (useBroadcastChannel && 'BroadcastChannel' in window) {
275
278
  this.broadcastChannel = new BroadcastChannel(`verdant-${ctx.namespace}`);
@@ -458,6 +461,22 @@ export class ServerSync<Presence = any, Profile = any>
458
461
 
459
462
  send = async (message: ClientMessage) => {
460
463
  if (this.activeSync.status === 'active') {
464
+ // before sync, replace 'originator' authz subjects
465
+ // with token userId. This is the easiest place to
466
+ // do this and allows the rest of the system to be
467
+ // ambivalent about user identity when assigning
468
+ // authorization for the current user.
469
+ const userId = this.endpointProvider.tokenInfo?.userId;
470
+ if (!userId) {
471
+ throw new VerdantError(
472
+ VerdantError.Code.Unexpected,
473
+ undefined,
474
+ 'Active sync has invalid token info',
475
+ );
476
+ }
477
+ if (message.type === 'sync' || message.type === 'op') {
478
+ rewriteAuthzOriginator(message, userId);
479
+ }
461
480
  await this.activeSync.send(message);
462
481
  this.onOutgoingMessage?.(message);
463
482
  }
@@ -468,6 +487,7 @@ export class ServerSync<Presence = any, Profile = any>
468
487
  name: info.name,
469
488
  type: info.type,
470
489
  id: info.id,
490
+ size: info.file?.size,
471
491
  });
472
492
  if (this.activeSync.status === 'active') {
473
493
  return this.fileSync.uploadFile(info);
@@ -475,6 +495,7 @@ export class ServerSync<Presence = any, Profile = any>
475
495
  return {
476
496
  success: false,
477
497
  retry: false,
498
+ error: 'Sync is not active',
478
499
  };
479
500
  }
480
501
  };