@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.
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +121 -17
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- 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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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>>(
|
|
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.
|
|
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
|
|
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,
|
|
263
|
-
const columnType = this.schema.getColumnType(this.entityType,
|
|
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
|
-
[
|
|
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:${
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
514
|
+
return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection)
|
|
484
515
|
}
|
|
485
516
|
|
|
486
517
|
// Unknown field type - fallback to FieldHandle
|
|
487
|
-
return this.field(
|
|
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.
|
|
102
|
+
const data = this.getPresentationData()
|
|
94
103
|
if (!data) return null
|
|
95
|
-
return
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|