@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
@@ -194,7 +194,9 @@ export class MockAdapter implements BackendAdapter {
194
194
  this.applyChanges(entity, changes)
195
195
  this.log('persist result', entity)
196
196
 
197
- return { ok: true }
197
+ // Echo the mutated node — PersistResult.data is what the persister reads
198
+ // nested create IDs out of.
199
+ return { ok: true, data: entity }
198
200
  }
199
201
 
200
202
  /**
@@ -335,11 +337,15 @@ export class MockAdapter implements BackendAdapter {
335
337
  }
336
338
 
337
339
  /**
338
- * Creates a related entity with a generated ID.
340
+ * Creates a related entity with a generated ID, materializing its own nested
341
+ * operations. The persister reconciles a create by reading server IDs out of
342
+ * the echoed node, so a nested create left as a raw `{ create: … }` operation
343
+ * reads as an unresolved ID and fails the whole persist.
339
344
  */
340
345
  private createRelatedEntity(data: Record<string, unknown>): Record<string, unknown> {
341
- const id = this.generateId()
342
- return { id, ...data }
346
+ const entity: Record<string, unknown> = { id: this.generateId() }
347
+ this.applyChanges(entity, data)
348
+ return entity
343
349
  }
344
350
 
345
351
  /**
@@ -368,7 +374,11 @@ export class MockAdapter implements BackendAdapter {
368
374
 
369
375
  // Generate ID if not provided
370
376
  const id = (data['id'] as string) ?? this.generateId()
371
- const entity = { ...data, id }
377
+ // Materialize relation operations so the echoed node carries the server IDs
378
+ // of every nested create — the persister reconciles against exactly that.
379
+ const entity: Record<string, unknown> = { id }
380
+ this.applyChanges(entity, data)
381
+ entity['id'] = id
372
382
 
373
383
  this.store[entityType]![id] = entity
374
384
  this.log('create result', entity)
@@ -27,6 +27,7 @@ export class ActionDispatcher {
27
27
  eventEmitter?: EventEmitter,
28
28
  ) {
29
29
  this.eventEmitter = eventEmitter ?? new EventEmitter()
30
+ this.store.attachRekeyParticipant(this.eventEmitter)
30
31
  }
31
32
 
32
33
  /**
@@ -42,39 +43,46 @@ export class ActionDispatcher {
42
43
  * Async interceptors are skipped with a warning — use dispatchAsync() for those.
43
44
  */
44
45
  dispatch(action: Action): void {
45
- // Run through middlewares first
46
- for (const middleware of this.middlewares) {
47
- const result = middleware(action, this.store)
48
- if (result === false) {
49
- // Middleware can cancel the action
50
- return
46
+ // One dispatch = one undoable gesture. The store records every primary write
47
+ // made between begin/commit into a single journal entry (no-op without undo).
48
+ this.store.beginTransaction()
49
+ try {
50
+ // Run through middlewares first
51
+ for (const middleware of this.middlewares) {
52
+ const result = middleware(action, this.store)
53
+ if (result === false) {
54
+ // Middleware can cancel the action
55
+ return
56
+ }
51
57
  }
52
- }
53
58
 
54
- // Create and run before event through interceptors (synchronously)
55
- const beforeEvent = createBeforeEvent(action, this.store)
56
- if (beforeEvent) {
57
- const result = this.eventEmitter.runInterceptorsSync(beforeEvent)
58
- if (result === null) {
59
- // Interceptor cancelled the action
60
- return
61
- }
62
- // If event was modified, update action accordingly
63
- if (result !== beforeEvent) {
64
- action = this.updateActionFromEvent(action, result)
59
+ // Create and run before event through interceptors (synchronously)
60
+ const beforeEvent = createBeforeEvent(action, this.store)
61
+ if (beforeEvent) {
62
+ const result = this.eventEmitter.runInterceptorsSync(beforeEvent)
63
+ if (result === null) {
64
+ // Interceptor cancelled the action
65
+ return
66
+ }
67
+ // If event was modified, update action accordingly
68
+ if (result !== beforeEvent) {
69
+ action = this.updateActionFromEvent(action, result)
70
+ }
65
71
  }
66
- }
67
72
 
68
- // Capture state before execution (for after events)
69
- const stateBefore = captureStateBeforeAction(action, this.store)
73
+ // Capture state before execution (for after events)
74
+ const stateBefore = captureStateBeforeAction(action, this.store)
70
75
 
71
- // Execute the action
72
- this.execute(action)
76
+ // Execute the action
77
+ this.execute(action)
73
78
 
74
- // Emit after event
75
- const afterEvent = createAfterEvent(action, stateBefore, this.store)
76
- if (afterEvent) {
77
- this.eventEmitter.emit(afterEvent)
79
+ // Emit after event
80
+ const afterEvent = createAfterEvent(action, stateBefore, this.store)
81
+ if (afterEvent) {
82
+ this.eventEmitter.emit(afterEvent)
83
+ }
84
+ } finally {
85
+ this.store.commitTransaction()
78
86
  }
79
87
  }
80
88
 
@@ -108,16 +116,21 @@ export class ActionDispatcher {
108
116
  // Capture state before execution (for after events)
109
117
  const stateBefore = captureStateBeforeAction(action, this.store)
110
118
 
111
- // Execute the action
112
- this.execute(action)
119
+ this.store.beginTransaction()
120
+ try {
121
+ // Execute the action
122
+ this.execute(action)
113
123
 
114
- // Emit after event
115
- const afterEvent = createAfterEvent(action, stateBefore, this.store)
116
- if (afterEvent) {
117
- this.eventEmitter.emit(afterEvent)
118
- }
124
+ // Emit after event
125
+ const afterEvent = createAfterEvent(action, stateBefore, this.store)
126
+ if (afterEvent) {
127
+ this.eventEmitter.emit(afterEvent)
128
+ }
119
129
 
120
- return true
130
+ return true
131
+ } finally {
132
+ this.store.commitTransaction()
133
+ }
121
134
  }
122
135
 
123
136
  /**
@@ -174,6 +187,9 @@ export class ActionDispatcher {
174
187
  const index = this.middlewares.indexOf(middleware)
175
188
  if (index !== -1) {
176
189
  this.middlewares.splice(index, 1)
190
+ if (!this.middlewares.includes(middleware)) {
191
+ middleware.dispose?.()
192
+ }
177
193
  }
178
194
  }
179
195
 
@@ -373,6 +389,7 @@ export class ActionDispatcher {
373
389
  action.entityType,
374
390
  action.entityId,
375
391
  action.isPersisting,
392
+ action.pessimistic ?? false,
376
393
  )
377
394
  break
378
395
 
@@ -455,7 +472,10 @@ export class ActionDispatcher {
455
472
  * Can inspect/modify actions before execution.
456
473
  * Return false to cancel the action.
457
474
  */
458
- export type ActionMiddleware = (action: Action, store: SnapshotStore) => boolean | void
475
+ export interface ActionMiddleware {
476
+ (action: Action, store: SnapshotStore): boolean | void
477
+ dispose?: () => void
478
+ }
459
479
 
460
480
  /**
461
481
  * Creates a logging middleware for debugging.
@@ -202,6 +202,12 @@ export interface SetPersistingAction {
202
202
  readonly entityType: string
203
203
  readonly entityId: string
204
204
  readonly isPersisting: boolean
205
+ /**
206
+ * Whether this persist is pessimistic. While a pessimistic persist is
207
+ * in-flight, handles present the server baseline instead of the (still dirty)
208
+ * local data. Ignored when isPersisting is false.
209
+ */
210
+ readonly pessimistic?: boolean
205
211
  }
206
212
 
207
213
  // ==================== Error Actions ====================
@@ -428,8 +434,9 @@ export function setPersisting(
428
434
  entityType: string,
429
435
  entityId: string,
430
436
  isPersisting: boolean,
437
+ pessimistic: boolean = false,
431
438
  ): SetPersistingAction {
432
- return { type: 'SET_PERSISTING', entityType, entityId, isPersisting }
439
+ return { type: 'SET_PERSISTING', entityType, entityId, isPersisting, pessimistic }
433
440
  }
434
441
 
435
442
  /**
@@ -15,6 +15,9 @@ import type {
15
15
  InterceptorResult,
16
16
  Unsubscribe,
17
17
  } from './types.js'
18
+ import type { RekeyContext, Rekeyable } from '../store/RekeyOrchestrator.js'
19
+
20
+ const SCOPE_SEPARATOR = '\u0000'
18
21
 
19
22
  interface ScopeKey {
20
23
  entityType: string
@@ -31,7 +34,7 @@ interface ScopeKey {
31
34
  * - Field-scoped subscriptions (events for a specific field/relation)
32
35
  * - Interceptors for before-events (can cancel or modify)
33
36
  */
34
- export class EventEmitter {
37
+ export class EventEmitter implements Rekeyable {
35
38
  // Global listeners by event type
36
39
  private readonly globalListeners = new Map<string, Set<EventListener<AfterEvent>>>()
37
40
 
@@ -44,6 +47,9 @@ export class EventEmitter {
44
47
  // Scoped interceptors
45
48
  private readonly scopedInterceptors = new Map<string, Set<Interceptor<BeforeEvent>>>()
46
49
 
50
+ /** Old scope keys retained so pre-rekey unsubscribe closures still resolve. */
51
+ private readonly scopeKeyRedirects = new Map<string, string>()
52
+
47
53
  // ============================================================================
48
54
  // Listener Subscription Methods
49
55
  // ============================================================================
@@ -69,10 +75,11 @@ export class EventEmitter {
69
75
  entityId: string,
70
76
  listener: EventListener<EventTypeMap[T] & AfterEvent>,
71
77
  ): Unsubscribe {
72
- const key = this.buildScopeKey(eventType, { entityType, entityId })
78
+ const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId }))
73
79
  const listeners = this.getOrCreateSet(this.scopedListeners, key)
74
- listeners.add(listener as EventListener<AfterEvent>)
75
- return () => listeners.delete(listener as EventListener<AfterEvent>)
80
+ const typedListener = listener as EventListener<AfterEvent>
81
+ listeners.add(typedListener)
82
+ return () => this.removeScoped(this.scopedListeners, key, typedListener)
76
83
  }
77
84
 
78
85
  /**
@@ -85,10 +92,11 @@ export class EventEmitter {
85
92
  fieldName: string,
86
93
  listener: EventListener<EventTypeMap[T] & AfterEvent>,
87
94
  ): Unsubscribe {
88
- const key = this.buildScopeKey(eventType, { entityType, entityId, fieldName })
95
+ const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId, fieldName }))
89
96
  const listeners = this.getOrCreateSet(this.scopedListeners, key)
90
- listeners.add(listener as EventListener<AfterEvent>)
91
- return () => listeners.delete(listener as EventListener<AfterEvent>)
97
+ const typedListener = listener as EventListener<AfterEvent>
98
+ listeners.add(typedListener)
99
+ return () => this.removeScoped(this.scopedListeners, key, typedListener)
92
100
  }
93
101
 
94
102
  // ============================================================================
@@ -117,10 +125,11 @@ export class EventEmitter {
117
125
  entityId: string,
118
126
  interceptor: Interceptor<EventTypeMap[T] & BeforeEvent>,
119
127
  ): Unsubscribe {
120
- const key = this.buildScopeKey(eventType, { entityType, entityId })
128
+ const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId }))
121
129
  const interceptors = this.getOrCreateSet(this.scopedInterceptors, key)
122
- interceptors.add(interceptor as Interceptor<BeforeEvent>)
123
- return () => interceptors.delete(interceptor as Interceptor<BeforeEvent>)
130
+ const typedInterceptor = interceptor as Interceptor<BeforeEvent>
131
+ interceptors.add(typedInterceptor)
132
+ return () => this.removeScoped(this.scopedInterceptors, key, typedInterceptor)
124
133
  }
125
134
 
126
135
  /**
@@ -133,10 +142,23 @@ export class EventEmitter {
133
142
  fieldName: string,
134
143
  interceptor: Interceptor<EventTypeMap[T] & BeforeEvent>,
135
144
  ): Unsubscribe {
136
- const key = this.buildScopeKey(eventType, { entityType, entityId, fieldName })
145
+ const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId, fieldName }))
137
146
  const interceptors = this.getOrCreateSet(this.scopedInterceptors, key)
138
- interceptors.add(interceptor as Interceptor<BeforeEvent>)
139
- return () => interceptors.delete(interceptor as Interceptor<BeforeEvent>)
147
+ const typedInterceptor = interceptor as Interceptor<BeforeEvent>
148
+ interceptors.add(typedInterceptor)
149
+ return () => this.removeScoped(this.scopedInterceptors, key, typedInterceptor)
150
+ }
151
+
152
+ /**
153
+ * Whether any interceptor could receive this event for the given entity.
154
+ * Lets a caller skip the (async) interceptor pipeline when nothing is listening.
155
+ */
156
+ hasInterceptors(eventType: string, entityType: string, entityId: string): boolean {
157
+ const global = this.globalInterceptors.get(eventType)
158
+ if (global !== undefined && global.size > 0) return true
159
+
160
+ const scoped = this.scopedInterceptors.get(this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId })))
161
+ return scoped !== undefined && scoped.size > 0
140
162
  }
141
163
 
142
164
  // ============================================================================
@@ -162,7 +184,7 @@ export class EventEmitter {
162
184
  fieldName,
163
185
  })
164
186
  const result = await this.runInterceptorSet(
165
- this.scopedInterceptors.get(fieldKey),
187
+ this.scopedInterceptors.get(this.resolveScopeKey(fieldKey)),
166
188
  currentEvent,
167
189
  )
168
190
  if (result === null) return null
@@ -176,7 +198,7 @@ export class EventEmitter {
176
198
  entityId: event.entityId,
177
199
  })
178
200
  const entityResult = await this.runInterceptorSet(
179
- this.scopedInterceptors.get(entityKey),
201
+ this.scopedInterceptors.get(this.resolveScopeKey(entityKey)),
180
202
  currentEvent,
181
203
  )
182
204
  if (entityResult === null) return null
@@ -209,7 +231,7 @@ export class EventEmitter {
209
231
  entityId: event.entityId,
210
232
  fieldName,
211
233
  })
212
- this.notifyListeners(this.scopedListeners.get(fieldKey), event)
234
+ this.notifyListeners(this.scopedListeners.get(this.resolveScopeKey(fieldKey)), event)
213
235
  }
214
236
 
215
237
  // Entity-level listeners
@@ -217,7 +239,7 @@ export class EventEmitter {
217
239
  entityType: event.entityType,
218
240
  entityId: event.entityId,
219
241
  })
220
- this.notifyListeners(this.scopedListeners.get(entityKey), event)
242
+ this.notifyListeners(this.scopedListeners.get(this.resolveScopeKey(entityKey)), event)
221
243
 
222
244
  // Global listeners
223
245
  this.notifyListeners(this.globalListeners.get(event.type), event)
@@ -231,11 +253,42 @@ export class EventEmitter {
231
253
  * Builds a scope key for listener/interceptor lookup.
232
254
  */
233
255
  private buildScopeKey(eventType: string, scope: ScopeKey): string {
234
- const parts = [eventType, scope.entityType, scope.entityId]
235
- if (scope.fieldName) {
236
- parts.push(scope.fieldName)
256
+ const parts = [eventType, scope.entityType, scope.entityId, scope.fieldName ?? '']
257
+ return parts.join(SCOPE_SEPARATOR)
258
+ }
259
+
260
+ private resolveScopeKey(key: string): string {
261
+ let resolved = key
262
+ let next = this.scopeKeyRedirects.get(resolved)
263
+ while (next && next !== resolved) {
264
+ resolved = next
265
+ next = this.scopeKeyRedirects.get(resolved)
266
+ }
267
+ return resolved
268
+ }
269
+
270
+ private removeScoped<T>(map: Map<string, Set<T>>, key: string, value: T): void {
271
+ map.get(this.resolveScopeKey(key))?.delete(value)
272
+ }
273
+
274
+ rekey(ctx: RekeyContext): void {
275
+ this.rekeyScopedMap(this.scopedListeners, ctx)
276
+ this.rekeyScopedMap(this.scopedInterceptors, ctx)
277
+ }
278
+
279
+ private rekeyScopedMap<T>(map: Map<string, Set<T>>, ctx: RekeyContext): void {
280
+ const entityType = ctx.oldKey.slice(0, ctx.oldKey.length - ctx.oldId.length - 1)
281
+ const oldIdentity = `${SCOPE_SEPARATOR}${entityType}${SCOPE_SEPARATOR}${ctx.oldId}${SCOPE_SEPARATOR}`
282
+ const newIdentity = `${SCOPE_SEPARATOR}${entityType}${SCOPE_SEPARATOR}${ctx.newId}${SCOPE_SEPARATOR}`
283
+
284
+ for (const [oldScopeKey, values] of [...map]) {
285
+ if (!oldScopeKey.includes(oldIdentity)) continue
286
+ const newScopeKey = oldScopeKey.replace(oldIdentity, newIdentity)
287
+ const destination = this.getOrCreateSet(map, newScopeKey)
288
+ for (const value of values) destination.add(value)
289
+ map.delete(oldScopeKey)
290
+ this.scopeKeyRedirects.set(oldScopeKey, newScopeKey)
237
291
  }
238
- return parts.join(':')
239
292
  }
240
293
 
241
294
  /**
@@ -297,7 +350,7 @@ export class EventEmitter {
297
350
  fieldName,
298
351
  })
299
352
  const result = this.runInterceptorSetSync(
300
- this.scopedInterceptors.get(fieldKey),
353
+ this.scopedInterceptors.get(this.resolveScopeKey(fieldKey)),
301
354
  currentEvent,
302
355
  )
303
356
  if (result === null) return null
@@ -310,7 +363,7 @@ export class EventEmitter {
310
363
  entityId: event.entityId,
311
364
  })
312
365
  const entityResult = this.runInterceptorSetSync(
313
- this.scopedInterceptors.get(entityKey),
366
+ this.scopedInterceptors.get(this.resolveScopeKey(entityKey)),
314
367
  currentEvent,
315
368
  )
316
369
  if (entityResult === null) return null
@@ -424,6 +477,7 @@ export class EventEmitter {
424
477
  this.scopedListeners.clear()
425
478
  this.globalInterceptors.clear()
426
479
  this.scopedInterceptors.clear()
480
+ this.scopeKeyRedirects.clear()
427
481
  }
428
482
 
429
483
  /**
@@ -437,7 +491,7 @@ export class EventEmitter {
437
491
 
438
492
  // Count scoped listeners
439
493
  for (const [key, listeners] of this.scopedListeners) {
440
- if (key.startsWith(eventType + ':')) {
494
+ if (key.startsWith(eventType + SCOPE_SEPARATOR)) {
441
495
  count += listeners.size
442
496
  }
443
497
  }
@@ -39,13 +39,18 @@ export abstract class BaseHandle {
39
39
  export abstract class EntityRelatedHandle extends BaseHandle {
40
40
  constructor(
41
41
  protected readonly entityType: string,
42
- protected readonly entityId: string,
42
+ private readonly sourceEntityId: string,
43
43
  store: SnapshotStore,
44
44
  dispatcher: ActionDispatcher,
45
45
  ) {
46
46
  super(store, dispatcher)
47
47
  }
48
48
 
49
+ /** Current canonical id; the handle keeps no mutable rekey state. */
50
+ protected get entityId(): string {
51
+ return this.store.resolveEntityId(this.entityType, this.sourceEntityId)
52
+ }
53
+
49
54
  /**
50
55
  * Subscribe to entity changes.
51
56
  */
@@ -62,13 +67,25 @@ export abstract class EntityRelatedHandle extends BaseHandle {
62
67
  }
63
68
 
64
69
  /**
65
- * Get entity data.
70
+ * Get the CANONICAL entity data — the live, possibly-dirty values. Use this
71
+ * for dirty tracking and any logic that must reflect the user's edits, even
72
+ * while a pessimistic persist is in-flight. For values to DISPLAY, use
73
+ * {@link getPresentationData}.
66
74
  */
67
75
  protected getEntityData(): Record<string, unknown> | undefined {
68
76
  const snapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
69
77
  return snapshot?.data as Record<string, unknown> | undefined
70
78
  }
71
79
 
80
+ /**
81
+ * Get the entity data a consumer should DISPLAY. Equals {@link getEntityData}
82
+ * except while the entity is pessimistically in-flight, when it returns the
83
+ * server baseline (the canonical data stays dirty underneath).
84
+ */
85
+ protected getPresentationData(): Record<string, unknown> | undefined {
86
+ return this.store.getPresentationSnapshot<Record<string, unknown>>(this.entityType, this.entityId)?.data
87
+ }
88
+
72
89
  /**
73
90
  * Get entity server data.
74
91
  */
@@ -80,6 +80,9 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
80
80
  private readonly schema: SchemaRegistry,
81
81
  brands?: Set<symbol>,
82
82
  private readonly selection?: SelectionMeta,
83
+ // Segments from the query root down to this entity. Empty at a root and at
84
+ // every has-many item, so item chains restart their own `fullPath`.
85
+ private readonly entityPath: readonly string[] = [],
83
86
  ) {
84
87
  super(entityType, id, store, dispatcher)
85
88
  this.__brands = brands
@@ -93,8 +96,9 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
93
96
  schema: SchemaRegistry,
94
97
  brands?: Set<symbol>,
95
98
  selection?: SelectionMeta,
99
+ entityPath?: readonly string[],
96
100
  ): EntityAccessor<T, TSelected> {
97
- return EntityHandle.wrapProxy(new EntityHandle<T, TSelected>(id, entityType, store, dispatcher, schema, brands, selection))
101
+ return EntityHandle.wrapProxy(new EntityHandle<T, TSelected>(id, entityType, store, dispatcher, schema, brands, selection, entityPath))
98
102
  }
99
103
 
100
104
  static createRaw<T extends object = object, TSelected = T>(
@@ -105,12 +109,26 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
105
109
  schema: SchemaRegistry,
106
110
  brands?: Set<symbol>,
107
111
  selection?: SelectionMeta,
112
+ entityPath?: readonly string[],
108
113
  ): EntityHandle<T, TSelected> {
109
- return new EntityHandle<T, TSelected>(id, entityType, store, dispatcher, schema, brands, selection)
114
+ return new EntityHandle<T, TSelected>(id, entityType, store, dispatcher, schema, brands, selection, entityPath)
110
115
  }
111
116
 
112
117
  static wrapProxy<T extends object, TSelected>(handle: EntityHandle<T, TSelected>): EntityAccessor<T, TSelected> {
113
- return createHandleProxy<EntityHandle<T, TSelected>, EntityAccessor<T, TSelected>>(handle, (target) => target.fields)
118
+ return createHandleProxy<EntityHandle<T, TSelected>, EntityAccessor<T, TSelected>>(
119
+ handle,
120
+ (target) => target.fields,
121
+ (target) => target.selectedFieldNames,
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Field names the handle's selection exposes, for enumeration. Without a selection
127
+ * nothing is validated and nothing is listed.
128
+ */
129
+ get selectedFieldNames(): readonly string[] {
130
+ if (!this.selection) return []
131
+ return [...this.selection.fields.keys()]
114
132
  }
115
133
 
116
134
  get [FIELD_REF_META](): FieldRefMeta {
@@ -139,11 +157,14 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
139
157
  }
140
158
 
141
159
  /**
142
- * Gets the current entity data.
160
+ * Gets the current entity data to DISPLAY.
143
161
  * Returns selected fields subset as specified by TSelected type parameter.
162
+ * While a pessimistic persist is in-flight this is the server baseline; the
163
+ * canonical snapshot (see {@link getSnapshot}, used for dirty tracking) stays
164
+ * dirty underneath.
144
165
  */
145
166
  get data(): TSelected | null {
146
- const snapshot = this.getSnapshot()
167
+ const snapshot = this.store.getPresentationSnapshot<T>(this.entityType, this.entityId)
147
168
  return (snapshot?.data ?? null) as TSelected | null
148
169
  }
149
170
 
@@ -251,24 +272,27 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
251
272
  * Gets a field handle for a specific field.
252
273
  * Returns cached handle to ensure stable identity.
253
274
  */
254
- field<K extends keyof T>(fieldName: K): FieldAccessor<T[K]> {
255
- const cacheKey = String(fieldName)
275
+ field<K extends keyof T>(fieldName: K, dataFieldName = String(fieldName)): FieldAccessor<T[K]> {
276
+ const schemaFieldName = String(fieldName)
277
+ const cacheKey = dataFieldName
256
278
  const cached = this.fieldHandleCache.get(cacheKey)
257
279
 
258
280
  if (cached) {
259
281
  return cached.proxy as FieldAccessor<T[K]>
260
282
  }
261
283
 
262
- const enumName = this.schema.getEnumName(this.entityType, cacheKey)
263
- const columnType = this.schema.getColumnType(this.entityType, cacheKey)
284
+ const enumName = this.schema.getEnumName(this.entityType, schemaFieldName)
285
+ const columnType = this.schema.getColumnType(this.entityType, schemaFieldName)
264
286
  const raw = FieldHandle.createRaw<T[K]>(
265
287
  this.entityType,
266
288
  this.entityId,
267
- [cacheKey],
289
+ [schemaFieldName],
268
290
  this.store,
269
291
  this.dispatcher,
270
292
  enumName,
271
293
  columnType,
294
+ [dataFieldName],
295
+ [...this.entityPath, schemaFieldName],
272
296
  )
273
297
  const proxy = FieldHandle.wrapProxy(raw)
274
298
  this.fieldHandleCache.set(cacheKey, { raw, proxy } as CachedFieldHandle)
@@ -279,8 +303,8 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
279
303
  /**
280
304
  * Gets a has-one relation handle.
281
305
  */
282
- hasOne<TRelated extends object>(fieldName: string, nestedSelection?: SelectionMeta): HasOneAccessor<TRelated> {
283
- const cacheKey = `hasOne:${fieldName}`
306
+ hasOne<TRelated extends object>(fieldName: string, nestedSelection?: SelectionMeta, dataFieldName = fieldName): HasOneAccessor<TRelated> {
307
+ const cacheKey = `hasOne:${dataFieldName}`
284
308
  const cached = this.relationHandleCache.get(cacheKey)
285
309
 
286
310
  if (cached) {
@@ -304,6 +328,8 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
304
328
  this.schema,
305
329
  undefined,
306
330
  nestedSelection,
331
+ dataFieldName,
332
+ [...this.entityPath, fieldName],
307
333
  )
308
334
  const proxy = HasOneHandle.wrapProxy(raw)
309
335
  this.relationHandleCache.set(cacheKey, { raw, proxy })
@@ -344,6 +370,7 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
344
370
  this.__brands,
345
371
  effectiveAlias,
346
372
  nestedSelection,
373
+ [...this.entityPath, fieldName],
347
374
  )
348
375
  this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle })
349
376
 
@@ -379,6 +406,8 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
379
406
  this.schema,
380
407
  undefined,
381
408
  undefined,
409
+ undefined,
410
+ [...this.entityPath, fieldName],
382
411
  )
383
412
  const proxy = HasOneHandle.wrapProxy(raw)
384
413
  this.relationHandleCache.set(cacheKey, { raw, proxy })
@@ -462,29 +491,31 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
462
491
  }
463
492
 
464
493
  const nestedSelection = fieldMeta?.nested
494
+ const schemaFieldName = fieldMeta?.fieldName ?? fieldName
495
+ const dataFieldName = fieldMeta?.alias ?? fieldName
465
496
 
466
497
  // Use schema to determine field type
467
- const fieldDef = this.schema.getFieldDef(this.entityType, fieldName)
498
+ const fieldDef = this.schema.getFieldDef(this.entityType, schemaFieldName)
468
499
 
469
500
  if (!fieldDef || fieldDef.type === 'scalar') {
470
501
  // Scalar field - return FieldHandle
471
- return this.field(fieldName as keyof T)
502
+ return this.field(schemaFieldName as keyof T, dataFieldName)
472
503
  }
473
504
 
474
505
  if (fieldDef.type === 'hasOne') {
475
506
  // Has-one relation - return HasOneHandle
476
- return this.hasOne(fieldName, nestedSelection)
507
+ return this.hasOne(schemaFieldName, nestedSelection, dataFieldName)
477
508
  }
478
509
 
479
510
  if (fieldDef.type === 'hasMany') {
480
511
  // Has-many relation - return HasManyListHandle.
481
512
  // Thread the selected alias so the handle reads data stored under the
482
513
  // auto-generated alias (e.g. `tags_<hash>`) for params-bearing relations.
483
- return this.hasMany(fieldName, fieldMeta?.alias, nestedSelection)
514
+ return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection)
484
515
  }
485
516
 
486
517
  // Unknown field type - fallback to FieldHandle
487
- return this.field(fieldName as keyof T)
518
+ return this.field(schemaFieldName as keyof T, dataFieldName)
488
519
  },
489
520
  })
490
521
  }