@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.
- package/dist/bundle/index.js +8 -8
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/IDBService.d.ts +1 -1
- package/dist/esm/IDBService.js +7 -2
- package/dist/esm/IDBService.js.map +1 -1
- package/dist/esm/__tests__/entities.test.js +21 -16
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/__tests__/fixtures/testStorage.d.ts +4 -0
- package/dist/esm/__tests__/fixtures/testStorage.js +3 -0
- package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
- package/dist/esm/authorization.d.ts +4 -0
- package/dist/esm/authorization.js +6 -0
- package/dist/esm/authorization.js.map +1 -0
- package/dist/esm/client/Client.d.ts +23 -1
- package/dist/esm/client/Client.js +22 -2
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/{DocumentManager.d.ts → entities/DocumentManager.d.ts} +12 -6
- package/dist/esm/entities/DocumentManager.js +77 -0
- package/dist/esm/entities/DocumentManager.js.map +1 -0
- package/dist/esm/entities/Entity.d.ts +8 -0
- package/dist/esm/entities/Entity.js +23 -3
- package/dist/esm/entities/Entity.js.map +1 -1
- package/dist/esm/entities/EntityMetadata.d.ts +1 -0
- package/dist/esm/entities/EntityMetadata.js +18 -3
- package/dist/esm/entities/EntityMetadata.js.map +1 -1
- package/dist/esm/entities/EntityStore.d.ts +6 -4
- package/dist/esm/entities/EntityStore.js +21 -10
- package/dist/esm/entities/EntityStore.js.map +1 -1
- package/dist/esm/entities/types.d.ts +1 -0
- package/dist/esm/files/EntityFile.d.ts +1 -1
- package/dist/esm/files/EntityFile.js +7 -1
- package/dist/esm/files/EntityFile.js.map +1 -1
- package/dist/esm/files/FileManager.d.ts +11 -2
- package/dist/esm/files/FileManager.js +45 -8
- package/dist/esm/files/FileManager.js.map +1 -1
- package/dist/esm/files/FileStorage.d.ts +6 -0
- package/dist/esm/files/FileStorage.js +6 -1
- package/dist/esm/files/FileStorage.js.map +1 -1
- package/dist/esm/files/utils.d.ts +1 -2
- package/dist/esm/files/utils.js +11 -5
- package/dist/esm/files/utils.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/metadata/LocalReplicaStore.d.ts +1 -0
- package/dist/esm/metadata/LocalReplicaStore.js +1 -0
- package/dist/esm/metadata/LocalReplicaStore.js.map +1 -1
- package/dist/esm/metadata/MessageCreator.js +4 -16
- package/dist/esm/metadata/MessageCreator.js.map +1 -1
- package/dist/esm/metadata/Metadata.d.ts +8 -0
- package/dist/esm/metadata/Metadata.js +32 -0
- package/dist/esm/metadata/Metadata.js.map +1 -1
- package/dist/esm/metadata/OperationsStore.js +3 -3
- package/dist/esm/metadata/OperationsStore.js.map +1 -1
- package/dist/esm/migration/engine.js +12 -2
- package/dist/esm/migration/engine.js.map +1 -1
- package/dist/esm/queries/CollectionQueries.d.ts +8 -2
- package/dist/esm/queries/CollectionQueries.js +2 -1
- package/dist/esm/queries/CollectionQueries.js.map +1 -1
- package/dist/esm/sync/FileSync.d.ts +1 -0
- package/dist/esm/sync/FileSync.js +5 -2
- package/dist/esm/sync/FileSync.js.map +1 -1
- package/dist/esm/sync/PushPullSync.d.ts +2 -1
- package/dist/esm/sync/PushPullSync.js +10 -6
- package/dist/esm/sync/PushPullSync.js.map +1 -1
- package/dist/esm/sync/ServerSyncEndpointProvider.d.ts +10 -1
- package/dist/esm/sync/ServerSyncEndpointProvider.js +13 -2
- package/dist/esm/sync/ServerSyncEndpointProvider.js.map +1 -1
- package/dist/esm/sync/Sync.d.ts +5 -4
- package/dist/esm/sync/Sync.js +22 -7
- package/dist/esm/sync/Sync.js.map +1 -1
- package/dist/esm/sync/WebSocketSync.d.ts +2 -1
- package/dist/esm/sync/WebSocketSync.js +5 -2
- package/dist/esm/sync/WebSocketSync.js.map +1 -1
- package/package.json +6 -6
- package/src/IDBService.ts +8 -4
- package/src/__tests__/entities.test.ts +29 -5
- package/src/__tests__/fixtures/testStorage.ts +3 -0
- package/src/authorization.ts +6 -0
- package/src/client/Client.ts +28 -6
- package/src/entities/DocumentManager.ts +154 -0
- package/src/entities/Entity.ts +26 -2
- package/src/entities/EntityMetadata.ts +22 -0
- package/src/entities/EntityStore.ts +28 -11
- package/src/entities/types.ts +1 -0
- package/src/files/EntityFile.ts +6 -2
- package/src/files/FileManager.ts +57 -9
- package/src/files/FileStorage.ts +7 -1
- package/src/files/utils.ts +17 -8
- package/src/index.ts +1 -0
- package/src/metadata/LocalReplicaStore.ts +2 -0
- package/src/metadata/MessageCreator.ts +4 -15
- package/src/metadata/Metadata.ts +37 -0
- package/src/metadata/OperationsStore.ts +3 -3
- package/src/migration/engine.ts +14 -2
- package/src/queries/CollectionQueries.ts +23 -4
- package/src/sync/FileSync.ts +6 -7
- package/src/sync/PushPullSync.ts +7 -2
- package/src/sync/ServerSyncEndpointProvider.ts +22 -2
- package/src/sync/Sync.ts +27 -6
- package/src/sync/WebSocketSync.ts +6 -2
- package/dist/esm/DocumentManager.js +0 -46
- package/dist/esm/DocumentManager.js.map +0 -1
- package/src/DocumentManager.ts +0 -97
package/src/files/FileManager.ts
CHANGED
|
@@ -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 (
|
|
76
|
-
|
|
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
|
|
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 <
|
|
104
|
-
this.context.log(
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
}
|
package/src/files/FileStorage.ts
CHANGED
|
@@ -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) {
|
package/src/files/utils.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
@@ -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(
|
|
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
|
{
|
package/src/metadata/Metadata.ts
CHANGED
|
@@ -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
|
-
?
|
|
270
|
+
? IDBKeyRange.bound(start, end, false, true)
|
|
271
271
|
: start
|
|
272
|
-
?
|
|
272
|
+
? IDBKeyRange.lowerBound(start, false)
|
|
273
273
|
: end
|
|
274
|
-
?
|
|
274
|
+
? IDBKeyRange.upperBound(end, true)
|
|
275
275
|
: undefined;
|
|
276
276
|
const index = store.index('timestamp');
|
|
277
277
|
return index.openCursor(range, 'next');
|
package/src/migration/engine.ts
CHANGED
|
@@ -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 {
|
|
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: (
|
|
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) => {
|
package/src/sync/FileSync.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
};
|
package/src/sync/PushPullSync.ts
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
};
|