@verdant-web/store 4.2.0 → 4.3.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 +9 -7
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/__tests__/entities.test.js +143 -5
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/__tests__/fixtures/testStorage.d.ts +1 -0
- package/dist/esm/__tests__/fixtures/testStorage.js +1 -1
- package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
- package/dist/esm/entities/Entity.d.ts +16 -1
- package/dist/esm/entities/Entity.js +120 -5
- package/dist/esm/entities/Entity.js.map +1 -1
- package/dist/esm/entities/EntityCache.d.ts +1 -1
- package/dist/esm/entities/EntityMetadata.d.ts +2 -2
- package/dist/esm/entities/EntityMetadata.js.map +1 -1
- package/dist/esm/entities/EntityStore.d.ts +4 -4
- package/dist/esm/entities/EntityStore.js +4 -9
- package/dist/esm/entities/EntityStore.js.map +1 -1
- package/dist/esm/entities/OperationBatcher.d.ts +1 -1
- package/dist/esm/entities/OperationBatcher.js +2 -0
- package/dist/esm/entities/OperationBatcher.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/entities.test.ts +160 -5
- package/src/__tests__/fixtures/testStorage.ts +1 -1
- package/src/entities/Entity.ts +135 -4
- package/src/entities/EntityMetadata.ts +5 -4
- package/src/entities/EntityStore.ts +10 -11
- package/src/entities/OperationBatcher.ts +16 -2
package/src/entities/Entity.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isFileRef,
|
|
18
18
|
isNullable,
|
|
19
19
|
isObject,
|
|
20
|
+
isObjectRef,
|
|
20
21
|
isRef,
|
|
21
22
|
maybeGetOid,
|
|
22
23
|
memoByKeys,
|
|
@@ -297,7 +298,12 @@ export class Entity<
|
|
|
297
298
|
break;
|
|
298
299
|
}
|
|
299
300
|
} else {
|
|
300
|
-
|
|
301
|
+
// special case - rewrite undefined fields to null
|
|
302
|
+
if (isNullable(schema) && child === undefined) {
|
|
303
|
+
this.cachedView[key] = null;
|
|
304
|
+
} else {
|
|
305
|
+
this.cachedView[key] = child;
|
|
306
|
+
}
|
|
301
307
|
}
|
|
302
308
|
}
|
|
303
309
|
}
|
|
@@ -325,6 +331,33 @@ export class Entity<
|
|
|
325
331
|
return !!this.validate();
|
|
326
332
|
}
|
|
327
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Returns true if this or any child is invalid (pruned)
|
|
336
|
+
*/
|
|
337
|
+
get deepInvalid(): boolean {
|
|
338
|
+
if (this.invalid) return true;
|
|
339
|
+
if (Array.isArray(this.rawView)) {
|
|
340
|
+
for (let i = 0; i < this.rawView.length; i++) {
|
|
341
|
+
if (isObjectRef(this.rawView[i])) {
|
|
342
|
+
const child = this.getChild(i, this.rawView[i].id);
|
|
343
|
+
if (child.deepInvalid) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} else if (isObject(this.rawView)) {
|
|
349
|
+
for (const key in this.rawView) {
|
|
350
|
+
if (isObjectRef(this.rawView[key])) {
|
|
351
|
+
const child = this.getChild(key, this.rawView[key].id);
|
|
352
|
+
if (child.deepInvalid) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
328
361
|
get isList() {
|
|
329
362
|
// have to turn TS off here as our two interfaces both implement
|
|
330
363
|
// const values for this boolean.
|
|
@@ -418,7 +451,7 @@ export class Entity<
|
|
|
418
451
|
field: this.schema,
|
|
419
452
|
value: this.rawView,
|
|
420
453
|
fieldPath: this.fieldPath,
|
|
421
|
-
|
|
454
|
+
expectRefs: true,
|
|
422
455
|
}) ?? undefined;
|
|
423
456
|
return this.validationError;
|
|
424
457
|
},
|
|
@@ -456,6 +489,37 @@ export class Entity<
|
|
|
456
489
|
}
|
|
457
490
|
};
|
|
458
491
|
|
|
492
|
+
private rawViewWithMappedChildren = (
|
|
493
|
+
mapper: (child: Entity | EntityFile) => any,
|
|
494
|
+
) => {
|
|
495
|
+
const view = this.rawView;
|
|
496
|
+
if (!view) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
if (Array.isArray(view)) {
|
|
500
|
+
const mapped = view.map((value, i) => {
|
|
501
|
+
if (isRef(value)) {
|
|
502
|
+
return mapper(this.getChild(i, value.id));
|
|
503
|
+
} else {
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
assignOid(mapped, this.oid);
|
|
508
|
+
return mapped;
|
|
509
|
+
} else {
|
|
510
|
+
const mapped = Object.entries(view).reduce((acc, [key, value]) => {
|
|
511
|
+
if (isRef(value)) {
|
|
512
|
+
acc[key as any] = mapper(this.getChild(key, value.id));
|
|
513
|
+
} else {
|
|
514
|
+
acc[key as any] = value;
|
|
515
|
+
}
|
|
516
|
+
return acc;
|
|
517
|
+
}, {} as any);
|
|
518
|
+
assignOid(mapped, this.oid);
|
|
519
|
+
return mapped;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
459
523
|
/**
|
|
460
524
|
* A current snapshot of this Entity's data, including nested
|
|
461
525
|
* Entities.
|
|
@@ -464,10 +528,62 @@ export class Entity<
|
|
|
464
528
|
return this.viewWithMappedChildren((child) => child.getSnapshot());
|
|
465
529
|
};
|
|
466
530
|
|
|
531
|
+
/**
|
|
532
|
+
* A snapshot of this Entity with unpruned (invalid) data. This will
|
|
533
|
+
* not conform to the entity schema and should be used carefully.
|
|
534
|
+
*
|
|
535
|
+
* Can be used to inspect or recover invalid, pruned data not
|
|
536
|
+
* otherwise accessible.
|
|
537
|
+
*/
|
|
538
|
+
getUnprunedSnapshot = (): any => {
|
|
539
|
+
return this.rawViewWithMappedChildren((child) => {
|
|
540
|
+
if (child instanceof EntityFile) return child.getSnapshot();
|
|
541
|
+
return child.getUnprunedSnapshot();
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
|
|
467
545
|
// change management methods (internal use only)
|
|
468
546
|
private addPendingOperations = (operations: Operation[]) => {
|
|
469
|
-
this.ctx.log(
|
|
547
|
+
this.ctx.log(
|
|
548
|
+
'debug',
|
|
549
|
+
'Entity: adding pending operations',
|
|
550
|
+
this.oid,
|
|
551
|
+
operations,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// special case -- if this entity is pruned, any changes we apply to it
|
|
555
|
+
// will be in relation to 'exposed' pruned data, not the 'real world'
|
|
556
|
+
// data that's backing it. That means those changes will produce unexpected
|
|
557
|
+
// or further invalid results. To avoid this, we basically stamp in the
|
|
558
|
+
// pruned version of this entity before proceeding.
|
|
559
|
+
//
|
|
560
|
+
// as an example of a failure mode without this check, consider a list:
|
|
561
|
+
// [1, 2, <pruned>, 4, 5]
|
|
562
|
+
// the user sees: [1, 2, 4, 5]
|
|
563
|
+
// when they try to replace the item at index 2 with "0" (they see "4"), they
|
|
564
|
+
// actually replace the invisible pruned item, resulting in [1, 2, 0, 4, 5]
|
|
565
|
+
// being the result when they expected [1, 2, 0, 5].
|
|
566
|
+
//
|
|
567
|
+
// To "stamp" the data before applying user changes, we diff the snapshot
|
|
568
|
+
// (which is the pruned version) with the current state of the entity.
|
|
569
|
+
if (this.deepInvalid) {
|
|
570
|
+
this.ctx.log(
|
|
571
|
+
'warn',
|
|
572
|
+
'Changes are being applied to a pruned entity. This means that the pruned version is being treated as the new baseline and any pruned invalid data is lost.',
|
|
573
|
+
this.oid,
|
|
574
|
+
);
|
|
575
|
+
this.canonizePrunedVersion();
|
|
576
|
+
}
|
|
470
577
|
|
|
578
|
+
this.applyPendingOperations(operations);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// naming is fuzzy here, but this method was split out from
|
|
582
|
+
// addPendingOperations since that method also conditionally canonizes
|
|
583
|
+
// the pruned snapshot, and I wanted to keep the actual insertion of
|
|
584
|
+
// the ops in one place, so leaving it as part of addPendingOperations
|
|
585
|
+
// would introduce infinite recursion when canonizing.
|
|
586
|
+
private applyPendingOperations = (operations: Operation[]) => {
|
|
471
587
|
// apply authz to all operations
|
|
472
588
|
if (this.access) {
|
|
473
589
|
for (const op of operations) {
|
|
@@ -481,6 +597,21 @@ export class Entity<
|
|
|
481
597
|
}
|
|
482
598
|
};
|
|
483
599
|
|
|
600
|
+
private canonizePrunedVersion = () => {
|
|
601
|
+
const snapshot = this.getSnapshot();
|
|
602
|
+
const unprunedSnapshot = this.getUnprunedSnapshot();
|
|
603
|
+
const operations = this.patchCreator.createDiff(
|
|
604
|
+
unprunedSnapshot,
|
|
605
|
+
snapshot,
|
|
606
|
+
{
|
|
607
|
+
authz: this.access,
|
|
608
|
+
merge: false,
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
this.applyPendingOperations(operations);
|
|
613
|
+
};
|
|
614
|
+
|
|
484
615
|
private addConfirmedData = (data: EntityStoreEventData) => {
|
|
485
616
|
this.ctx.log('debug', 'Entity: adding confirmed data', this.oid);
|
|
486
617
|
const changes = this.metadataFamily.addConfirmedData(data);
|
|
@@ -927,7 +1058,7 @@ export class Entity<
|
|
|
927
1058
|
this.addPendingOperations(
|
|
928
1059
|
this.patchCreator.createDiff(this.getSnapshot(), changes, {
|
|
929
1060
|
mergeUnknownObjects: !replaceSubObjects,
|
|
930
|
-
|
|
1061
|
+
merge,
|
|
931
1062
|
}),
|
|
932
1063
|
);
|
|
933
1064
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AuthorizationKey,
|
|
2
3
|
DocumentBaseline,
|
|
3
4
|
ObjectIdentifier,
|
|
4
5
|
Operation,
|
|
@@ -20,7 +21,7 @@ export type EntityMetadataView = {
|
|
|
20
21
|
empty: boolean;
|
|
21
22
|
updatedAt: number;
|
|
22
23
|
latestTimestamp: string | null;
|
|
23
|
-
authz?:
|
|
24
|
+
authz?: AuthorizationKey;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export class EntityMetadata {
|
|
@@ -101,7 +102,7 @@ export class EntityMetadata {
|
|
|
101
102
|
// we don't use after for pending ops, they're all
|
|
102
103
|
// logically in the future
|
|
103
104
|
null,
|
|
104
|
-
|
|
105
|
+
);
|
|
105
106
|
if (pendingResult.authz) {
|
|
106
107
|
authz = pendingResult.authz;
|
|
107
108
|
}
|
|
@@ -221,10 +222,10 @@ export class EntityMetadata {
|
|
|
221
222
|
latestTimestamp: string | null;
|
|
222
223
|
deleted: boolean;
|
|
223
224
|
futureSeen: string | undefined;
|
|
224
|
-
authz?:
|
|
225
|
+
authz?: AuthorizationKey;
|
|
225
226
|
} => {
|
|
226
227
|
let futureSeen: string | undefined = undefined;
|
|
227
|
-
let authz:
|
|
228
|
+
let authz: AuthorizationKey | undefined = undefined;
|
|
228
229
|
|
|
229
230
|
const now = this.ctx.time.now;
|
|
230
231
|
for (const op of operations) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AuthorizationKey,
|
|
2
3
|
DocumentBaseline,
|
|
3
4
|
ObjectIdentifier,
|
|
4
5
|
Operation,
|
|
@@ -12,16 +13,15 @@ import {
|
|
|
12
13
|
groupPatchesByRootOid,
|
|
13
14
|
isRootOid,
|
|
14
15
|
removeOidsFromAllSubObjects,
|
|
15
|
-
AuthorizationKey,
|
|
16
16
|
} from '@verdant-web/common';
|
|
17
|
+
import { WeakEvent } from 'weak-event';
|
|
17
18
|
import { Context } from '../context/context.js';
|
|
18
|
-
import {
|
|
19
|
+
import { FileManager } from '../files/FileManager.js';
|
|
20
|
+
import { processValueFiles } from '../files/utils.js';
|
|
19
21
|
import { Disposable } from '../utils/Disposable.js';
|
|
22
|
+
import { Entity } from './Entity.js';
|
|
20
23
|
import { EntityFamilyMetadata } from './EntityMetadata.js';
|
|
21
|
-
import { FileManager } from '../files/FileManager.js';
|
|
22
24
|
import { OperationBatcher } from './OperationBatcher.js';
|
|
23
|
-
import { WeakEvent } from 'weak-event';
|
|
24
|
-
import { processValueFiles } from '../files/utils.js';
|
|
25
25
|
|
|
26
26
|
enum AbortReason {
|
|
27
27
|
Reset,
|
|
@@ -347,12 +347,11 @@ export class EntityStore extends Disposable {
|
|
|
347
347
|
);
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
-
const operations = this.ctx.patchCreator.createInitialize(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
350
|
+
const operations = this.ctx.patchCreator.createInitialize(
|
|
351
|
+
processed,
|
|
352
|
+
oid,
|
|
353
|
+
access,
|
|
354
|
+
);
|
|
356
355
|
await this.batcher.commitOperations(operations, {
|
|
357
356
|
undoable: !!undoable,
|
|
358
357
|
source: entity,
|
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
operationSupersedes,
|
|
12
12
|
} from '@verdant-web/common';
|
|
13
13
|
import { Context } from '../context/context.js';
|
|
14
|
-
import type { EntityStore } from './EntityStore.js';
|
|
15
14
|
import { Entity } from './Entity.js';
|
|
15
|
+
import type { EntityStore } from './EntityStore.js';
|
|
16
16
|
|
|
17
17
|
const DEFAULT_BATCH_KEY = '@@default';
|
|
18
18
|
|
|
@@ -257,7 +257,11 @@ export class OperationBatcher {
|
|
|
257
257
|
return Promise.all(this.batcher.flushAll());
|
|
258
258
|
};
|
|
259
259
|
|
|
260
|
-
private createUndo = async (data: {
|
|
260
|
+
private createUndo = async (data: {
|
|
261
|
+
ops: Operation[];
|
|
262
|
+
source?: Entity;
|
|
263
|
+
isRedo?: boolean;
|
|
264
|
+
}) => {
|
|
261
265
|
// this can't be done on-demand because we rely on the current
|
|
262
266
|
// state of the entities to calculate the inverse operations.
|
|
263
267
|
const inverseOps = await this.getInverseOperations(data);
|
|
@@ -268,11 +272,21 @@ export class OperationBatcher {
|
|
|
268
272
|
const redo = await this.createUndo({
|
|
269
273
|
ops: inverseOps,
|
|
270
274
|
source: data.source,
|
|
275
|
+
isRedo: true,
|
|
271
276
|
});
|
|
272
277
|
// set time to now for all undo operations, they're happening now.
|
|
273
278
|
for (const op of inverseOps) {
|
|
274
279
|
op.timestamp = this.ctx.time.now;
|
|
275
280
|
}
|
|
281
|
+
|
|
282
|
+
this.ctx.log(
|
|
283
|
+
'debug',
|
|
284
|
+
data.isRedo ? 'Redo' : 'Undo',
|
|
285
|
+
inverseOps,
|
|
286
|
+
'\n was \n',
|
|
287
|
+
data.ops,
|
|
288
|
+
);
|
|
289
|
+
|
|
276
290
|
await this.commitOperations(
|
|
277
291
|
inverseOps,
|
|
278
292
|
// undos should not generate their own undo operations
|