@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.
- 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 +29 -1
- package/dist/esm/client/Client.js +25 -2
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/context.d.ts +6 -0
- 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 +22 -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 +37 -6
- package/src/context.ts +6 -0
- 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 +29 -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/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
|
-
|
|
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
|
|
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
|
|
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 () => {
|
package/src/client/Client.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/entities/Entity.ts
CHANGED
|
@@ -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 {
|
|
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 }:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
388
|
-
const
|
|
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
|
});
|
package/src/entities/types.ts
CHANGED
package/src/files/EntityFile.ts
CHANGED
|
@@ -101,10 +101,14 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
|
|
|
101
101
|
this.dispose();
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
getSnapshot():
|
|
104
|
+
getSnapshot(): FileData {
|
|
105
|
+
if (this._fileData) return this._fileData;
|
|
105
106
|
return {
|
|
106
107
|
id: this.id,
|
|
107
|
-
url: this.
|
|
108
|
+
url: this._objectUrl ?? undefined,
|
|
109
|
+
name: this.name ?? 'unknown-file',
|
|
110
|
+
remote: false,
|
|
111
|
+
type: this.type ?? '',
|
|
108
112
|
};
|
|
109
113
|
}
|
|
110
114
|
}
|