@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.
@@ -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
- this.cachedView[key] = child;
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
- depth: 1,
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('debug', 'Entity: adding pending operations', this.oid);
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
- defaultUndefined: merge,
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?: string;
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?: string;
225
+ authz?: AuthorizationKey;
225
226
  } => {
226
227
  let futureSeen: string | undefined = undefined;
227
- let authz: string | undefined = undefined;
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 { Entity } from './Entity.js';
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(processed, oid);
351
- if (access) {
352
- operations.forEach((op) => {
353
- op.authz = access;
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: { ops: Operation[]; source?: Entity }) => {
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