@contember/bindx 0.1.45 → 0.1.47

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 (118) hide show
  1. package/dist/core/ActionDispatcher.d.ts +4 -1
  2. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  3. package/dist/core/actions.d.ts +7 -1
  4. package/dist/core/actions.d.ts.map +1 -1
  5. package/dist/events/EventEmitter.d.ts +13 -1
  6. package/dist/events/EventEmitter.d.ts.map +1 -1
  7. package/dist/handles/BaseHandle.d.ts +14 -3
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/EntityHandle.d.ts +14 -5
  10. package/dist/handles/EntityHandle.d.ts.map +1 -1
  11. package/dist/handles/FieldHandle.d.ts +11 -4
  12. package/dist/handles/FieldHandle.d.ts.map +1 -1
  13. package/dist/handles/HasManyListHandle.d.ts +36 -2
  14. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  15. package/dist/handles/HasOneHandle.d.ts +52 -9
  16. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  17. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  18. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  19. package/dist/handles/index.d.ts +1 -1
  20. package/dist/handles/index.d.ts.map +1 -1
  21. package/dist/handles/proxyFactory.d.ts +7 -1
  22. package/dist/handles/proxyFactory.d.ts.map +1 -1
  23. package/dist/handles/types.d.ts +33 -0
  24. package/dist/handles/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/persistence/BatchPersister.d.ts +53 -37
  28. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  29. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  30. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  31. package/dist/persistence/MutationCollector.d.ts +106 -3
  32. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  33. package/dist/persistence/PersistExecution.d.ts +26 -0
  34. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  35. package/dist/persistence/types.d.ts +8 -2
  36. package/dist/persistence/types.d.ts.map +1 -1
  37. package/dist/store/EntityMetaStore.d.ts +32 -10
  38. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  39. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  40. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  41. package/dist/store/ErrorStore.d.ts +3 -2
  42. package/dist/store/ErrorStore.d.ts.map +1 -1
  43. package/dist/store/HasManyStore.d.ts +214 -0
  44. package/dist/store/HasManyStore.d.ts.map +1 -0
  45. package/dist/store/HasOneStore.d.ts +159 -0
  46. package/dist/store/HasOneStore.d.ts.map +1 -0
  47. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  48. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  49. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  50. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  51. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  52. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  53. package/dist/store/RelationStore.d.ts +74 -137
  54. package/dist/store/RelationStore.d.ts.map +1 -1
  55. package/dist/store/RootRegistry.d.ts +25 -4
  56. package/dist/store/RootRegistry.d.ts.map +1 -1
  57. package/dist/store/SnapshotStore.d.ts +137 -13
  58. package/dist/store/SnapshotStore.d.ts.map +1 -1
  59. package/dist/store/SubscriptionManager.d.ts +63 -14
  60. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  61. package/dist/store/TouchedStore.d.ts +3 -2
  62. package/dist/store/TouchedStore.d.ts.map +1 -1
  63. package/dist/store/relationKey.d.ts +15 -0
  64. package/dist/store/relationKey.d.ts.map +1 -0
  65. package/dist/undo/UndoJournal.d.ts +133 -0
  66. package/dist/undo/UndoJournal.d.ts.map +1 -0
  67. package/dist/undo/UndoManager.d.ts +25 -82
  68. package/dist/undo/UndoManager.d.ts.map +1 -1
  69. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  70. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  71. package/dist/undo/index.d.ts +3 -1
  72. package/dist/undo/index.d.ts.map +1 -1
  73. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  74. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  75. package/dist/undo/types.d.ts +0 -48
  76. package/dist/undo/types.d.ts.map +1 -1
  77. package/package.json +2 -2
  78. package/src/core/ActionDispatcher.ts +56 -36
  79. package/src/core/actions.ts +8 -1
  80. package/src/events/EventEmitter.ts +78 -24
  81. package/src/handles/BaseHandle.ts +19 -2
  82. package/src/handles/EntityHandle.ts +48 -17
  83. package/src/handles/FieldHandle.ts +36 -10
  84. package/src/handles/HasManyListHandle.ts +121 -17
  85. package/src/handles/HasOneHandle.ts +177 -44
  86. package/src/handles/PlaceholderHandle.ts +184 -36
  87. package/src/handles/index.ts +3 -0
  88. package/src/handles/proxyFactory.ts +27 -0
  89. package/src/handles/types.ts +39 -0
  90. package/src/index.ts +4 -4
  91. package/src/persistence/BatchPersister.ts +810 -446
  92. package/src/persistence/ChangeRegistry.ts +27 -3
  93. package/src/persistence/MutationCollector.ts +265 -31
  94. package/src/persistence/PersistExecution.ts +217 -0
  95. package/src/persistence/types.ts +8 -2
  96. package/src/store/EntityMetaStore.ts +89 -45
  97. package/src/store/EntitySnapshotStore.ts +102 -19
  98. package/src/store/ErrorStore.ts +6 -5
  99. package/src/store/HasManyStore.ts +810 -0
  100. package/src/store/HasOneStore.ts +454 -0
  101. package/src/store/ReachabilityAnalyzer.ts +37 -0
  102. package/src/store/RekeyOrchestrator.ts +123 -0
  103. package/src/store/RelationEdgeIndex.ts +79 -0
  104. package/src/store/RelationStore.ts +176 -725
  105. package/src/store/RootRegistry.ts +50 -8
  106. package/src/store/SnapshotStore.ts +572 -101
  107. package/src/store/SubscriptionManager.ts +122 -75
  108. package/src/store/TouchedStore.ts +5 -2
  109. package/src/store/relationKey.ts +20 -0
  110. package/src/undo/UndoJournal.ts +258 -0
  111. package/src/undo/UndoManager.ts +151 -281
  112. package/src/undo/UnrecordedWriteError.ts +23 -0
  113. package/src/undo/index.ts +8 -7
  114. package/src/undo/rekeyJournalEntry.ts +145 -0
  115. package/src/undo/types.ts +0 -58
  116. package/dist/core/actionClassification.d.ts +0 -48
  117. package/dist/core/actionClassification.d.ts.map +0 -1
  118. package/src/core/actionClassification.ts +0 -199
@@ -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
  }
@@ -37,6 +37,9 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
37
37
  dispatcher: ActionDispatcher,
38
38
  private readonly _enumName?: string,
39
39
  private readonly _columnType?: string,
40
+ private readonly dataFieldPath: string[] = fieldPath,
41
+ // Absolute chain from the query root; see FieldRefMeta.fullPath.
42
+ private readonly _fullPath: readonly string[] = fieldPath,
40
43
  ) {
41
44
  super(entityType, entityId, store, dispatcher)
42
45
  }
@@ -49,8 +52,10 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
49
52
  dispatcher: ActionDispatcher,
50
53
  enumName?: string,
51
54
  columnType?: string,
55
+ dataFieldPath?: string[],
56
+ fullPath?: readonly string[],
52
57
  ): FieldAccessor<T> {
53
- return createAliasProxy<FieldHandle<T>, FieldAccessor<T>>(new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType))
58
+ return createAliasProxy<FieldHandle<T>, FieldAccessor<T>>(new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath))
54
59
  }
55
60
 
56
61
  static createRaw<T = unknown>(
@@ -61,8 +66,10 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
61
66
  dispatcher: ActionDispatcher,
62
67
  enumName?: string,
63
68
  columnType?: string,
69
+ dataFieldPath?: string[],
70
+ fullPath?: readonly string[],
64
71
  ): FieldHandle<T> {
65
- return new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType)
72
+ return new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath)
66
73
  }
67
74
 
68
75
  static wrapProxy<T>(handle: FieldHandle<T>): FieldAccessor<T> {
@@ -83,16 +90,18 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
83
90
  isRelation: false,
84
91
  enumName: this._enumName,
85
92
  columnType: this._columnType,
93
+ fullPath: this._fullPath,
86
94
  }
87
95
  }
88
96
 
89
97
  /**
90
- * Gets the current field value.
98
+ * Gets the current field value to DISPLAY. While a pessimistic persist is
99
+ * in-flight this is the server baseline; otherwise it is the live value.
91
100
  */
92
101
  get value(): T | null {
93
- const data = this.getEntityData()
102
+ const data = this.getPresentationData()
94
103
  if (!data) return null
95
- return getNestedValue(data, this.fieldPath) as T | null
104
+ return this.getCurrentValue(data) as T | null
96
105
  }
97
106
 
98
107
  /**
@@ -101,14 +110,28 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
101
110
  get serverValue(): T | null {
102
111
  const serverData = this.getServerData()
103
112
  if (!serverData) return null
104
- return getNestedValue(serverData, this.fieldPath) as T | null
113
+ return this.getServerValue(serverData) as T | null
105
114
  }
106
115
 
107
116
  /**
108
- * Checks if the field has been modified.
117
+ * Checks if the field has been modified. Compares the CANONICAL value against
118
+ * the server value — never the presented value, so a field stays correctly
119
+ * dirty even while its display shows the server baseline mid-pessimistic-persist.
109
120
  */
110
121
  get isDirty(): boolean {
111
- return !deepEqual(this.value, this.serverValue)
122
+ const data = this.getEntityData()
123
+ const value = data ? (this.getCurrentValue(data) as T | null) : null
124
+ return !deepEqual(value, this.serverValue)
125
+ }
126
+
127
+ private getCurrentValue(data: Record<string, unknown>): unknown {
128
+ const value = getNestedValue(data, this.fieldPath)
129
+ return value === undefined ? getNestedValue(data, this.dataFieldPath) : value
130
+ }
131
+
132
+ private getServerValue(data: Record<string, unknown>): unknown {
133
+ const value = getNestedValue(data, this.fieldPath)
134
+ return value === undefined ? getNestedValue(data, this.dataFieldPath) : value
112
135
  }
113
136
 
114
137
  /**
@@ -217,12 +240,16 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
217
240
  nested<K extends keyof NonNullable<T>>(
218
241
  key: K,
219
242
  ): FieldAccessor<NonNullable<T>[K]> {
243
+ const fieldName = String(key)
220
244
  return FieldHandle.create<NonNullable<T>[K]>(
221
245
  this.entityType,
222
246
  this.entityId,
223
- [...this.fieldPath, key as string],
247
+ [...this.fieldPath, fieldName],
224
248
  this.store,
225
249
  this.dispatcher,
250
+ undefined,
251
+ undefined,
252
+ [...this.dataFieldPath, fieldName],
226
253
  )
227
254
  }
228
255
 
@@ -277,4 +304,3 @@ function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
277
304
 
278
305
  return current
279
306
  }
280
-