@contember/bindx 0.1.46 → 0.1.48

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 (121) hide show
  1. package/dist/adapter/MockAdapter.d.ts +4 -1
  2. package/dist/adapter/MockAdapter.d.ts.map +1 -1
  3. package/dist/core/ActionDispatcher.d.ts +4 -1
  4. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  5. package/dist/core/actions.d.ts +7 -1
  6. package/dist/core/actions.d.ts.map +1 -1
  7. package/dist/events/EventEmitter.d.ts +13 -1
  8. package/dist/events/EventEmitter.d.ts.map +1 -1
  9. package/dist/handles/BaseHandle.d.ts +14 -3
  10. package/dist/handles/BaseHandle.d.ts.map +1 -1
  11. package/dist/handles/EntityHandle.d.ts +14 -5
  12. package/dist/handles/EntityHandle.d.ts.map +1 -1
  13. package/dist/handles/FieldHandle.d.ts +11 -4
  14. package/dist/handles/FieldHandle.d.ts.map +1 -1
  15. package/dist/handles/HasManyListHandle.d.ts +36 -2
  16. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  17. package/dist/handles/HasOneHandle.d.ts +52 -9
  18. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  19. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  20. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  21. package/dist/handles/index.d.ts +1 -1
  22. package/dist/handles/index.d.ts.map +1 -1
  23. package/dist/handles/proxyFactory.d.ts +7 -1
  24. package/dist/handles/proxyFactory.d.ts.map +1 -1
  25. package/dist/handles/types.d.ts +33 -0
  26. package/dist/handles/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/persistence/BatchPersister.d.ts +53 -37
  30. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  31. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  32. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  33. package/dist/persistence/MutationCollector.d.ts +106 -3
  34. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  35. package/dist/persistence/PersistExecution.d.ts +26 -0
  36. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  37. package/dist/persistence/types.d.ts +8 -2
  38. package/dist/persistence/types.d.ts.map +1 -1
  39. package/dist/store/EntityMetaStore.d.ts +32 -10
  40. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  41. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  42. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  43. package/dist/store/ErrorStore.d.ts +3 -2
  44. package/dist/store/ErrorStore.d.ts.map +1 -1
  45. package/dist/store/HasManyStore.d.ts +214 -0
  46. package/dist/store/HasManyStore.d.ts.map +1 -0
  47. package/dist/store/HasOneStore.d.ts +159 -0
  48. package/dist/store/HasOneStore.d.ts.map +1 -0
  49. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  50. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  51. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  52. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  53. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  54. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  55. package/dist/store/RelationStore.d.ts +74 -137
  56. package/dist/store/RelationStore.d.ts.map +1 -1
  57. package/dist/store/RootRegistry.d.ts +25 -4
  58. package/dist/store/RootRegistry.d.ts.map +1 -1
  59. package/dist/store/SnapshotStore.d.ts +137 -13
  60. package/dist/store/SnapshotStore.d.ts.map +1 -1
  61. package/dist/store/SubscriptionManager.d.ts +63 -14
  62. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  63. package/dist/store/TouchedStore.d.ts +3 -2
  64. package/dist/store/TouchedStore.d.ts.map +1 -1
  65. package/dist/store/relationKey.d.ts +15 -0
  66. package/dist/store/relationKey.d.ts.map +1 -0
  67. package/dist/undo/UndoJournal.d.ts +133 -0
  68. package/dist/undo/UndoJournal.d.ts.map +1 -0
  69. package/dist/undo/UndoManager.d.ts +25 -82
  70. package/dist/undo/UndoManager.d.ts.map +1 -1
  71. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  72. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  73. package/dist/undo/index.d.ts +3 -1
  74. package/dist/undo/index.d.ts.map +1 -1
  75. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  76. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  77. package/dist/undo/types.d.ts +0 -48
  78. package/dist/undo/types.d.ts.map +1 -1
  79. package/package.json +2 -2
  80. package/src/adapter/MockAdapter.ts +15 -5
  81. package/src/core/ActionDispatcher.ts +56 -36
  82. package/src/core/actions.ts +8 -1
  83. package/src/events/EventEmitter.ts +78 -24
  84. package/src/handles/BaseHandle.ts +19 -2
  85. package/src/handles/EntityHandle.ts +48 -17
  86. package/src/handles/FieldHandle.ts +36 -10
  87. package/src/handles/HasManyListHandle.ts +120 -16
  88. package/src/handles/HasOneHandle.ts +177 -44
  89. package/src/handles/PlaceholderHandle.ts +184 -36
  90. package/src/handles/index.ts +3 -0
  91. package/src/handles/proxyFactory.ts +27 -0
  92. package/src/handles/types.ts +39 -0
  93. package/src/index.ts +4 -4
  94. package/src/persistence/BatchPersister.ts +810 -446
  95. package/src/persistence/ChangeRegistry.ts +27 -3
  96. package/src/persistence/MutationCollector.ts +265 -31
  97. package/src/persistence/PersistExecution.ts +217 -0
  98. package/src/persistence/types.ts +8 -2
  99. package/src/store/EntityMetaStore.ts +89 -45
  100. package/src/store/EntitySnapshotStore.ts +102 -19
  101. package/src/store/ErrorStore.ts +6 -5
  102. package/src/store/HasManyStore.ts +810 -0
  103. package/src/store/HasOneStore.ts +454 -0
  104. package/src/store/ReachabilityAnalyzer.ts +37 -0
  105. package/src/store/RekeyOrchestrator.ts +123 -0
  106. package/src/store/RelationEdgeIndex.ts +79 -0
  107. package/src/store/RelationStore.ts +176 -725
  108. package/src/store/RootRegistry.ts +50 -8
  109. package/src/store/SnapshotStore.ts +572 -101
  110. package/src/store/SubscriptionManager.ts +122 -75
  111. package/src/store/TouchedStore.ts +5 -2
  112. package/src/store/relationKey.ts +20 -0
  113. package/src/undo/UndoJournal.ts +258 -0
  114. package/src/undo/UndoManager.ts +151 -281
  115. package/src/undo/UnrecordedWriteError.ts +23 -0
  116. package/src/undo/index.ts +8 -7
  117. package/src/undo/rekeyJournalEntry.ts +145 -0
  118. package/src/undo/types.ts +0 -58
  119. package/dist/core/actionClassification.d.ts +0 -48
  120. package/dist/core/actionClassification.d.ts.map +0 -1
  121. package/src/core/actionClassification.ts +0 -199
@@ -0,0 +1,217 @@
1
+ import type { DirtyEntity } from './ChangeRegistry.js'
2
+ import type {
3
+ CollectedHasManyChange,
4
+ CollectedHasOneChange,
5
+ CollectedNestedCreate,
6
+ CollectedNestedEntity,
7
+ CollectedNestedUpdate,
8
+ CollectedRelationField,
9
+ } from './MutationCollector.js'
10
+ import type { PersistScope, TransactionMutation } from './types.js'
11
+ import type { SnapshotStore } from '../store/SnapshotStore.js'
12
+
13
+ export interface ExecutionEntity {
14
+ readonly entityType: string
15
+ readonly entityId: string
16
+ readonly operation: 'create' | 'update' | 'delete'
17
+ readonly scalarData: Readonly<Record<string, unknown>>
18
+ readonly nested: boolean
19
+ }
20
+
21
+ export interface PersistExecution {
22
+ readonly mutations: readonly TransactionMutation[]
23
+ readonly entities: readonly ExecutionEntity[]
24
+ readonly hasOneChanges: readonly CollectedHasOneChange[]
25
+ readonly hasManyChanges: readonly CollectedHasManyChange[]
26
+ readonly vetoed: readonly DirtyEntity[]
27
+ readonly nestedEntities: readonly CollectedNestedEntity[]
28
+ readonly relationFields: readonly CollectedRelationField[]
29
+ readonly nestedCreates: readonly CollectedNestedCreate[]
30
+ readonly nestedUpdates: readonly CollectedNestedUpdate[]
31
+ readonly suppressedRelationItems: ReadonlyMap<string, ReadonlySet<string>>
32
+ }
33
+
34
+ export function entityIdentityKey(entityType: string, entityId: string): string {
35
+ return `${entityType}:${entityId}`
36
+ }
37
+
38
+ export function createPersistExecution(
39
+ store: SnapshotStore,
40
+ mutations: readonly TransactionMutation[],
41
+ entities: readonly DirtyEntity[],
42
+ nestedEntities: readonly CollectedNestedEntity[],
43
+ relationFieldMetadata: readonly CollectedRelationField[],
44
+ nestedCreateMetadata: readonly CollectedNestedCreate[],
45
+ nestedUpdateMetadata: readonly CollectedNestedUpdate[],
46
+ hasOneChanges: readonly CollectedHasOneChange[],
47
+ hasManyChanges: readonly CollectedHasManyChange[],
48
+ vetoed: readonly DirtyEntity[],
49
+ suppressedRelationItems: ReadonlyMap<string, ReadonlySet<string>>,
50
+ scope: PersistScope,
51
+ ): PersistExecution {
52
+ const uniqueHasOne = new Map(
53
+ hasOneChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]),
54
+ )
55
+ const uniqueHasMany = new Map(
56
+ hasManyChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]),
57
+ )
58
+ const sentHasOneChanges = [...uniqueHasOne.values()]
59
+ const sentHasManyChanges = [...uniqueHasMany.values()]
60
+ const uniqueNestedCreates = new Map(
61
+ nestedCreateMetadata.map(create => [nestedIdentityKey(
62
+ create.parentEntityType,
63
+ create.parentEntityId,
64
+ create.fieldName,
65
+ create.entityType,
66
+ create.entityId,
67
+ ), create]),
68
+ )
69
+ const uniqueNestedUpdates = new Map(
70
+ nestedUpdateMetadata.map(update => [nestedIdentityKey(
71
+ update.parentEntityType,
72
+ update.parentEntityId,
73
+ update.fieldName,
74
+ update.entityType,
75
+ update.entityId,
76
+ ), update]),
77
+ )
78
+ const nestedCreates = [...uniqueNestedCreates.values()]
79
+ const nestedUpdates = [...uniqueNestedUpdates.values()]
80
+ const topLevelOperations = new Map(
81
+ mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation.operation]),
82
+ )
83
+ const topLevelMutations = new Map(
84
+ mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation]),
85
+ )
86
+ const uniqueRelationFields = new Map(
87
+ relationFieldMetadata.map(field => [relationIdentityKey(field.entityType, field.entityId, field.fieldName), field]),
88
+ )
89
+ const relationFields = new Map<string, Set<string>>()
90
+ for (const change of uniqueRelationFields.values()) {
91
+ const key = entityIdentityKey(change.entityType, change.entityId)
92
+ const fields = relationFields.get(key)
93
+ if (fields) fields.add(change.fieldName)
94
+ else relationFields.set(key, new Set([change.fieldName]))
95
+ }
96
+ const nestedKeys = new Set(nestedEntities.map(entity => entityIdentityKey(entity.entityType, entity.entityId)))
97
+ const nestedMutationData = new Map<string, Readonly<Record<string, unknown>>>()
98
+ for (const create of nestedCreates) {
99
+ nestedMutationData.set(entityIdentityKey(create.entityType, create.entityId), create.createData)
100
+ }
101
+ for (const update of nestedUpdates) {
102
+ nestedMutationData.set(entityIdentityKey(update.entityType, update.entityId), update.data)
103
+ nestedKeys.add(entityIdentityKey(update.entityType, update.entityId))
104
+ }
105
+ const all = new Map<string, ExecutionEntity>()
106
+
107
+ const candidates: DirtyEntity[] = [...entities]
108
+ for (const nested of nestedEntities) {
109
+ candidates.push({
110
+ entityType: nested.entityType,
111
+ entityId: nested.entityId,
112
+ changeType: 'create',
113
+ dirtyFields: [],
114
+ dirtyRelations: [],
115
+ })
116
+ }
117
+ for (const nested of nestedUpdates) {
118
+ candidates.push({
119
+ entityType: nested.entityType,
120
+ entityId: nested.entityId,
121
+ changeType: 'update',
122
+ dirtyFields: Object.keys(nested.data),
123
+ dirtyRelations: [],
124
+ })
125
+ }
126
+
127
+ for (const entity of candidates) {
128
+ const key = entityIdentityKey(entity.entityType, entity.entityId)
129
+ if (all.has(key) || vetoed.some(item => entityIdentityKey(item.entityType, item.entityId) === key)) continue
130
+ const operation = topLevelOperations.get(key) ?? entity.changeType
131
+ all.set(key, {
132
+ entityType: entity.entityType,
133
+ entityId: entity.entityId,
134
+ operation,
135
+ scalarData: collectSentScalars(
136
+ store,
137
+ entity,
138
+ scope,
139
+ topLevelMutations.get(key)?.data ?? nestedMutationData.get(key),
140
+ relationFields.get(key) ?? new Set(),
141
+ ),
142
+ nested: nestedKeys.has(key),
143
+ })
144
+ }
145
+
146
+ return {
147
+ mutations: mutations.map(mutation => ({
148
+ ...mutation,
149
+ data: mutation.data ? { ...mutation.data } : undefined,
150
+ })),
151
+ entities: [...all.values()],
152
+ hasOneChanges: sentHasOneChanges.map(change => ({ ...change, transition: { ...change.transition } })),
153
+ hasManyChanges: sentHasManyChanges.map(change => ({
154
+ ...change,
155
+ additions: change.additions.map(addition => ({ ...addition })),
156
+ removals: change.removals.map(removal => ({ ...removal })),
157
+ })),
158
+ vetoed: [...vetoed],
159
+ nestedEntities: [...nestedEntities],
160
+ relationFields: [...uniqueRelationFields.values()],
161
+ nestedCreates: nestedCreates.map(create => ({
162
+ ...create,
163
+ createData: { ...create.createData },
164
+ knownServerIds: [...create.knownServerIds],
165
+ })),
166
+ nestedUpdates: nestedUpdates.map(update => ({ ...update, data: { ...update.data } })),
167
+ suppressedRelationItems: new Map(
168
+ [...suppressedRelationItems].map(([key, ids]) => [key, new Set(ids)]),
169
+ ),
170
+ }
171
+ }
172
+
173
+ function relationIdentityKey(entityType: string, entityId: string, fieldName: string): string {
174
+ return `${entityIdentityKey(entityType, entityId)}:${fieldName}`
175
+ }
176
+
177
+ function nestedIdentityKey(
178
+ parentEntityType: string,
179
+ parentEntityId: string,
180
+ fieldName: string,
181
+ entityType: string,
182
+ entityId: string,
183
+ ): string {
184
+ return `${relationIdentityKey(parentEntityType, parentEntityId, fieldName)}>${entityIdentityKey(entityType, entityId)}`
185
+ }
186
+
187
+ function collectSentScalars(
188
+ store: SnapshotStore,
189
+ entity: Pick<DirtyEntity, 'entityType' | 'entityId' | 'changeType' | 'dirtyFields'>,
190
+ scope: PersistScope,
191
+ mutationData: Readonly<Record<string, unknown>> | undefined,
192
+ relationFields: ReadonlySet<string>,
193
+ ): Readonly<Record<string, unknown>> {
194
+ if (entity.changeType === 'delete') return {}
195
+ if (mutationData) {
196
+ const result: Record<string, unknown> = {}
197
+ for (const [field, value] of Object.entries(mutationData)) {
198
+ if (!relationFields.has(field)) result[field] = value
199
+ }
200
+ return result
201
+ }
202
+ const snapshot = store.getEntitySnapshot<Record<string, unknown>>(entity.entityType, entity.entityId)
203
+ if (!snapshot) return {}
204
+ const fields = scope.type === 'fields'
205
+ && scope.entityType === entity.entityType
206
+ && scope.entityId === entity.entityId
207
+ ? scope.fields
208
+ : entity.changeType === 'create'
209
+ ? Object.keys(snapshot.data)
210
+ : store.getDirtyFields(entity.entityType, entity.entityId)
211
+ const result: Record<string, unknown> = {}
212
+ for (const field of fields) {
213
+ if (field === 'id' && entity.entityId.startsWith('__temp_')) continue
214
+ result[field] = snapshot.data[field]
215
+ }
216
+ return result
217
+ }
@@ -97,6 +97,12 @@ export interface EntityPersistResult {
97
97
  readonly entityId: string
98
98
  readonly operation: 'create' | 'update' | 'delete'
99
99
  readonly success: boolean
100
+ /**
101
+ * The entity was deliberately not sent (vetoed by an `entity:persisting`
102
+ * interceptor). `success` is false, but it counts towards `skippedCount`,
103
+ * not `failedCount`.
104
+ */
105
+ readonly skipped?: true
100
106
  readonly error?: PersistError
101
107
  readonly fieldResults?: readonly FieldPersistResult[]
102
108
  /** Server-assigned ID for creates (when tempId was used) */
@@ -121,9 +127,9 @@ export interface PersistenceResult {
121
127
  readonly results: readonly EntityPersistResult[]
122
128
  /** Number of successfully persisted entities */
123
129
  readonly successCount: number
124
- /** Number of failed entities */
130
+ /** Number of entities the server rejected — results with `success: false` and no `skipped` flag */
125
131
  readonly failedCount: number
126
- /** Number of entities skipped (e.g., already in-flight) */
132
+ /** Number of entities not sent: already in-flight, or vetoed by an interceptor (listed in `results` with `skipped`) */
127
133
  readonly skippedCount: number
128
134
  }
129
135
 
@@ -1,6 +1,6 @@
1
1
  import type { LoadStatus } from './snapshots.js'
2
- import { isPersistedId, isPlaceholderId } from './entityId.js'
3
2
  import type { FieldError } from '../errors/types.js'
3
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
4
4
 
5
5
  /**
6
6
  * Entity load state tracking.
@@ -26,7 +26,7 @@ export interface EntityMeta {
26
26
  * Keys are pre-computed composite strings (e.g., "entityType:id").
27
27
  * Follows the same pattern as ErrorStore and RelationStore.
28
28
  */
29
- export class EntityMetaStore {
29
+ export class EntityMetaStore implements Rekeyable {
30
30
  /** Load states keyed by "entityType:id" */
31
31
  private readonly loadStates = new Map<string, EntityLoadState>()
32
32
 
@@ -36,8 +36,37 @@ export class EntityMetaStore {
36
36
  /** Persisting status keyed by "entityType:id" */
37
37
  private readonly persistingEntities = new Set<string>()
38
38
 
39
- /** Mapping from temp ID to persisted ID (keyed by "entityType:tempId") */
40
- private readonly tempToPersistedId = new Map<string, string>()
39
+ /**
40
+ * Entities whose in-flight persist is pessimistic, keyed by "entityType:id".
41
+ * A subset of {@link persistingEntities}. While present, the entity is
42
+ * presented at its server baseline (see SnapshotStore.getPresentationSnapshot)
43
+ * even though its canonical data stays dirty.
44
+ */
45
+ private readonly pessimisticInFlight = new Set<string>()
46
+
47
+ /**
48
+ * Monotonic counter bumped when reachability-relevant metadata changes —
49
+ * `existsOnServer` and `isPersisting` (which seed the reachability roots) plus
50
+ * entity removal/rekey. Load state and deletion scheduling do NOT bump it.
51
+ * Used by {@link ReachabilityAnalyzer} to memoize its walk.
52
+ */
53
+ private mutationVersion = 0
54
+
55
+ /**
56
+ * Monotonic counter bumped on EDITABLE-layer meta writes — deletion scheduling,
57
+ * the one undoable gesture this store owns. Server-baseline (`setExistsOnServer`),
58
+ * load/persist state, removal, rekey, import and clear deliberately do NOT bump
59
+ * it. Read by the undo write-guard (see UndoJournal).
60
+ */
61
+ private editableWriteVersion = 0
62
+
63
+ getMutationVersion(): number {
64
+ return this.mutationVersion
65
+ }
66
+
67
+ getEditableWriteVersion(): number {
68
+ return this.editableWriteVersion
69
+ }
41
70
 
42
71
  // ==================== Load State ====================
43
72
 
@@ -60,8 +89,15 @@ export class EntityMetaStore {
60
89
  }
61
90
 
62
91
  setExistsOnServer(key: string, existsOnServer: boolean): void {
63
- const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false }
64
- this.entityMetas.set(key, { ...existing, existsOnServer })
92
+ const existing = this.entityMetas.get(key)
93
+ if (existing && existing.existsOnServer === existsOnServer) {
94
+ return
95
+ }
96
+ this.entityMetas.set(key, {
97
+ existsOnServer,
98
+ isScheduledForDeletion: existing?.isScheduledForDeletion ?? false,
99
+ })
100
+ this.mutationVersion++
65
101
  }
66
102
 
67
103
  existsOnServer(key: string): boolean {
@@ -71,11 +107,13 @@ export class EntityMetaStore {
71
107
  scheduleForDeletion(key: string): void {
72
108
  const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false }
73
109
  this.entityMetas.set(key, { ...existing, isScheduledForDeletion: true })
110
+ this.editableWriteVersion++
74
111
  }
75
112
 
76
113
  unscheduleForDeletion(key: string): void {
77
114
  const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false }
78
115
  this.entityMetas.set(key, { ...existing, isScheduledForDeletion: false })
116
+ this.editableWriteVersion++
79
117
  }
80
118
 
81
119
  isScheduledForDeletion(key: string): boolean {
@@ -88,70 +126,72 @@ export class EntityMetaStore {
88
126
  return this.persistingEntities.has(key)
89
127
  }
90
128
 
91
- setPersisting(key: string, isPersisting: boolean): void {
129
+ setPersisting(key: string, isPersisting: boolean, pessimistic: boolean = false): void {
92
130
  if (isPersisting) {
93
- this.persistingEntities.add(key)
131
+ if (!this.persistingEntities.has(key)) {
132
+ this.persistingEntities.add(key)
133
+ this.mutationVersion++
134
+ }
135
+ // The pessimistic flag drives presentation only, not reachability, so it
136
+ // deliberately does not bump mutationVersion.
137
+ if (pessimistic) {
138
+ this.pessimisticInFlight.add(key)
139
+ } else {
140
+ this.pessimisticInFlight.delete(key)
141
+ }
94
142
  } else {
95
- this.persistingEntities.delete(key)
143
+ if (this.persistingEntities.delete(key)) {
144
+ this.mutationVersion++
145
+ }
146
+ this.pessimisticInFlight.delete(key)
96
147
  }
97
148
  }
98
149
 
99
- // ==================== Temp ID Mapping ====================
100
-
101
- mapTempIdToPersistedId(key: string, persistedId: string): void {
102
- this.tempToPersistedId.set(key, persistedId)
103
- this.setExistsOnServer(key, true)
104
- }
105
-
106
- getPersistedId(key: string, id: string): string | null {
107
- if (isPlaceholderId(id)) return null
108
- if (isPersistedId(id)) return id
109
- return this.tempToPersistedId.get(key) ?? null
110
- }
111
-
112
- isNewEntity(key: string, id: string): boolean {
113
- if (isPlaceholderId(id)) return true
114
- if (isPersistedId(id)) return false
115
- return !this.tempToPersistedId.has(key)
150
+ isPessimisticInFlight(key: string): boolean {
151
+ return this.pessimisticInFlight.has(key)
116
152
  }
117
153
 
118
154
  /**
119
- * Removes all metadata for an entity (load state, meta, persisting, temp mapping).
155
+ * Removes all metadata for an entity (load state, meta, persisting).
120
156
  */
121
157
  remove(key: string): void {
122
158
  this.loadStates.delete(key)
123
159
  this.entityMetas.delete(key)
124
160
  this.persistingEntities.delete(key)
125
- this.tempToPersistedId.delete(key)
161
+ this.pessimisticInFlight.delete(key)
162
+ this.mutationVersion++
126
163
  }
127
164
 
128
165
  /**
129
- * Moves all metadata from oldKey to newKey.
166
+ * Moves all metadata from the temp key to the persisted key. The entity has
167
+ * just been confirmed by the server, so it is also marked as existing there
168
+ * (this replaces the former separate mapTempIdToPersistedId step).
130
169
  */
131
- rekey(oldKey: string, newKey: string): void {
132
- const meta = this.entityMetas.get(oldKey)
170
+ rekey(ctx: RekeyContext): void {
171
+ const meta = this.entityMetas.get(ctx.oldKey)
133
172
  if (meta) {
134
- this.entityMetas.delete(oldKey)
135
- this.entityMetas.set(newKey, meta)
173
+ this.entityMetas.delete(ctx.oldKey)
174
+ this.entityMetas.set(ctx.newKey, meta)
136
175
  }
137
176
 
138
- const loadState = this.loadStates.get(oldKey)
177
+ const loadState = this.loadStates.get(ctx.oldKey)
139
178
  if (loadState) {
140
- this.loadStates.delete(oldKey)
141
- this.loadStates.set(newKey, loadState)
179
+ this.loadStates.delete(ctx.oldKey)
180
+ this.loadStates.set(ctx.newKey, loadState)
142
181
  }
143
182
 
144
- if (this.persistingEntities.has(oldKey)) {
145
- this.persistingEntities.delete(oldKey)
146
- this.persistingEntities.add(newKey)
183
+ if (this.persistingEntities.has(ctx.oldKey)) {
184
+ this.persistingEntities.delete(ctx.oldKey)
185
+ this.persistingEntities.add(ctx.newKey)
147
186
  }
148
187
 
149
- // Move temp ID mapping
150
- const persistedId = this.tempToPersistedId.get(oldKey)
151
- if (persistedId !== undefined) {
152
- this.tempToPersistedId.delete(oldKey)
153
- this.tempToPersistedId.set(newKey, persistedId)
188
+ if (this.pessimisticInFlight.has(ctx.oldKey)) {
189
+ this.pessimisticInFlight.delete(ctx.oldKey)
190
+ this.pessimisticInFlight.add(ctx.newKey)
154
191
  }
192
+
193
+ this.mutationVersion++
194
+ this.setExistsOnServer(ctx.newKey, true)
155
195
  }
156
196
 
157
197
  // ==================== Bulk Operations ====================
@@ -168,15 +208,19 @@ export class EntityMetaStore {
168
208
  }
169
209
 
170
210
  importMetas(metas: Map<string, EntityMeta>): void {
211
+ let imported = false
171
212
  for (const [key, meta] of metas) {
172
213
  this.entityMetas.set(key, { ...meta })
214
+ imported = true
173
215
  }
216
+ if (imported) this.mutationVersion++
174
217
  }
175
218
 
176
219
  clear(): void {
177
220
  this.loadStates.clear()
178
221
  this.entityMetas.clear()
179
222
  this.persistingEntities.clear()
180
- this.tempToPersistedId.clear()
223
+ this.pessimisticInFlight.clear()
224
+ this.mutationVersion++
181
225
  }
182
226
  }
@@ -2,6 +2,7 @@ import {
2
2
  createEntitySnapshot,
3
3
  type EntitySnapshot,
4
4
  } from './snapshots.js'
5
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
5
6
 
6
7
  /**
7
8
  * Manages entity snapshots — core CRUD for immutable entity data.
@@ -12,7 +13,7 @@ import {
12
13
  * Follows the same sub-store pattern as ErrorStore, RelationStore, etc.
13
14
  * SnapshotStore delegates entity snapshot operations here.
14
15
  */
15
- export class EntitySnapshotStore {
16
+ export class EntitySnapshotStore implements Rekeyable {
16
17
  private readonly snapshots = new Map<string, EntitySnapshot>()
17
18
 
18
19
  /**
@@ -23,6 +24,67 @@ export class EntitySnapshotStore {
23
24
  */
24
25
  private readonly idIndex = new Map<string, string>()
25
26
 
27
+ /**
28
+ * Monotonic counter bumped only when the set of keys or the id→key index
29
+ * changes (new key, removal, rekey, bulk import, clear). Pure value edits
30
+ * (setFieldValue/updateFields/commit/reset/...) do NOT bump it — they cannot
31
+ * change reachability — so the per-keystroke edit path keeps the
32
+ * {@link ReachabilityAnalyzer} cache warm.
33
+ */
34
+ private mutationVersion = 0
35
+
36
+ /**
37
+ * Monotonic counter bumped on every EDITABLE-layer snapshot write (local
38
+ * setData / updateFields / setFieldValue / reset) — the writes an undo gesture
39
+ * must first record. Server-baseline writes (server setData, refreshServerData,
40
+ * commit, commitFields) and non-write bookkeeping (remove, bumpVersion, import,
41
+ * rekey, clear) deliberately do NOT bump it. Read by the undo write-guard to
42
+ * detect a mutating path that wrote without recording. See UndoJournal.
43
+ */
44
+ private editableWriteVersion = 0
45
+
46
+ /**
47
+ * Monotonic counter bumped on every write that can change a snapshot's `data`
48
+ * or `serverData` — i.e. every write that can change whether the entity is
49
+ * dirty. Unlike the two counters above it spans BOTH layers: `mutationVersion`
50
+ * ignores value edits and `editableWriteVersion` ignores server-baseline writes
51
+ * (refreshServerData/commit/commitFields), so neither alone is a sound dirty
52
+ * key. {@link bumpVersion} is excluded — it rewrites neither side. Read by
53
+ * SnapshotStore.getDirtyVersion() to memoize the full-store dirty scan.
54
+ */
55
+ private dataWriteVersion = 0
56
+
57
+ getMutationVersion(): number {
58
+ return this.mutationVersion
59
+ }
60
+
61
+ getEditableWriteVersion(): number {
62
+ return this.editableWriteVersion
63
+ }
64
+
65
+ getDataWriteVersion(): number {
66
+ return this.dataWriteVersion
67
+ }
68
+
69
+ /**
70
+ * The single write chokepoint. Every path that installs a snapshot goes through
71
+ * here, so {@link dataWriteVersion} cannot be forgotten by a new mutating method
72
+ * or a new early-return branch — a stale dirty memo is silent (dead Save button),
73
+ * so the bump must not be hand-maintained per call site. Mirrors
74
+ * HasOneStore.writeRelation / HasManyStore.writeHasMany.
75
+ */
76
+ private writeSnapshot(key: string, snapshot: EntitySnapshot): void {
77
+ this.snapshots.set(key, snapshot)
78
+ this.dataWriteVersion++
79
+ }
80
+
81
+ /** The single delete chokepoint — counterpart of {@link writeSnapshot}. */
82
+ private deleteSnapshot(key: string): void {
83
+ if (this.snapshots.delete(key)) {
84
+ this.dataWriteVersion++
85
+ }
86
+ }
87
+
26
88
  get(key: string): EntitySnapshot | undefined {
27
89
  return this.snapshots.get(key)
28
90
  }
@@ -60,8 +122,13 @@ export class EntitySnapshotStore {
60
122
  (existing?.version ?? 0) + 1,
61
123
  )
62
124
 
63
- this.snapshots.set(key, newSnapshot)
125
+ this.writeSnapshot(key, newSnapshot)
126
+ // The id index resolves relation edges for reachability, which memoizes on
127
+ // mutationVersion — so any change to the index must move it, not just a new key.
128
+ const indexChanged = this.idIndex.get(id) !== key
64
129
  this.idIndex.set(id, key)
130
+ if (!existing || indexChanged) this.mutationVersion++
131
+ if (!isServerData) this.editableWriteVersion++
65
132
  return newSnapshot
66
133
  }
67
134
 
@@ -111,7 +178,7 @@ export class EntitySnapshotStore {
111
178
  existing.version + 1,
112
179
  )
113
180
 
114
- this.snapshots.set(key, newSnapshot)
181
+ this.writeSnapshot(key, newSnapshot)
115
182
  return newSnapshot
116
183
  }
117
184
 
@@ -135,7 +202,8 @@ export class EntitySnapshotStore {
135
202
  existing.version + 1,
136
203
  )
137
204
 
138
- this.snapshots.set(key, newSnapshot)
205
+ this.writeSnapshot(key, newSnapshot)
206
+ this.editableWriteVersion++
139
207
  return newSnapshot
140
208
  }
141
209
 
@@ -156,7 +224,8 @@ export class EntitySnapshotStore {
156
224
  existing.version + 1,
157
225
  )
158
226
 
159
- this.snapshots.set(key, newSnapshot)
227
+ this.writeSnapshot(key, newSnapshot)
228
+ this.editableWriteVersion++
160
229
  return true
161
230
  }
162
231
 
@@ -175,7 +244,7 @@ export class EntitySnapshotStore {
175
244
  existing.version + 1,
176
245
  )
177
246
 
178
- this.snapshots.set(key, newSnapshot)
247
+ this.writeSnapshot(key, newSnapshot)
179
248
  }
180
249
 
181
250
  /**
@@ -193,7 +262,8 @@ export class EntitySnapshotStore {
193
262
  existing.version + 1,
194
263
  )
195
264
 
196
- this.snapshots.set(key, newSnapshot)
265
+ this.writeSnapshot(key, newSnapshot)
266
+ this.editableWriteVersion++
197
267
  }
198
268
 
199
269
  /**
@@ -208,13 +278,19 @@ export class EntitySnapshotStore {
208
278
  // that survivor as id-unresolvable.
209
279
  if (existing && this.idIndex.get(existing.id) === key) {
210
280
  this.idIndex.delete(existing.id)
281
+ this.mutationVersion++
211
282
  }
212
- this.snapshots.delete(key)
283
+ this.deleteSnapshot(key)
213
284
  }
214
285
 
215
286
  /**
216
287
  * Bumps the version of an entity snapshot without changing data.
217
288
  * Used by SubscriptionManager when child entities change.
289
+ *
290
+ * The ONE deliberate bypass of {@link writeSnapshot}: it reinstalls the very same
291
+ * `data` and `serverData` references under a new version, so it cannot change
292
+ * dirtiness and must not invalidate the dirty memo — it runs once per ancestor on
293
+ * every notification, which would keep the cache permanently cold.
218
294
  */
219
295
  bumpVersion(key: string): void {
220
296
  const existing = this.snapshots.get(key)
@@ -256,7 +332,7 @@ export class EntitySnapshotStore {
256
332
  existing.version + 1,
257
333
  )
258
334
 
259
- this.snapshots.set(key, newSnapshot)
335
+ this.writeSnapshot(key, newSnapshot)
260
336
  }
261
337
 
262
338
  /**
@@ -280,36 +356,39 @@ export class EntitySnapshotStore {
280
356
  importSnapshots(snapshots: Map<string, EntitySnapshot>): Set<string> {
281
357
  const keys = new Set<string>()
282
358
  for (const [key, snapshot] of snapshots) {
283
- this.snapshots.set(key, snapshot)
359
+ this.writeSnapshot(key, snapshot)
284
360
  this.idIndex.set(snapshot.id, key)
285
361
  keys.add(key)
286
362
  }
363
+ if (keys.size > 0) this.mutationVersion++
287
364
  return keys
288
365
  }
289
366
 
290
367
  /**
291
- * Moves a snapshot from oldKey to newKey, updating the id in the snapshot data.
368
+ * Moves a snapshot from the temp key to the persisted key, updating the id in
369
+ * the snapshot data.
292
370
  */
293
- rekey(oldKey: string, newKey: string, newId: string): void {
294
- const snapshot = this.snapshots.get(oldKey)
371
+ rekey(ctx: RekeyContext): void {
372
+ const snapshot = this.snapshots.get(ctx.oldKey)
295
373
  if (!snapshot) return
296
374
 
297
375
  // Update id field in data and serverData
298
- const data = { ...snapshot.data as Record<string, unknown>, id: newId }
299
- const serverData = { ...snapshot.serverData as Record<string, unknown>, id: newId }
376
+ const data = { ...snapshot.data as Record<string, unknown>, id: ctx.newId }
377
+ const serverData = { ...snapshot.serverData as Record<string, unknown>, id: ctx.newId }
300
378
 
301
379
  const newSnapshot = createEntitySnapshot(
302
- newId,
380
+ ctx.newId,
303
381
  snapshot.entityType,
304
382
  data,
305
383
  serverData,
306
384
  snapshot.version + 1,
307
385
  )
308
386
 
309
- this.snapshots.delete(oldKey)
310
- this.snapshots.set(newKey, newSnapshot)
387
+ this.deleteSnapshot(ctx.oldKey)
388
+ this.writeSnapshot(ctx.newKey, newSnapshot)
311
389
  this.idIndex.delete(snapshot.id)
312
- this.idIndex.set(newId, newKey)
390
+ this.idIndex.set(ctx.newId, ctx.newKey)
391
+ this.mutationVersion++
313
392
  }
314
393
 
315
394
  keys(): IterableIterator<string> {
@@ -327,8 +406,12 @@ export class EntitySnapshotStore {
327
406
  }
328
407
 
329
408
  clear(): void {
409
+ // Bulk drop: bumps both counters inline rather than per key, the same shape as
410
+ // HasOneStore.clear / HasManyStore.clear.
330
411
  this.snapshots.clear()
331
412
  this.idIndex.clear()
413
+ this.mutationVersion++
414
+ this.dataWriteVersion++
332
415
  }
333
416
  }
334
417