@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
|
@@ -33,7 +33,10 @@ import { createAliasProxy } from './proxyFactory.js'
|
|
|
33
33
|
* @typeParam TSelected - The selected subset of fields (defaults to TEntity for backwards compatibility)
|
|
34
34
|
*/
|
|
35
35
|
export class HasManyListHandle<TEntity extends object = object, TSelected = TEntity> extends EntityRelatedHandle {
|
|
36
|
-
|
|
36
|
+
// Per-item accessor cache, keyed by the item's canonical (post-rekey) id. Identity is the
|
|
37
|
+
// point: a still-listed item keeps the same proxy — and with it the same handle, which the
|
|
38
|
+
// proxy is the only reference to. Kept bounded by syncItemHandleCache, which drops ids
|
|
39
|
+
// that have left the list.
|
|
37
40
|
private itemHandleCacheProxy = new Map<string, EntityAccessor<TEntity, TSelected>>()
|
|
38
41
|
|
|
39
42
|
/** Runtime brand symbols for validation */
|
|
@@ -73,6 +76,9 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
73
76
|
brands?: Set<symbol>,
|
|
74
77
|
alias?: string,
|
|
75
78
|
private readonly selection?: SelectionMeta,
|
|
79
|
+
// Segments from the query root down to this relation. Items do NOT inherit it:
|
|
80
|
+
// a has-many restarts the chain, mirroring the collector proxy.
|
|
81
|
+
private readonly relationPath: readonly string[] = [fieldName],
|
|
76
82
|
) {
|
|
77
83
|
super(parentEntityType, parentEntityId, store, dispatcher)
|
|
78
84
|
this.__brands = brands
|
|
@@ -90,8 +96,31 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
90
96
|
brands?: Set<symbol>,
|
|
91
97
|
alias?: string,
|
|
92
98
|
selection?: SelectionMeta,
|
|
99
|
+
relationPath?: readonly string[],
|
|
93
100
|
): HasManyAccessor<TEntity, TSelected> {
|
|
94
|
-
return createAliasProxy<HasManyListHandle<TEntity, TSelected>, HasManyAccessor<TEntity, TSelected>>(
|
|
101
|
+
return createAliasProxy<HasManyListHandle<TEntity, TSelected>, HasManyAccessor<TEntity, TSelected>>(
|
|
102
|
+
HasManyListHandle.createRaw<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates the handle without the alias proxy. Mirrors {@link EntityHandle.createRaw}:
|
|
108
|
+
* the class surface (e.g. {@link itemHandleCacheSize}) is not part of HasManyAccessor.
|
|
109
|
+
*/
|
|
110
|
+
static createRaw<TEntity extends object = object, TSelected = TEntity>(
|
|
111
|
+
parentEntityType: string,
|
|
112
|
+
parentEntityId: string,
|
|
113
|
+
fieldName: string,
|
|
114
|
+
itemType: string,
|
|
115
|
+
store: SnapshotStore,
|
|
116
|
+
dispatcher: ActionDispatcher,
|
|
117
|
+
schema: SchemaRegistry,
|
|
118
|
+
brands?: Set<symbol>,
|
|
119
|
+
alias?: string,
|
|
120
|
+
selection?: SelectionMeta,
|
|
121
|
+
relationPath?: readonly string[],
|
|
122
|
+
): HasManyListHandle<TEntity, TSelected> {
|
|
123
|
+
return new HasManyListHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath)
|
|
95
124
|
}
|
|
96
125
|
|
|
97
126
|
/**
|
|
@@ -107,6 +136,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
107
136
|
isArray: true,
|
|
108
137
|
isRelation: true,
|
|
109
138
|
targetType: this.itemType,
|
|
139
|
+
fullPath: this.relationPath,
|
|
110
140
|
}
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -115,21 +145,76 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
115
145
|
* Returns selection-aware EntityAccessors that support `item.fieldName.value`.
|
|
116
146
|
* Includes planned connections and excludes planned removals.
|
|
117
147
|
* Uses ordered IDs to preserve order including after move() operations.
|
|
148
|
+
*
|
|
149
|
+
* This is also where the item-accessor cache is pruned — see {@link syncItemHandleCache}.
|
|
118
150
|
*/
|
|
119
151
|
get items(): EntityAccessor<TEntity, TSelected>[] {
|
|
120
|
-
|
|
152
|
+
this.materializeEmbeddedItems()
|
|
121
153
|
|
|
122
154
|
// Use ordered IDs from store (handles removals, connections, and ordering)
|
|
123
|
-
const orderedIds = this.store.
|
|
155
|
+
const orderedIds = this.store.getPresentationHasManyOrderedIds(
|
|
124
156
|
this.entityType,
|
|
125
157
|
this.entityId,
|
|
126
158
|
this.fieldName,
|
|
127
159
|
this.alias,
|
|
128
160
|
)
|
|
129
161
|
|
|
162
|
+
this.syncItemHandleCache(orderedIds)
|
|
163
|
+
|
|
130
164
|
return orderedIds.map((id) => this.getItemHandle(id))
|
|
131
165
|
}
|
|
132
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Canonical cache key for an item id: a temp id follows its temp→persisted rekey, so a
|
|
169
|
+
* lookup by the now-dead temp id reuses the persisted item's handle instead of minting a
|
|
170
|
+
* duplicate for the same entity.
|
|
171
|
+
*/
|
|
172
|
+
private resolveItemKey(itemId: string): string {
|
|
173
|
+
return this.store.resolveEntityId(this.itemType, itemId)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Drops cached item accessors for ids that have left the list. Without it the cache keeps an
|
|
178
|
+
* accessor for every id the relation has ever shown, for as long as the parent handle lives
|
|
179
|
+
* — a paginated / filtered / refetched has-many grows without bound.
|
|
180
|
+
*
|
|
181
|
+
* Runs before the accessors are resolved and never touches a live id, so an item that is
|
|
182
|
+
* still listed keeps the exact same proxy and the exact same handle behind it.
|
|
183
|
+
*/
|
|
184
|
+
private syncItemHandleCache(liveIds: readonly string[]): void {
|
|
185
|
+
if (this.itemHandleCacheProxy.size === 0) return
|
|
186
|
+
this.canonicalizeItemHandleCache()
|
|
187
|
+
|
|
188
|
+
// While the parent persists, the presented list hides planned additions — pruning
|
|
189
|
+
// against it would drop handles that reappear as soon as the persist settles.
|
|
190
|
+
if (this.store.isPersisting(this.entityType, this.entityId)) return
|
|
191
|
+
|
|
192
|
+
const liveKeys = new Set(liveIds.map(id => this.resolveItemKey(id)))
|
|
193
|
+
for (const key of this.itemHandleCacheProxy.keys()) {
|
|
194
|
+
if (liveKeys.has(key)) continue
|
|
195
|
+
this.itemHandleCacheProxy.delete(key)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private canonicalizeItemHandleCache(): void {
|
|
200
|
+
for (const [key, proxy] of [...this.itemHandleCacheProxy]) {
|
|
201
|
+
const canonicalKey = this.resolveItemKey(key)
|
|
202
|
+
if (canonicalKey === key) continue
|
|
203
|
+
if (!this.itemHandleCacheProxy.has(canonicalKey)) {
|
|
204
|
+
this.itemHandleCacheProxy.set(canonicalKey, proxy)
|
|
205
|
+
}
|
|
206
|
+
this.itemHandleCacheProxy.delete(key)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @internal Occupancy of the item-accessor cache. Exposed only so eviction tests can
|
|
212
|
+
* assert the cache stays bounded; not part of the HasManyAccessor API.
|
|
213
|
+
*/
|
|
214
|
+
get itemHandleCacheSize(): number {
|
|
215
|
+
return this.itemHandleCacheProxy.size
|
|
216
|
+
}
|
|
217
|
+
|
|
133
218
|
/**
|
|
134
219
|
* Propagates the parent's embedded has-many data into per-item snapshots and
|
|
135
220
|
* ensures the has-many state exists in the store.
|
|
@@ -139,6 +224,12 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
139
224
|
* without ever reading `items`, and that path must also see populated data.
|
|
140
225
|
* Idempotent within a render — guarded by hasEmbeddedDataChanged.
|
|
141
226
|
*
|
|
227
|
+
* For the same reason EVERY entry point that reads or writes the has-many state
|
|
228
|
+
* calls this first — isDirty and the mutators as well as items/getById. An
|
|
229
|
+
* embedded list has no state in the store until it is materialised, so a
|
|
230
|
+
* consumer that never iterated saw isDirty === false and had its mutation land
|
|
231
|
+
* on a state nobody reads. Keep new entry points on this guard.
|
|
232
|
+
*
|
|
142
233
|
* @returns false when there is no embedded list data to materialise.
|
|
143
234
|
*/
|
|
144
235
|
private materializeEmbeddedItems(): boolean {
|
|
@@ -274,11 +365,13 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
274
365
|
* Returns selection-aware EntityAccessor that supports direct field access.
|
|
275
366
|
*/
|
|
276
367
|
getItemHandle(itemId: string): EntityAccessor<TEntity, TSelected> {
|
|
277
|
-
|
|
368
|
+
this.canonicalizeItemHandleCache()
|
|
369
|
+
const key = this.resolveItemKey(itemId)
|
|
370
|
+
let proxy = this.itemHandleCacheProxy.get(key)
|
|
278
371
|
|
|
279
372
|
if (!proxy) {
|
|
280
373
|
const raw = EntityHandle.createRaw<TEntity, TSelected>(
|
|
281
|
-
|
|
374
|
+
key,
|
|
282
375
|
this.itemType,
|
|
283
376
|
this.store,
|
|
284
377
|
this.dispatcher,
|
|
@@ -287,8 +380,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
287
380
|
this.selection,
|
|
288
381
|
)
|
|
289
382
|
proxy = EntityHandle.wrapProxy(raw)
|
|
290
|
-
this.
|
|
291
|
-
this.itemHandleCacheProxy.set(itemId, proxy)
|
|
383
|
+
this.itemHandleCacheProxy.set(key, proxy)
|
|
292
384
|
}
|
|
293
385
|
|
|
294
386
|
return proxy
|
|
@@ -311,6 +403,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
311
403
|
* Checks if the list is dirty (items added/removed/moved/connected/disconnected).
|
|
312
404
|
*/
|
|
313
405
|
get isDirty(): boolean {
|
|
406
|
+
this.materializeEmbeddedItems()
|
|
407
|
+
|
|
314
408
|
const state = this.store.getHasMany(
|
|
315
409
|
this.entityType,
|
|
316
410
|
this.entityId,
|
|
@@ -322,8 +416,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
322
416
|
|
|
323
417
|
return (
|
|
324
418
|
state.plannedRemovals.size > 0 ||
|
|
325
|
-
state.
|
|
326
|
-
state.createdEntities.size > 0 ||
|
|
419
|
+
state.plannedAdditions.size > 0 ||
|
|
327
420
|
state.orderedIds !== null
|
|
328
421
|
)
|
|
329
422
|
}
|
|
@@ -340,6 +433,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
340
433
|
* Connects an existing entity to this has-many relation.
|
|
341
434
|
*/
|
|
342
435
|
connect(itemId: string): void {
|
|
436
|
+
this.materializeEmbeddedItems()
|
|
343
437
|
this.dispatcher.dispatch(
|
|
344
438
|
connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.itemType, this.alias),
|
|
345
439
|
)
|
|
@@ -351,6 +445,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
351
445
|
* For created entities (via add()), cancels the add operation.
|
|
352
446
|
*/
|
|
353
447
|
disconnect(itemId: string): void {
|
|
448
|
+
this.materializeEmbeddedItems()
|
|
354
449
|
this.dispatcher.dispatch(
|
|
355
450
|
removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'disconnect', this.alias),
|
|
356
451
|
)
|
|
@@ -362,6 +457,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
362
457
|
* For created entities (via add()), cancels the add operation.
|
|
363
458
|
*/
|
|
364
459
|
delete(itemId: string): void {
|
|
460
|
+
this.materializeEmbeddedItems()
|
|
365
461
|
this.dispatcher.dispatch(
|
|
366
462
|
removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'delete', this.alias),
|
|
367
463
|
)
|
|
@@ -373,16 +469,21 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
373
469
|
* For connecting existing entities, use connect() instead.
|
|
374
470
|
*/
|
|
375
471
|
add(data?: Partial<TEntity>): string {
|
|
472
|
+
this.materializeEmbeddedItems()
|
|
376
473
|
|
|
377
|
-
//
|
|
378
|
-
|
|
474
|
+
// One gesture = one undo entry: the pre-create and the list-add are journaled
|
|
475
|
+
// together so undo removes (and redo re-creates) the whole row.
|
|
476
|
+
return this.store.transaction(() => {
|
|
477
|
+
// Pre-create entity so we can return tempId
|
|
478
|
+
const tempId = this.store.createEntity(this.itemType, data as Record<string, unknown>)
|
|
379
479
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
480
|
+
// Add to has-many relation through dispatcher (enables events/interceptors)
|
|
481
|
+
this.dispatcher.dispatch(
|
|
482
|
+
addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias),
|
|
483
|
+
)
|
|
384
484
|
|
|
385
|
-
|
|
485
|
+
return tempId
|
|
486
|
+
})
|
|
386
487
|
}
|
|
387
488
|
|
|
388
489
|
/**
|
|
@@ -395,6 +496,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
395
496
|
* Falls back to disconnect when schema metadata is not available.
|
|
396
497
|
*/
|
|
397
498
|
remove(itemId: string): void {
|
|
499
|
+
this.materializeEmbeddedItems()
|
|
398
500
|
this.dispatcher.dispatch(
|
|
399
501
|
removeFromList(this.entityType, this.entityId, this.fieldName, itemId, this.resolveRemovalType(), this.alias),
|
|
400
502
|
)
|
|
@@ -425,6 +527,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
425
527
|
* use an 'order' field on the entity.
|
|
426
528
|
*/
|
|
427
529
|
move(fromIndex: number, toIndex: number): void {
|
|
530
|
+
this.materializeEmbeddedItems()
|
|
428
531
|
this.dispatcher.dispatch(
|
|
429
532
|
moveInList(this.entityType, this.entityId, this.fieldName, fromIndex, toIndex, this.alias),
|
|
430
533
|
)
|
|
@@ -435,6 +538,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
435
538
|
* Clears all planned connections and removals.
|
|
436
539
|
*/
|
|
437
540
|
reset(): void {
|
|
541
|
+
this.materializeEmbeddedItems()
|
|
438
542
|
this.store.resetHasMany(
|
|
439
543
|
this.entityType,
|
|
440
544
|
this.entityId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js'
|
|
2
2
|
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
|
|
3
3
|
import type { SnapshotStore } from '../store/SnapshotStore.js'
|
|
4
|
+
import type { StoredRelationState } from '../store/RelationStore.js'
|
|
4
5
|
import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
|
|
5
6
|
import type { SelectionMeta } from '../selection/types.js'
|
|
6
7
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type Unsubscribe,
|
|
18
19
|
type EntityAccessor,
|
|
19
20
|
type HasOneAccessor,
|
|
21
|
+
type HasOneRelationState,
|
|
20
22
|
} from './types.js'
|
|
21
23
|
import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js'
|
|
22
24
|
import type {
|
|
@@ -63,6 +65,10 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
63
65
|
private readonly schema: SchemaRegistry,
|
|
64
66
|
brands?: Set<symbol>,
|
|
65
67
|
private readonly selection?: SelectionMeta,
|
|
68
|
+
private readonly dataFieldName: string = fieldName,
|
|
69
|
+
// Segments from the query root down to this relation; also the entity path
|
|
70
|
+
// of whatever it resolves to. See FieldRefMeta.fullPath.
|
|
71
|
+
private readonly relationPath: readonly string[] = [fieldName],
|
|
66
72
|
) {
|
|
67
73
|
super(parentEntityType, parentEntityId, store, dispatcher)
|
|
68
74
|
this.__brands = brands
|
|
@@ -78,8 +84,10 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
78
84
|
schema: SchemaRegistry,
|
|
79
85
|
brands?: Set<symbol>,
|
|
80
86
|
selection?: SelectionMeta,
|
|
87
|
+
dataFieldName?: string,
|
|
88
|
+
relationPath?: readonly string[],
|
|
81
89
|
): HasOneAccessor<TEntity, TSelected> {
|
|
82
|
-
return
|
|
90
|
+
return HasOneHandle.wrapProxy(new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath))
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
static createRaw<TEntity extends object = object, TSelected = TEntity>(
|
|
@@ -92,12 +100,18 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
92
100
|
schema: SchemaRegistry,
|
|
93
101
|
brands?: Set<symbol>,
|
|
94
102
|
selection?: SelectionMeta,
|
|
103
|
+
dataFieldName?: string,
|
|
104
|
+
relationPath?: readonly string[],
|
|
95
105
|
): HasOneHandle<TEntity, TSelected> {
|
|
96
|
-
return new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection)
|
|
106
|
+
return new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath)
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
static wrapProxy<TEntity extends object, TSelected>(handle: HasOneHandle<TEntity, TSelected>): HasOneAccessor<TEntity, TSelected> {
|
|
100
|
-
return createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(
|
|
110
|
+
return createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(
|
|
111
|
+
handle,
|
|
112
|
+
(target) => target.entityRaw.fields,
|
|
113
|
+
(target) => target.entityRaw.selectedFieldNames,
|
|
114
|
+
)
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
@@ -113,6 +127,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
113
127
|
isArray: false,
|
|
114
128
|
isRelation: true,
|
|
115
129
|
targetType: this.targetType,
|
|
130
|
+
fullPath: this.relationPath,
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
|
|
@@ -137,51 +152,141 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
137
152
|
|
|
138
153
|
/**
|
|
139
154
|
* Gets the relation state.
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* requiring a prior RelationStore entry.
|
|
155
|
+
* Materializes the RelationStore entry from embedded snapshot data first, so a
|
|
156
|
+
* server-loaded has-one reports 'connected' without a prior explicit entry.
|
|
143
157
|
*/
|
|
144
|
-
get state():
|
|
145
|
-
|
|
158
|
+
get state(): HasOneRelationState {
|
|
159
|
+
this.ensureEntry()
|
|
160
|
+
const relation = this.store.getPresentationRelation(
|
|
146
161
|
this.entityType,
|
|
147
162
|
this.entityId,
|
|
148
163
|
this.fieldName,
|
|
149
164
|
)
|
|
150
|
-
|
|
151
|
-
return relation.state
|
|
152
|
-
}
|
|
153
|
-
// No explicit relation state — check if snapshot has embedded data
|
|
154
|
-
if (this.relatedId !== null) {
|
|
155
|
-
return 'connected'
|
|
156
|
-
}
|
|
157
|
-
return 'disconnected'
|
|
165
|
+
return relation?.state ?? 'disconnected'
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
/**
|
|
161
|
-
* Gets the current related entity ID
|
|
162
|
-
*
|
|
169
|
+
* Gets the current related entity ID — read exclusively from the RelationStore
|
|
170
|
+
* after materializing the entry from embedded snapshot data.
|
|
163
171
|
*/
|
|
164
172
|
get relatedId(): string | null {
|
|
165
|
-
|
|
166
|
-
const relation = this.store.
|
|
173
|
+
this.ensureEntry()
|
|
174
|
+
const relation = this.store.getPresentationRelation(
|
|
167
175
|
this.entityType,
|
|
168
176
|
this.entityId,
|
|
169
177
|
this.fieldName,
|
|
170
178
|
)
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
return relation?.currentId ?? null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Materializes this has-one's RelationStore entry from the parent's embedded
|
|
184
|
+
* snapshot data, so the store is the single source of truth (symmetric with
|
|
185
|
+
* {@link HasManyListHandle.materializeEmbeddedItems}).
|
|
186
|
+
*
|
|
187
|
+
* Only the relation entry is touched here — child-snapshot propagation stays in
|
|
188
|
+
* {@link ensureRelatedEntitySnapshot}, which owns the per-relation propagation
|
|
189
|
+
* slot so the two paths never double-consume it.
|
|
190
|
+
*
|
|
191
|
+
* - No entry yet + the parent embeds a related object with an id → create a
|
|
192
|
+
* `connected` entry (non-notifying) whose server baseline comes from the
|
|
193
|
+
* parent's serverData, so a freshly loaded relation is not dirty
|
|
194
|
+
* (currentId === serverId, state === serverState).
|
|
195
|
+
* - Existing entry + parent re-fetch (embedded reference changed) that is NOT
|
|
196
|
+
* locally dirty → advance the server baseline to the new related id.
|
|
197
|
+
* - A local connect/disconnect, a placeholder, or a `creating` entry is left
|
|
198
|
+
* untouched — it is detected as a locally-dirty relation.
|
|
199
|
+
* - No embedded data and no entry → the relation stays unmaterialized (null).
|
|
200
|
+
*/
|
|
201
|
+
private ensureEntry(): void {
|
|
202
|
+
const existing = this.store.getRelation(this.entityType, this.entityId, this.fieldName)
|
|
203
|
+
const embeddedData = this.readEmbeddedRelatedData()
|
|
204
|
+
const embeddedReference = readEmbeddedRelatedReference(embeddedData)
|
|
205
|
+
|
|
206
|
+
if (!existing) {
|
|
207
|
+
if (embeddedReference.kind !== 'connected') return
|
|
208
|
+
const serverId = this.readServerRelatedId()
|
|
209
|
+
this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, {
|
|
210
|
+
currentId: embeddedReference.id,
|
|
211
|
+
serverId,
|
|
212
|
+
state: 'connected',
|
|
213
|
+
serverState: serverId !== null ? 'connected' : 'disconnected',
|
|
214
|
+
placeholderData: {},
|
|
215
|
+
})
|
|
216
|
+
return
|
|
173
217
|
}
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
219
|
+
this.advanceServerBaselineOnRefetch(existing, embeddedReference, embeddedData)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* On a parent re-fetch (embedded reference changed) advances the relation's
|
|
224
|
+
* server baseline to the embedded related id, but only when the relation is
|
|
225
|
+
* not locally dirty — a local connect/disconnect/create must survive a re-fetch.
|
|
226
|
+
*
|
|
227
|
+
* Does NOT consume the propagation slot — {@link ensureRelatedEntitySnapshot}
|
|
228
|
+
* owns it. The same reference-change signal drives both, so both run within the
|
|
229
|
+
* one render that observes a new parent reference.
|
|
230
|
+
*
|
|
231
|
+
* The baseline write is NON-NOTIFYING: it runs during a render-phase read, and
|
|
232
|
+
* the parent re-fetch that produced the new embedded reference already notified
|
|
233
|
+
* subscribers. Notifying again here would mutate the store and synchronously call
|
|
234
|
+
* subscribers mid-render, violating the external-store contract (cf.
|
|
235
|
+
* {@link ensureRelatedEntitySnapshot}, which refreshes server data with skipNotify
|
|
236
|
+
* for the same reason).
|
|
237
|
+
*/
|
|
238
|
+
private advanceServerBaselineOnRefetch(
|
|
239
|
+
existing: StoredRelationState,
|
|
240
|
+
embeddedReference: EmbeddedRelatedReference,
|
|
241
|
+
embeddedData: unknown,
|
|
242
|
+
): void {
|
|
243
|
+
if (embeddedReference.kind === 'absent') return
|
|
244
|
+
if (this.isLocallyDirty(existing)) return
|
|
245
|
+
|
|
246
|
+
if (embeddedReference.kind === 'null' && existing.serverId === null && existing.serverState === 'disconnected') {
|
|
247
|
+
return
|
|
182
248
|
}
|
|
249
|
+
if (embeddedReference.kind === 'connected' && existing.serverId === embeddedReference.id && existing.serverState === 'connected') {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (embeddedReference.kind === 'null') {
|
|
257
|
+
this.store.setRelation(this.entityType, this.entityId, this.fieldName, {
|
|
258
|
+
currentId: null,
|
|
259
|
+
serverId: null,
|
|
260
|
+
state: 'disconnected',
|
|
261
|
+
serverState: 'disconnected',
|
|
262
|
+
}, true)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.store.setRelation(this.entityType, this.entityId, this.fieldName, {
|
|
267
|
+
currentId: embeddedReference.id,
|
|
268
|
+
serverId: embeddedReference.id,
|
|
269
|
+
state: 'connected',
|
|
270
|
+
serverState: 'connected',
|
|
271
|
+
}, true)
|
|
272
|
+
}
|
|
183
273
|
|
|
184
|
-
|
|
274
|
+
private isLocallyDirty(relation: StoredRelationState): boolean {
|
|
275
|
+
return (
|
|
276
|
+
relation.currentId !== relation.serverId ||
|
|
277
|
+
relation.state !== relation.serverState ||
|
|
278
|
+
Object.keys(relation.placeholderData).length > 0
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Reads the embedded related object from the parent's canonical current data. */
|
|
283
|
+
private readEmbeddedRelatedData(): unknown {
|
|
284
|
+
return this.getEntityData()?.[this.dataFieldName]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Extracts the related id from the parent's embedded server data, or null. */
|
|
288
|
+
private readServerRelatedId(): string | null {
|
|
289
|
+
return extractRelatedId(this.getServerData()?.[this.dataFieldName])
|
|
185
290
|
}
|
|
186
291
|
|
|
187
292
|
/**
|
|
@@ -222,6 +327,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
222
327
|
this.schema,
|
|
223
328
|
this.__brands,
|
|
224
329
|
this.selection,
|
|
330
|
+
this.relationPath,
|
|
225
331
|
)
|
|
226
332
|
this.entityHandleCacheProxy = EntityHandle.wrapProxy(this.entityHandleCacheRaw)
|
|
227
333
|
}
|
|
@@ -238,6 +344,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
238
344
|
this.dispatcher,
|
|
239
345
|
this.schema,
|
|
240
346
|
this.__brands,
|
|
347
|
+
this.relationPath,
|
|
241
348
|
)
|
|
242
349
|
this.placeholderCacheProxy = PlaceholderHandle.wrapProxy(this.placeholderCacheRaw)
|
|
243
350
|
}
|
|
@@ -289,7 +396,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
289
396
|
return
|
|
290
397
|
}
|
|
291
398
|
|
|
292
|
-
const embeddedData = (parentSnapshot.data as Record<string, unknown>)[this.
|
|
399
|
+
const embeddedData = (parentSnapshot.data as Record<string, unknown>)[this.dataFieldName]
|
|
293
400
|
if (!embeddedData || typeof embeddedData !== 'object') {
|
|
294
401
|
return
|
|
295
402
|
}
|
|
@@ -307,7 +414,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
307
414
|
// A new reference means the parent was re-fetched from the server.
|
|
308
415
|
// Same reference means the embedded data is stale and must not overwrite
|
|
309
416
|
// child state that may have been updated by a local commit.
|
|
310
|
-
if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.
|
|
417
|
+
if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) {
|
|
311
418
|
return
|
|
312
419
|
}
|
|
313
420
|
|
|
@@ -316,7 +423,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
316
423
|
// (e.g. polling). A new reference with identical values means no actual change.
|
|
317
424
|
const existing = this.store.getEntitySnapshot(this.targetType, id)
|
|
318
425
|
if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record<string, unknown>, existing.serverData as Record<string, unknown>)) {
|
|
319
|
-
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.
|
|
426
|
+
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
|
|
320
427
|
return
|
|
321
428
|
}
|
|
322
429
|
|
|
@@ -331,7 +438,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
331
438
|
embeddedData as Record<string, unknown>,
|
|
332
439
|
true, // skipNotify - called during render, data already exists embedded in parent
|
|
333
440
|
)
|
|
334
|
-
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.
|
|
441
|
+
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
|
|
335
442
|
}
|
|
336
443
|
|
|
337
444
|
/**
|
|
@@ -354,6 +461,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
354
461
|
* Checks if the relation is dirty.
|
|
355
462
|
*/
|
|
356
463
|
get isDirty(): boolean {
|
|
464
|
+
this.ensureEntry()
|
|
357
465
|
const relation = this.store.getRelation(
|
|
358
466
|
this.entityType,
|
|
359
467
|
this.entityId,
|
|
@@ -361,11 +469,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
361
469
|
)
|
|
362
470
|
if (!relation) return false
|
|
363
471
|
|
|
364
|
-
return (
|
|
365
|
-
relation.currentId !== relation.serverId ||
|
|
366
|
-
relation.state !== relation.serverState ||
|
|
367
|
-
Object.keys(relation.placeholderData).length > 0
|
|
368
|
-
)
|
|
472
|
+
return this.isLocallyDirty(relation)
|
|
369
473
|
}
|
|
370
474
|
|
|
371
475
|
/**
|
|
@@ -374,11 +478,15 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
374
478
|
* Accessible via proxy as `$create()`.
|
|
375
479
|
*/
|
|
376
480
|
create(data?: Partial<TEntity>): string {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
481
|
+
// One gesture = one undo entry: the pre-create and the connect are journaled
|
|
482
|
+
// together so undo removes (and redo re-creates) the created target.
|
|
483
|
+
return this.store.transaction(() => {
|
|
484
|
+
const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
|
|
485
|
+
this.dispatcher.dispatch(
|
|
486
|
+
connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
|
|
487
|
+
)
|
|
488
|
+
return tempId
|
|
489
|
+
})
|
|
382
490
|
}
|
|
383
491
|
|
|
384
492
|
/**
|
|
@@ -574,3 +682,28 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
574
682
|
}
|
|
575
683
|
|
|
576
684
|
}
|
|
685
|
+
|
|
686
|
+
type EmbeddedRelatedReference =
|
|
687
|
+
| { kind: 'absent' }
|
|
688
|
+
| { kind: 'null' }
|
|
689
|
+
| { kind: 'connected'; id: string }
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Reads an embedded has-one value while preserving explicit null.
|
|
693
|
+
*/
|
|
694
|
+
function readEmbeddedRelatedReference(embedded: unknown): EmbeddedRelatedReference {
|
|
695
|
+
if (embedded === undefined) return { kind: 'absent' }
|
|
696
|
+
if (embedded === null) return { kind: 'null' }
|
|
697
|
+
if (typeof embedded !== 'object' || !('id' in embedded)) return { kind: 'absent' }
|
|
698
|
+
const id = embedded.id
|
|
699
|
+
return typeof id === 'string' ? { kind: 'connected', id } : { kind: 'absent' }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Extracts the related entity id from an embedded has-one object, or null when
|
|
704
|
+
* the value is not an object with a string id.
|
|
705
|
+
*/
|
|
706
|
+
function extractRelatedId(embedded: unknown): string | null {
|
|
707
|
+
const reference = readEmbeddedRelatedReference(embedded)
|
|
708
|
+
return reference.kind === 'connected' ? reference.id : null
|
|
709
|
+
}
|