@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.
- package/dist/adapter/MockAdapter.d.ts +4 -1
- package/dist/adapter/MockAdapter.d.ts.map +1 -1
- 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/adapter/MockAdapter.ts +15 -5
- 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 +120 -16
- 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
|
@@ -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
|
-
|
|
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
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
73
|
+
// Capture state before execution (for after events)
|
|
74
|
+
const stateBefore = captureStateBeforeAction(action, this.store)
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
// Execute the action
|
|
77
|
+
this.execute(action)
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
112
|
-
|
|
119
|
+
this.store.beginTransaction()
|
|
120
|
+
try {
|
|
121
|
+
// Execute the action
|
|
122
|
+
this.execute(action)
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/core/actions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|