@contember/bindx 0.1.51 → 0.1.53

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 (54) hide show
  1. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  2. package/dist/core/EntityLoader.d.ts +7 -0
  3. package/dist/core/EntityLoader.d.ts.map +1 -1
  4. package/dist/core/actions.d.ts +1 -2
  5. package/dist/core/actions.d.ts.map +1 -1
  6. package/dist/dataview/types.d.ts +6 -6
  7. package/dist/dataview/types.d.ts.map +1 -1
  8. package/dist/handles/EntityHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +8 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  15. package/dist/store/HasManyStore.d.ts +94 -78
  16. package/dist/store/HasManyStore.d.ts.map +1 -1
  17. package/dist/store/RelationStore.d.ts +21 -12
  18. package/dist/store/RelationStore.d.ts.map +1 -1
  19. package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
  20. package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
  21. package/dist/store/SnapshotStore.d.ts +53 -15
  22. package/dist/store/SnapshotStore.d.ts.map +1 -1
  23. package/dist/store/SubscriptionManager.d.ts +19 -4
  24. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  25. package/dist/store/hasManyState.d.ts +164 -0
  26. package/dist/store/hasManyState.d.ts.map +1 -0
  27. package/dist/store/relationKey.d.ts +11 -0
  28. package/dist/store/relationKey.d.ts.map +1 -1
  29. package/dist/undo/UndoJournal.d.ts.map +1 -1
  30. package/dist/undo/rekeyJournalEntry.d.ts +4 -4
  31. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
  32. package/dist/utils/aliasGenerator.d.ts +1 -1
  33. package/dist/utils/aliasGenerator.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/core/ActionDispatcher.ts +0 -1
  36. package/src/core/EntityLoader.ts +13 -6
  37. package/src/core/actions.ts +1 -3
  38. package/src/dataview/types.ts +6 -6
  39. package/src/handles/EntityHandle.ts +11 -0
  40. package/src/handles/HasManyListHandle.ts +15 -17
  41. package/src/handles/HasOneHandle.ts +4 -2
  42. package/src/index.ts +6 -0
  43. package/src/persistence/BatchPersister.ts +15 -11
  44. package/src/store/HasManyStore.ts +312 -488
  45. package/src/store/RelationStore.ts +60 -24
  46. package/src/store/ResponseOccurrenceIndex.ts +224 -0
  47. package/src/store/SnapshotStore.ts +183 -109
  48. package/src/store/SubscriptionManager.ts +116 -58
  49. package/src/store/hasManyState.ts +408 -0
  50. package/src/store/relationKey.ts +17 -0
  51. package/src/undo/UndoJournal.ts +3 -1
  52. package/src/undo/UndoManager.ts +1 -1
  53. package/src/undo/rekeyJournalEntry.ts +31 -43
  54. package/src/utils/aliasGenerator.ts +1 -1
@@ -1,11 +1,15 @@
1
1
  import type { EntitySnapshot, LoadStatus } from './snapshots.js'
2
2
  import { createEntitySnapshot } from './snapshots.js'
3
3
  import type { FieldError, FieldErrorFilter } from '../errors/types.js'
4
- import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js'
4
+ import type { SelectionMeta } from '../selection/types.js'
5
+ import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult } from './SubscriptionManager.js'
5
6
  import { ErrorStore } from './ErrorStore.js'
6
7
  import {
7
8
  RelationStore,
9
+ type HasManyRelationProjection,
8
10
  type HasManyRemovalType,
11
+ type HasManyViewMembership,
12
+ type HasManyViewProjection,
9
13
  type RelationReconciliationResult,
10
14
  type SentHasManyDelta,
11
15
  type SentHasOneTransition,
@@ -18,6 +22,7 @@ import { generateTempId } from './entityId.js'
18
22
  import { DirtyTracker } from './DirtyTracker.js'
19
23
  import { EntitySnapshotStore } from './EntitySnapshotStore.js'
20
24
  import { RootRegistry } from './RootRegistry.js'
25
+ import { ResponseOccurrenceIndex, type RelationTargetResolver } from './ResponseOccurrenceIndex.js'
21
26
  import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js'
22
27
  import { RekeyOrchestrator } from './RekeyOrchestrator.js'
23
28
  import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
@@ -32,7 +37,13 @@ import type {
32
37
  } from '../undo/UndoJournal.js'
33
38
 
34
39
  export type {
40
+ HasManyAdditionKind,
41
+ HasManyRelationProjection,
35
42
  HasManyRemovalType,
43
+ HasManyView,
44
+ HasManyViewMembership,
45
+ HasManyViewProjection,
46
+ PlannedHasManyAddition,
36
47
  RelationReconciliationResult,
37
48
  SentHasManyAddition,
38
49
  SentHasManyDelta,
@@ -83,6 +94,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
83
94
  */
84
95
  private readonly lastPropagatedData = new Map<string, unknown>()
85
96
 
97
+ private readonly responseOccurrences = new ResponseOccurrenceIndex()
98
+
86
99
  /**
87
100
  * Optional write-journal. When set (by an attached UndoManager), mutating
88
101
  * methods record editable-layer pre-images of the cells they touch so a gesture
@@ -176,6 +189,11 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
176
189
  }
177
190
  }
178
191
 
192
+ /** Coalesces synchronous notifications, even on failure, without changing undo boundaries. */
193
+ batchNotifications<T>(fn: () => SynchronousResult<T>): T {
194
+ return this.subscriptions.batchNotifications(fn)
195
+ }
196
+
179
197
  // ==================== Key Generation ====================
180
198
 
181
199
  private getEntityKey(entityType: string, id: string): string {
@@ -304,6 +322,23 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
304
322
  return newSnapshot as EntitySnapshot<T>
305
323
  }
306
324
 
325
+ /**
326
+ * Unites the occurrences of each entity within one server response, so the
327
+ * narrower of two occurrences cannot replace fields the wider one carried.
328
+ * Call it before any of the response is written. See {@link ResponseOccurrenceIndex}.
329
+ */
330
+ indexServerResponse(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void {
331
+ this.responseOccurrences.index(entityType, data, selection, schema)
332
+ }
333
+
334
+ /**
335
+ * What to write for an entity occurrence of a server response: the union of
336
+ * its occurrences in that response, or the occurrence itself.
337
+ */
338
+ resolveServerOccurrence(occurrence: Record<string, unknown>): Record<string, unknown> {
339
+ return this.responseOccurrences.resolve(occurrence)
340
+ }
341
+
307
342
  /**
308
343
  * Refreshes server data from a revalidation read while preserving the user's
309
344
  * local dirty edits. See {@link EntitySnapshotStore.refreshServerData}.
@@ -525,25 +560,74 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
525
560
 
526
561
  // ==================== Has-Many State (delegated to RelationStore) ====================
527
562
 
563
+ /**
564
+ * Declares what a view read under {@link alias} can leave out, so the store can tell
565
+ * a filtered view from one that merely orders or renames. Only the caller that
566
+ * selected the relation knows the params; the alias is a hash of them.
567
+ */
568
+ declareHasManyViewMembership(
569
+ entityType: string,
570
+ fieldName: string,
571
+ alias: string,
572
+ membership: HasManyViewMembership,
573
+ ): void {
574
+ this.relations.declareHasManyViewMembership(entityType, fieldName, alias, membership)
575
+ }
576
+
577
+ /**
578
+ * Materializes the relation and one args-view of it, refreshing that view's
579
+ * server baseline when one is supplied.
580
+ *
581
+ * `alias` addresses a VIEW inside the relation state, never a key of its own:
582
+ * the state is always keyed by the schema field name, so everything that names
583
+ * the relation the way the schema and the server do finds it.
584
+ */
528
585
  getOrCreateHasMany(
529
586
  parentType: string,
530
587
  parentId: string,
531
588
  fieldName: string,
532
589
  serverIds?: string[],
533
590
  alias?: string,
534
- ): StoredHasManyState {
535
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
536
- return this.relations.getOrCreateHasMany(key, serverIds)
591
+ ): void {
592
+ const key = this.getRelationKey(parentType, parentId, fieldName)
593
+ this.relations.getOrCreateHasMany(key, alias ?? fieldName, serverIds)
537
594
  }
538
595
 
596
+ /** The relation as persistence and dirty tracking see it — server baseline unioned across views. */
539
597
  getHasMany(
540
598
  parentType: string,
541
599
  parentId: string,
542
600
  fieldName: string,
601
+ ): HasManyRelationProjection | undefined {
602
+ const key = this.getRelationKey(parentType, parentId, fieldName)
603
+ return this.relations.getHasManyRelation(key)
604
+ }
605
+
606
+ /** Whether the relation carries writes the next persist would send. */
607
+ hasPendingHasManyWrites(parentType: string, parentId: string, fieldName: string): boolean {
608
+ return this.relations.hasPendingHasManyWrites(this.getRelationKey(parentType, parentId, fieldName))
609
+ }
610
+
611
+ /** Whether one view carries an order its user arranged rather than the default one. */
612
+ hasExplicitHasManyOrder(
613
+ parentType: string,
614
+ parentId: string,
615
+ fieldName: string,
543
616
  alias?: string,
544
- ): StoredHasManyState | undefined {
545
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
546
- return this.relations.getHasMany(key)
617
+ ): boolean {
618
+ const key = this.getRelationKey(parentType, parentId, fieldName)
619
+ return this.relations.hasExplicitHasManyOrder(key, alias ?? fieldName)
620
+ }
621
+
622
+ /** The relation as ONE mounted has-many handle sees it. */
623
+ getHasManyView(
624
+ parentType: string,
625
+ parentId: string,
626
+ fieldName: string,
627
+ alias?: string,
628
+ ): HasManyViewProjection {
629
+ const key = this.getRelationKey(parentType, parentId, fieldName)
630
+ return this.relations.getHasManyView(key, alias ?? fieldName)
547
631
  }
548
632
 
549
633
  setHasManyServerIds(
@@ -553,8 +637,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
553
637
  serverIds: string[],
554
638
  alias?: string,
555
639
  ): void {
556
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
557
- this.relations.setHasManyServerIds(key, serverIds)
640
+ const key = this.getRelationKey(parentType, parentId, fieldName)
641
+ this.relations.setHasManyServerIds(key, alias ?? fieldName, serverIds)
558
642
  this.notifyRelationSubscribers(key)
559
643
  }
560
644
 
@@ -564,9 +648,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
564
648
  fieldName: string,
565
649
  itemId: string,
566
650
  type: HasManyRemovalType,
567
- alias?: string,
568
651
  ): void {
569
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
652
+ const key = this.getRelationKey(parentType, parentId, fieldName)
570
653
  this.journal?.recordHasMany(key)
571
654
  this.relations.planHasManyRemoval(key, itemId, type)
572
655
  this.notifyRelationSubscribers(key)
@@ -576,10 +659,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
576
659
  parentType: string,
577
660
  parentId: string,
578
661
  fieldName: string,
579
- alias?: string,
580
662
  ): Map<string, HasManyRemovalType> | undefined {
581
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
582
- return this.relations.getHasMany(key)?.plannedRemovals
663
+ const key = this.getRelationKey(parentType, parentId, fieldName)
664
+ return this.relations.getHasManyRelation(key)?.plannedRemovals
583
665
  }
584
666
 
585
667
  /**
@@ -598,34 +680,22 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
598
680
  itemId: string,
599
681
  alias?: string,
600
682
  ): void {
601
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
683
+ const key = this.getRelationKey(parentType, parentId, fieldName)
602
684
  this.journal?.recordHasMany(key)
603
- this.relations.planHasManyConnection(key, itemId)
685
+ this.relations.planHasManyConnection(key, alias ?? fieldName, itemId)
604
686
  this.notifyRelationSubscribers(key)
605
687
  }
606
688
 
689
+ /** The connections the next persist will send — relation-level, not per view. */
607
690
  getHasManyPlannedConnections(
608
691
  parentType: string,
609
692
  parentId: string,
610
693
  fieldName: string,
611
- alias?: string,
612
694
  ): Set<string> | undefined {
613
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
614
- const state = this.relations.getHasMany(key)
615
- if (!state) return undefined
616
- return new Set(state.plannedAdditions.keys())
617
- }
618
-
619
- commitHasMany(
620
- parentType: string,
621
- parentId: string,
622
- fieldName: string,
623
- newServerIds: string[],
624
- alias?: string,
625
- ): void {
626
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
627
- this.relations.commitHasMany(key, newServerIds)
628
- this.notifyRelationSubscribers(key)
695
+ const key = this.getRelationKey(parentType, parentId, fieldName)
696
+ const relation = this.relations.getHasManyRelation(key)
697
+ if (!relation) return undefined
698
+ return new Set(relation.plannedAdditions.keys())
629
699
  }
630
700
 
631
701
  reconcileSentHasMany(
@@ -633,9 +703,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
633
703
  parentId: string,
634
704
  fieldName: string,
635
705
  delta: SentHasManyDelta,
636
- alias?: string,
637
706
  ): RelationReconciliationResult {
638
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
707
+ const key = this.getRelationKey(parentType, parentId, fieldName)
639
708
  const result = this.relations.reconcileSentHasMany(key, delta)
640
709
  this.notifyRelationSubscribers(key)
641
710
  return result
@@ -645,9 +714,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
645
714
  parentType: string,
646
715
  parentId: string,
647
716
  fieldName: string,
648
- alias?: string,
649
717
  ): void {
650
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
718
+ const key = this.getRelationKey(parentType, parentId, fieldName)
651
719
  this.journal?.recordHasMany(key)
652
720
  this.relations.resetHasMany(key)
653
721
  this.notifyRelationSubscribers(key)
@@ -660,9 +728,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
660
728
  itemId: string,
661
729
  alias?: string,
662
730
  ): void {
663
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
731
+ const key = this.getRelationKey(parentType, parentId, fieldName)
664
732
  this.journal?.recordHasMany(key)
665
- this.relations.addToHasMany(key, itemId)
733
+ this.relations.addToHasMany(key, alias ?? fieldName, itemId)
666
734
  this.notifyRelationSubscribers(key)
667
735
  }
668
736
 
@@ -674,7 +742,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
674
742
  ): void {
675
743
  const key = this.getRelationKey(parentType, parentId, fieldName)
676
744
  this.journal?.recordHasMany(key)
677
- this.relations.connectExistingToHasMany(key, itemId)
745
+ this.relations.connectExistingToHasMany(key, fieldName, itemId)
678
746
  this.notifyRelationSubscribers(key)
679
747
  }
680
748
 
@@ -684,9 +752,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
684
752
  fieldName: string,
685
753
  itemId: string,
686
754
  removalType: HasManyRemovalType,
687
- alias?: string,
688
755
  ): void {
689
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
756
+ const key = this.getRelationKey(parentType, parentId, fieldName)
690
757
  this.journal?.recordHasMany(key)
691
758
  // Cancelling the add of a never-persisted child just removes it from the
692
759
  // list; its now-unreachable snapshot is no longer reported as a `create` and
@@ -704,9 +771,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
704
771
  toIndex: number,
705
772
  alias?: string,
706
773
  ): void {
707
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
774
+ const key = this.getRelationKey(parentType, parentId, fieldName)
708
775
  this.journal?.recordHasMany(key)
709
- this.relations.moveInHasMany(key, fromIndex, toIndex)
776
+ this.relations.moveInHasMany(key, alias ?? fieldName, fromIndex, toIndex)
710
777
  this.notifyRelationSubscribers(key)
711
778
  }
712
779
 
@@ -716,8 +783,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
716
783
  fieldName: string,
717
784
  alias?: string,
718
785
  ): string[] {
719
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
720
- return this.relations.getHasManyOrderedIds(key)
786
+ const key = this.getRelationKey(parentType, parentId, fieldName)
787
+ return this.relations.getHasManyOrderedIds(key, alias ?? fieldName)
721
788
  }
722
789
 
723
790
  getPresentationHasManyOrderedIds(
@@ -726,13 +793,13 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
726
793
  fieldName: string,
727
794
  alias?: string,
728
795
  ): string[] {
729
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
796
+ const key = this.getRelationKey(parentType, parentId, fieldName)
797
+ const view = alias ?? fieldName
730
798
  if (!this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) {
731
- return this.relations.getHasManyOrderedIds(key)
799
+ return this.relations.getHasManyOrderedIds(key, view)
732
800
  }
733
801
 
734
- const state = this.relations.getHasMany(key)
735
- return state ? [...state.serverIds] : []
802
+ return [...this.relations.getHasManyView(key, view).serverIds]
736
803
  }
737
804
 
738
805
  isHasManyItemCreated(
@@ -740,24 +807,21 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
740
807
  parentId: string,
741
808
  fieldName: string,
742
809
  itemId: string,
743
- alias?: string,
744
810
  ): boolean {
745
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
746
- const state = this.relations.getHasMany(key)
747
- return state?.plannedAdditions.get(itemId) === 'created'
811
+ const key = this.getRelationKey(parentType, parentId, fieldName)
812
+ return this.relations.getHasManyRelation(key)?.plannedAdditions.get(itemId) === 'created'
748
813
  }
749
814
 
750
815
  getHasManyCreatedEntities(
751
816
  parentType: string,
752
817
  parentId: string,
753
818
  fieldName: string,
754
- alias?: string,
755
819
  ): Set<string> | undefined {
756
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
757
- const state = this.relations.getHasMany(key)
758
- if (!state) return undefined
820
+ const key = this.getRelationKey(parentType, parentId, fieldName)
821
+ const relation = this.relations.getHasManyRelation(key)
822
+ if (!relation) return undefined
759
823
  const created = new Set<string>()
760
- for (const [id, kind] of state.plannedAdditions) {
824
+ for (const [id, kind] of relation.plannedAdditions) {
761
825
  if (kind === 'created') created.add(id)
762
826
  }
763
827
  return created
@@ -1105,23 +1169,16 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1105
1169
  hasManyStates: Map<string, StoredHasManyState>
1106
1170
  entityMetas: Map<string, EntityMeta>
1107
1171
  }): void {
1108
- const notifiedEntityKeys = this.entitySnapshots.importSnapshots(snapshot.entitySnapshots)
1109
-
1110
- this.meta.importMetas(snapshot.entityMetas)
1172
+ this.subscriptions.batchNotifications(() => {
1173
+ const entityKeys = this.entitySnapshots.importSnapshots(snapshot.entitySnapshots)
1174
+ this.meta.importMetas(snapshot.entityMetas)
1175
+ const relationKeys = this.relations.importRelationStates(snapshot.relationStates)
1176
+ const hasManyKeys = this.relations.importHasManyStates(snapshot.hasManyStates)
1111
1177
 
1112
- const relationKeys = this.relations.importRelationStates(snapshot.relationStates)
1113
- const hasManyKeys = this.relations.importHasManyStates(snapshot.hasManyStates)
1114
- const notifiedRelationKeys = new Set([...relationKeys, ...hasManyKeys])
1115
-
1116
- this.subscriptions.notifyGlobal()
1117
-
1118
- for (const key of notifiedEntityKeys) {
1119
- this.subscriptions.notifyEntityDirect(key)
1120
- }
1121
-
1122
- for (const key of notifiedRelationKeys) {
1123
- this.subscriptions.notifyRelationDirect(key)
1124
- }
1178
+ this.subscriptions.notify()
1179
+ for (const key of entityKeys) this.subscriptions.notifyEntityDirect(key)
1180
+ for (const key of [...relationKeys, ...hasManyKeys]) this.subscriptions.notifyRelationDirect(key)
1181
+ })
1125
1182
  }
1126
1183
 
1127
1184
  // ==================== Undo Journal Target (cell capture / restore) ====================
@@ -1163,25 +1220,19 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1163
1220
  * journal's rekey to rebase a pre-image when a just-persisted create became a
1164
1221
  * permanent member of the list (membership rebase for sealed creates).
1165
1222
  */
1166
- getLiveHasManyServerIds(relationKey: string): Set<string> {
1167
- const state = this.relations.getHasMany(relationKey)
1168
- return new Set(state?.serverIds ?? [])
1223
+ getLiveHasManyServerIds(relationKey: string): ReadonlyMap<string, ReadonlySet<string>> {
1224
+ return this.relations.collectHasManyViewServerIds(relationKey)
1169
1225
  }
1170
1226
 
1171
1227
  exportHasManyCell(key: string): HasManyCellImage {
1172
- const state = this.relations.getHasMany(key)
1228
+ const state = this.relations.getHasManyState(key)
1173
1229
  if (!state) return { kind: 'hasMany', key, present: false }
1174
1230
  return {
1175
1231
  kind: 'hasMany',
1176
1232
  key,
1177
1233
  present: true,
1178
- state: {
1179
- serverIds: new Set(state.serverIds),
1180
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
1181
- plannedRemovals: new Map(state.plannedRemovals),
1182
- plannedAdditions: new Map(state.plannedAdditions),
1183
- version: state.version,
1184
- },
1234
+ // getHasMany already returns a deep copy; the image owns it outright.
1235
+ state,
1185
1236
  }
1186
1237
  }
1187
1238
 
@@ -1220,7 +1271,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1220
1271
  const image = this.exportHasManyCell(hasManyKey)
1221
1272
  images.push(image)
1222
1273
  if (image.state) {
1223
- if (image.state.orderedIds) worklist.push(...image.state.orderedIds)
1274
+ for (const view of image.state.views.values()) {
1275
+ if (view.orderedIds) worklist.push(...view.orderedIds)
1276
+ }
1224
1277
  for (const additionId of image.state.plannedAdditions.keys()) worklist.push(additionId)
1225
1278
  }
1226
1279
  }
@@ -1236,9 +1289,6 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1236
1289
  * then present relations, then un-creates, then dropped relations.
1237
1290
  */
1238
1291
  applyJournalImages(images: JournalCellImage[]): void {
1239
- const notifyEntities = new Set<string>()
1240
- const notifyRelations = new Set<string>()
1241
-
1242
1292
  const presentEntities: EntityCellImage[] = []
1243
1293
  const presentRelations: Array<RelationCellImage | HasManyCellImage> = []
1244
1294
  const absentEntities: EntityCellImage[] = []
@@ -1246,29 +1296,37 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1246
1296
 
1247
1297
  for (const img of images) {
1248
1298
  if (img.kind === 'entity') {
1249
- notifyEntities.add(img.key)
1250
1299
  ;(img.present ? presentEntities : absentEntities).push(img)
1251
1300
  } else {
1252
- notifyRelations.add(img.key)
1253
- notifyEntities.add(entityKeyOfRelationKey(img.key))
1254
1301
  ;(img.present ? presentRelations : absentRelations).push(img)
1255
1302
  }
1256
1303
  }
1257
1304
 
1258
- for (const img of presentEntities) this.applyEntityImage(img)
1259
- for (const img of presentRelations) this.applyRelationImage(img)
1260
- for (const img of absentEntities) {
1261
- const [type, id] = splitEntityKey(img.key)
1262
- this.removeEntity(type, id)
1263
- }
1264
- for (const img of absentRelations) {
1265
- if (img.kind === 'relation') this.relations.removeRelationState(img.key)
1266
- else this.relations.removeHasManyState(img.key)
1267
- }
1305
+ // removeEntity notifies; the batch holds that back until the restore is complete.
1306
+ this.subscriptions.batchNotifications(() => {
1307
+ for (const img of presentEntities) this.applyEntityImage(img)
1308
+ for (const img of presentRelations) this.applyRelationImage(img)
1309
+ for (const img of absentEntities) {
1310
+ const [type, id] = splitEntityKey(img.key)
1311
+ this.removeEntity(type, id)
1312
+ }
1313
+ for (const img of absentRelations) {
1314
+ if (img.kind === 'relation') this.relations.removeRelationState(img.key)
1315
+ else this.relations.removeHasManyState(img.key)
1316
+ }
1268
1317
 
1269
- this.subscriptions.notifyGlobal()
1270
- for (const key of notifyEntities) this.subscriptions.notifyEntityDirect(key)
1271
- for (const key of notifyRelations) this.subscriptions.notifyRelationDirect(key)
1318
+ this.subscriptions.notify()
1319
+ for (const img of images) this.notifyRestoredCell(img)
1320
+ })
1321
+ }
1322
+
1323
+ private notifyRestoredCell(img: JournalCellImage): void {
1324
+ if (img.kind === 'entity') {
1325
+ this.subscriptions.notifyEntityDirect(img.key)
1326
+ return
1327
+ }
1328
+ this.subscriptions.notifyRelationDirect(img.key)
1329
+ this.subscriptions.notifyEntityDirect(entityKeyOfRelationKey(img.key))
1272
1330
  }
1273
1331
 
1274
1332
  private applyEntityImage(img: EntityCellImage): void {
@@ -1315,10 +1373,26 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
1315
1373
  }]]))
1316
1374
  } else {
1317
1375
  const s = img.state!
1318
- const live = this.relations.getHasMany(img.key)
1376
+ const live = this.relations.getHasManyState(img.key)
1377
+ // The editable layer (planned writes, manual ordering) comes from the image;
1378
+ // the server baseline always from the live state, which may have advanced
1379
+ // since the gesture. A view the image does not know did not exist before the
1380
+ // gesture — the gesture itself may have created it — so it had no arranged
1381
+ // order to restore and falls back to the default order of the restored writes.
1382
+ const views = new Map(live?.views ?? [])
1383
+ for (const [alias, view] of views) {
1384
+ const recorded = s.views.get(alias)
1385
+ views.set(alias, {
1386
+ serverIds: new Set(view.serverIds),
1387
+ orderedIds: recorded?.orderedIds ? [...recorded.orderedIds] : null,
1388
+ membership: view.membership,
1389
+ })
1390
+ }
1391
+ for (const [alias, recorded] of s.views) {
1392
+ if (!views.has(alias)) views.set(alias, recorded)
1393
+ }
1319
1394
  this.relations.importHasManyStates(new Map([[img.key, {
1320
- serverIds: new Set(live ? live.serverIds : s.serverIds),
1321
- orderedIds: s.orderedIds ? [...s.orderedIds] : null,
1395
+ views,
1322
1396
  plannedRemovals: new Map(s.plannedRemovals),
1323
1397
  plannedAdditions: new Map(s.plannedAdditions),
1324
1398
  version: (live?.version ?? s.version) + 1,