@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
|
@@ -44,21 +44,76 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
44
44
|
* (the first time a child is added to one of its has-many relations). Lazily minted.
|
|
45
45
|
*/
|
|
46
46
|
private materializedId: string | null = null
|
|
47
|
+
private readonly pendingEventSubscriptions = new Set<(entityId: string) => void>()
|
|
48
|
+
private pendingRelationUnsubscribe: Unsubscribe | null = null
|
|
47
49
|
|
|
48
50
|
private constructor(
|
|
49
51
|
private readonly parentEntityType: string,
|
|
50
|
-
private readonly
|
|
52
|
+
private readonly sourceParentEntityId: string,
|
|
51
53
|
private readonly fieldName: string,
|
|
52
54
|
private readonly targetType: string,
|
|
53
55
|
private readonly store: SnapshotStore,
|
|
54
56
|
private readonly dispatcher: ActionDispatcher,
|
|
55
57
|
private readonly schema: SchemaRegistry | null,
|
|
56
58
|
brands?: Set<symbol>,
|
|
59
|
+
// Segments from the query root down to the relation this placeholder stands in
|
|
60
|
+
// for, so its field refs carry the same `fullPath` a connected entity would.
|
|
61
|
+
private readonly relationPath: readonly string[] = [fieldName],
|
|
57
62
|
) {
|
|
58
63
|
this.__brands = brands
|
|
59
64
|
this.placeholderId = generatePlaceholderId()
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
private get parentEntityId(): string {
|
|
68
|
+
return this.store.resolveEntityId(this.parentEntityType, this.sourceParentEntityId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private get currentEntityId(): string | null {
|
|
72
|
+
const id = this.findCurrentEntityId()
|
|
73
|
+
if (id) this.activatePendingEventSubscriptions(id)
|
|
74
|
+
return id
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private findCurrentEntityId(): string | null {
|
|
78
|
+
const relationId = this.store.getRelation(
|
|
79
|
+
this.parentEntityType,
|
|
80
|
+
this.parentEntityId,
|
|
81
|
+
this.fieldName,
|
|
82
|
+
)?.currentId
|
|
83
|
+
const sourceId = relationId ?? this.materializedId
|
|
84
|
+
if (!sourceId) return null
|
|
85
|
+
const id = this.store.resolveEntityId(this.targetType, sourceId)
|
|
86
|
+
return this.store.getEntitySnapshot(this.targetType, id) ? id : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private watchPendingMaterialization(): void {
|
|
90
|
+
if (this.pendingRelationUnsubscribe) return
|
|
91
|
+
this.pendingRelationUnsubscribe = this.store.subscribeToRelation(
|
|
92
|
+
this.parentEntityType,
|
|
93
|
+
this.parentEntityId,
|
|
94
|
+
this.fieldName,
|
|
95
|
+
() => {
|
|
96
|
+
const id = this.findCurrentEntityId()
|
|
97
|
+
if (id) this.activatePendingEventSubscriptions(id)
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private activatePendingEventSubscriptions(entityId: string): void {
|
|
103
|
+
if (this.pendingEventSubscriptions.size === 0) return
|
|
104
|
+
const subscriptions = [...this.pendingEventSubscriptions]
|
|
105
|
+
this.pendingEventSubscriptions.clear()
|
|
106
|
+
this.pendingRelationUnsubscribe?.()
|
|
107
|
+
this.pendingRelationUnsubscribe = null
|
|
108
|
+
for (const subscribe of subscriptions) subscribe(entityId)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private stopWatchingMaterializationWhenIdle(): void {
|
|
112
|
+
if (this.pendingEventSubscriptions.size > 0) return
|
|
113
|
+
this.pendingRelationUnsubscribe?.()
|
|
114
|
+
this.pendingRelationUnsubscribe = null
|
|
115
|
+
}
|
|
116
|
+
|
|
62
117
|
static create<TEntity extends object = object, TSelected = TEntity>(
|
|
63
118
|
parentEntityType: string,
|
|
64
119
|
parentEntityId: string,
|
|
@@ -68,8 +123,9 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
68
123
|
dispatcher: ActionDispatcher,
|
|
69
124
|
schema?: SchemaRegistry | null,
|
|
70
125
|
brands?: Set<symbol>,
|
|
126
|
+
relationPath?: readonly string[],
|
|
71
127
|
): EntityAccessor<TEntity, TSelected> {
|
|
72
|
-
return PlaceholderHandle.wrapProxy(new PlaceholderHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands))
|
|
128
|
+
return PlaceholderHandle.wrapProxy(new PlaceholderHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands, relationPath))
|
|
73
129
|
}
|
|
74
130
|
|
|
75
131
|
static createRaw<TEntity extends object = object, TSelected = TEntity>(
|
|
@@ -81,40 +137,63 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
81
137
|
dispatcher: ActionDispatcher,
|
|
82
138
|
schema?: SchemaRegistry | null,
|
|
83
139
|
brands?: Set<symbol>,
|
|
140
|
+
relationPath?: readonly string[],
|
|
84
141
|
): PlaceholderHandle<TEntity, TSelected> {
|
|
85
|
-
return new PlaceholderHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands)
|
|
142
|
+
return new PlaceholderHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands, relationPath)
|
|
86
143
|
}
|
|
87
144
|
|
|
88
145
|
static wrapProxy<TEntity extends object, TSelected>(handle: PlaceholderHandle<TEntity, TSelected>): EntityAccessor<TEntity, TSelected> {
|
|
89
146
|
return createHandleProxy<PlaceholderHandle<TEntity, TSelected>, EntityAccessor<TEntity, TSelected>>(handle, (target) => target.fields)
|
|
90
147
|
}
|
|
91
148
|
|
|
149
|
+
/** A placeholder carries no selection, so enumeration lists nothing beyond `id`. */
|
|
150
|
+
get selectedFieldNames(): readonly string[] {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get [FIELD_REF_META](): FieldRefMeta {
|
|
155
|
+
return {
|
|
156
|
+
entityType: this.targetType,
|
|
157
|
+
entityId: this.id,
|
|
158
|
+
path: [],
|
|
159
|
+
fieldName: '',
|
|
160
|
+
isArray: false,
|
|
161
|
+
isRelation: false,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
92
165
|
/**
|
|
93
166
|
* Gets the placeholder ID.
|
|
94
167
|
*/
|
|
95
168
|
get id(): string {
|
|
96
|
-
return this.placeholderId
|
|
169
|
+
return this.currentEntityId ?? this.placeholderId
|
|
97
170
|
}
|
|
98
171
|
|
|
99
172
|
/**
|
|
100
173
|
* Gets placeholder data from the relation state.
|
|
101
174
|
*/
|
|
102
175
|
get data(): TSelected | null {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
this.
|
|
106
|
-
this.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
176
|
+
const entityId = this.currentEntityId
|
|
177
|
+
const data = entityId
|
|
178
|
+
? this.store.getPresentationSnapshot<TEntity>(this.targetType, entityId)?.data ?? null
|
|
179
|
+
: this.store.getRelation(
|
|
180
|
+
this.parentEntityType,
|
|
181
|
+
this.parentEntityId,
|
|
182
|
+
this.fieldName,
|
|
183
|
+
)?.placeholderData ?? null
|
|
184
|
+
if (data && Object.keys(data).length === 0) return null
|
|
185
|
+
return data as TSelected | null
|
|
112
186
|
}
|
|
113
187
|
|
|
114
188
|
/**
|
|
115
189
|
* Placeholder is dirty if it has any data.
|
|
116
190
|
*/
|
|
117
191
|
get isDirty(): boolean {
|
|
192
|
+
const entityId = this.currentEntityId
|
|
193
|
+
if (entityId) {
|
|
194
|
+
return this.store.getDirtyFields(this.targetType, entityId).length > 0 ||
|
|
195
|
+
this.store.getDirtyRelations(this.targetType, entityId).length > 0
|
|
196
|
+
}
|
|
118
197
|
const relation = this.store.getRelation(
|
|
119
198
|
this.parentEntityType,
|
|
120
199
|
this.parentEntityId,
|
|
@@ -127,21 +206,24 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
127
206
|
* Placeholder entities are never being persisted.
|
|
128
207
|
*/
|
|
129
208
|
get isPersisting(): boolean {
|
|
130
|
-
|
|
209
|
+
const entityId = this.currentEntityId
|
|
210
|
+
return entityId ? this.store.isPersisting(this.targetType, entityId) : false
|
|
131
211
|
}
|
|
132
212
|
|
|
133
213
|
/**
|
|
134
214
|
* Placeholder entities are always new (not yet persisted).
|
|
135
215
|
*/
|
|
136
|
-
get persistedId(): null {
|
|
137
|
-
|
|
216
|
+
get persistedId(): string | null {
|
|
217
|
+
const entityId = this.currentEntityId
|
|
218
|
+
return entityId ? this.store.getPersistedId(this.targetType, entityId) : null
|
|
138
219
|
}
|
|
139
220
|
|
|
140
221
|
/**
|
|
141
222
|
* Placeholder entities are always new.
|
|
142
223
|
*/
|
|
143
224
|
get isNew(): boolean {
|
|
144
|
-
|
|
225
|
+
const entityId = this.currentEntityId
|
|
226
|
+
return entityId ? this.store.isNewEntity(this.targetType, entityId) : true
|
|
145
227
|
}
|
|
146
228
|
|
|
147
229
|
/**
|
|
@@ -160,7 +242,7 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
160
242
|
*/
|
|
161
243
|
private getMaterializedId(): string {
|
|
162
244
|
if (!this.materializedId) this.materializedId = generateTempId()
|
|
163
|
-
return this.materializedId
|
|
245
|
+
return this.store.resolveEntityId(this.targetType, this.materializedId)
|
|
164
246
|
}
|
|
165
247
|
|
|
166
248
|
/**
|
|
@@ -213,6 +295,9 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
213
295
|
this.dispatcher,
|
|
214
296
|
this.schema,
|
|
215
297
|
this.__brands,
|
|
298
|
+
undefined,
|
|
299
|
+
undefined,
|
|
300
|
+
[...this.relationPath, fieldName],
|
|
216
301
|
)
|
|
217
302
|
// Intercept add() to lazily promote the placeholder to a real entity first — a child
|
|
218
303
|
// can't belong to a parent that doesn't exist. Reads pass through untouched.
|
|
@@ -244,14 +329,19 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
244
329
|
get [FIELD_REF_META](): FieldRefMeta {
|
|
245
330
|
return {
|
|
246
331
|
entityType: self.targetType,
|
|
247
|
-
entityId: self.
|
|
332
|
+
entityId: self.id,
|
|
248
333
|
path: [fieldName],
|
|
249
334
|
fieldName,
|
|
250
335
|
isArray: false,
|
|
251
336
|
isRelation: false,
|
|
337
|
+
fullPath: [...self.relationPath, fieldName],
|
|
252
338
|
}
|
|
253
339
|
},
|
|
254
340
|
get value(): unknown {
|
|
341
|
+
const entityId = self.currentEntityId
|
|
342
|
+
if (entityId) {
|
|
343
|
+
return self.store.getPresentationSnapshot<Record<string, unknown>>(self.targetType, entityId)?.data[fieldName] ?? null
|
|
344
|
+
}
|
|
255
345
|
const relation = self.store.getRelation(
|
|
256
346
|
self.parentEntityType,
|
|
257
347
|
self.parentEntityId,
|
|
@@ -263,6 +353,10 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
263
353
|
return null
|
|
264
354
|
},
|
|
265
355
|
get isDirty(): boolean {
|
|
356
|
+
const entityId = self.currentEntityId
|
|
357
|
+
if (entityId) {
|
|
358
|
+
return self.store.getDirtyFields(self.targetType, entityId).includes(fieldName)
|
|
359
|
+
}
|
|
266
360
|
const relation = self.store.getRelation(
|
|
267
361
|
self.parentEntityType,
|
|
268
362
|
self.parentEntityId,
|
|
@@ -271,6 +365,16 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
271
365
|
return fieldName in (relation?.placeholderData ?? {})
|
|
272
366
|
},
|
|
273
367
|
setValue: (value: unknown): void => {
|
|
368
|
+
if (self.currentEntityId) {
|
|
369
|
+
self.dispatcher.dispatch({
|
|
370
|
+
type: 'SET_FIELD',
|
|
371
|
+
entityType: self.targetType,
|
|
372
|
+
entityId: self.id,
|
|
373
|
+
fieldPath: [fieldName],
|
|
374
|
+
value,
|
|
375
|
+
})
|
|
376
|
+
return
|
|
377
|
+
}
|
|
274
378
|
self.dispatcher.dispatch({
|
|
275
379
|
type: 'SET_PLACEHOLDER_DATA',
|
|
276
380
|
entityType: self.parentEntityType,
|
|
@@ -282,6 +386,10 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
282
386
|
},
|
|
283
387
|
get inputProps() {
|
|
284
388
|
const getValue = () => {
|
|
389
|
+
const entityId = self.currentEntityId
|
|
390
|
+
if (entityId) {
|
|
391
|
+
return self.store.getPresentationSnapshot<Record<string, unknown>>(self.targetType, entityId)?.data[fieldName] ?? null
|
|
392
|
+
}
|
|
285
393
|
const relation = self.store.getRelation(
|
|
286
394
|
self.parentEntityType,
|
|
287
395
|
self.parentEntityId,
|
|
@@ -290,6 +398,16 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
290
398
|
return relation?.placeholderData[fieldName] ?? null
|
|
291
399
|
}
|
|
292
400
|
const setValue = (value: unknown) => {
|
|
401
|
+
if (self.currentEntityId) {
|
|
402
|
+
self.dispatcher.dispatch({
|
|
403
|
+
type: 'SET_FIELD',
|
|
404
|
+
entityType: self.targetType,
|
|
405
|
+
entityId: self.id,
|
|
406
|
+
fieldPath: [fieldName],
|
|
407
|
+
value,
|
|
408
|
+
})
|
|
409
|
+
return
|
|
410
|
+
}
|
|
293
411
|
self.dispatcher.dispatch({
|
|
294
412
|
type: 'SET_PLACEHOLDER_DATA',
|
|
295
413
|
entityType: self.parentEntityType,
|
|
@@ -347,25 +465,28 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
347
465
|
*/
|
|
348
466
|
private createPlaceholderHasOneFieldHandle(fieldName: string, innerTargetType: string): unknown {
|
|
349
467
|
const self = this
|
|
468
|
+
const nestedRelationPath = [...this.relationPath, fieldName]
|
|
350
469
|
const nestedPlaceholderRaw = PlaceholderHandle.createRaw(
|
|
351
470
|
this.targetType,
|
|
352
|
-
this.
|
|
471
|
+
this.id,
|
|
353
472
|
fieldName,
|
|
354
473
|
innerTargetType,
|
|
355
474
|
this.store,
|
|
356
475
|
this.dispatcher,
|
|
357
476
|
this.schema,
|
|
358
477
|
this.__brands,
|
|
478
|
+
nestedRelationPath,
|
|
359
479
|
)
|
|
360
480
|
const nestedPlaceholderProxy = PlaceholderHandle.wrapProxy(nestedPlaceholderRaw)
|
|
361
481
|
const fieldRefMeta: FieldRefMeta = {
|
|
362
482
|
entityType: self.targetType,
|
|
363
|
-
entityId: self.
|
|
483
|
+
entityId: self.id,
|
|
364
484
|
path: [fieldName],
|
|
365
485
|
fieldName,
|
|
366
486
|
isArray: false,
|
|
367
487
|
isRelation: true,
|
|
368
488
|
targetType: innerTargetType,
|
|
489
|
+
fullPath: nestedRelationPath,
|
|
369
490
|
}
|
|
370
491
|
const noopUnsubscribe: Unsubscribe = () => {}
|
|
371
492
|
|
|
@@ -464,40 +585,67 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
464
585
|
}
|
|
465
586
|
|
|
466
587
|
// ==================== Event Subscriptions ====================
|
|
467
|
-
// Placeholder entities don't fire events - these are no-ops that return dummy unsubscribe functions
|
|
468
588
|
|
|
469
589
|
/**
|
|
470
|
-
*
|
|
590
|
+
* Activates the subscription when the placeholder materializes.
|
|
471
591
|
*/
|
|
472
592
|
on<E extends AfterEventTypes>(
|
|
473
|
-
|
|
474
|
-
|
|
593
|
+
eventType: E,
|
|
594
|
+
listener: EventListener<EventTypeMap[E]>,
|
|
475
595
|
): Unsubscribe {
|
|
476
|
-
|
|
596
|
+
const emitter = this.dispatcher.getEventEmitter()
|
|
597
|
+
if (this.currentEntityId) {
|
|
598
|
+
return emitter.onEntity(eventType, this.targetType, this.id, listener)
|
|
599
|
+
}
|
|
600
|
+
let unsubscribe: Unsubscribe | null = null
|
|
601
|
+
const subscribe = (entityId: string): void => {
|
|
602
|
+
unsubscribe = emitter.onEntity(eventType, this.targetType, entityId, listener)
|
|
603
|
+
}
|
|
604
|
+
this.pendingEventSubscriptions.add(subscribe)
|
|
605
|
+
this.watchPendingMaterialization()
|
|
606
|
+
return () => {
|
|
607
|
+
this.pendingEventSubscriptions.delete(subscribe)
|
|
608
|
+
this.stopWatchingMaterializationWhenIdle()
|
|
609
|
+
unsubscribe?.()
|
|
610
|
+
}
|
|
477
611
|
}
|
|
478
612
|
|
|
479
613
|
/**
|
|
480
|
-
*
|
|
614
|
+
* Activates the interceptor when the placeholder materializes.
|
|
481
615
|
*/
|
|
482
616
|
intercept<E extends BeforeEventTypes>(
|
|
483
|
-
|
|
484
|
-
|
|
617
|
+
eventType: E,
|
|
618
|
+
interceptor: Interceptor<EventTypeMap[E]>,
|
|
485
619
|
): Unsubscribe {
|
|
486
|
-
|
|
620
|
+
const emitter = this.dispatcher.getEventEmitter()
|
|
621
|
+
if (this.currentEntityId) {
|
|
622
|
+
return emitter.interceptEntity(eventType, this.targetType, this.id, interceptor)
|
|
623
|
+
}
|
|
624
|
+
let unsubscribe: Unsubscribe | null = null
|
|
625
|
+
const subscribe = (entityId: string): void => {
|
|
626
|
+
unsubscribe = emitter.interceptEntity(eventType, this.targetType, entityId, interceptor)
|
|
627
|
+
}
|
|
628
|
+
this.pendingEventSubscriptions.add(subscribe)
|
|
629
|
+
this.watchPendingMaterialization()
|
|
630
|
+
return () => {
|
|
631
|
+
this.pendingEventSubscriptions.delete(subscribe)
|
|
632
|
+
this.stopWatchingMaterializationWhenIdle()
|
|
633
|
+
unsubscribe?.()
|
|
634
|
+
}
|
|
487
635
|
}
|
|
488
636
|
|
|
489
637
|
/**
|
|
490
|
-
*
|
|
638
|
+
* Subscribes to persistence after materialization.
|
|
491
639
|
*/
|
|
492
|
-
onPersisted(
|
|
493
|
-
return ()
|
|
640
|
+
onPersisted(listener: EventListener<EntityPersistedEvent>): Unsubscribe {
|
|
641
|
+
return this.on('entity:persisted', listener)
|
|
494
642
|
}
|
|
495
643
|
|
|
496
644
|
/**
|
|
497
|
-
*
|
|
645
|
+
* Intercepts persistence after materialization.
|
|
498
646
|
*/
|
|
499
|
-
interceptPersisting(
|
|
500
|
-
return ()
|
|
647
|
+
interceptPersisting(interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe {
|
|
648
|
+
return this.intercept('entity:persisting', interceptor)
|
|
501
649
|
}
|
|
502
650
|
|
|
503
651
|
}
|
package/src/handles/index.ts
CHANGED
|
@@ -27,6 +27,9 @@ export {
|
|
|
27
27
|
type HasManyAccessor,
|
|
28
28
|
type HasOneAccessor,
|
|
29
29
|
type EntityAccessor,
|
|
30
|
+
// Selection-erased views (parameter positions that accept any selection)
|
|
31
|
+
type EntityRefLike,
|
|
32
|
+
type EntityAccessorLike,
|
|
30
33
|
type Unsubscribe,
|
|
31
34
|
// Type extraction helpers
|
|
32
35
|
type ExtractHasOneEntityName,
|
|
@@ -24,6 +24,8 @@ const HANDLE_PASSTHROUGH_PROPERTIES = new Set<string | symbol>([
|
|
|
24
24
|
FIELD_REF_META,
|
|
25
25
|
])
|
|
26
26
|
|
|
27
|
+
const NO_FIELD_NAMES = (): readonly string[] => []
|
|
28
|
+
|
|
27
29
|
/**
|
|
28
30
|
* Creates a proxy around a handle that supports direct field access.
|
|
29
31
|
*
|
|
@@ -33,14 +35,23 @@ const HANDLE_PASSTHROUGH_PROPERTIES = new Set<string | symbol>([
|
|
|
33
35
|
* 3. `$xxx` → strip `$`, access handle property
|
|
34
36
|
* 4. Everything else → field access (returns FieldHandle/HasOneHandle/HasManyListHandle)
|
|
35
37
|
*
|
|
38
|
+
* Enumeration (`for…in`, `Object.keys`) sees `id` plus the selected field names, never
|
|
39
|
+
* the handle's own instance fields: a generic walker (React's dev prop-diff logger,
|
|
40
|
+
* a debugger) reading those through the `get` trap would hit field access and throw
|
|
41
|
+
* `UnfetchedFieldError` for `store`, `dispatcher`, ...
|
|
42
|
+
*
|
|
36
43
|
* @param handle - The handle instance to wrap
|
|
37
44
|
* @param getFields - Function to get the fields object from the handle
|
|
45
|
+
* @param getFieldNames - Function to list the selected field names (enumeration only)
|
|
38
46
|
* @returns Proxied handle with direct field access support
|
|
39
47
|
*/
|
|
40
48
|
export function createHandleProxy<T extends object, TResult = T>(
|
|
41
49
|
handle: T,
|
|
42
50
|
getFields: (target: T) => object,
|
|
51
|
+
getFieldNames: (target: T) => readonly string[] = NO_FIELD_NAMES,
|
|
43
52
|
): TResult {
|
|
53
|
+
const ownKeys = (target: T): string[] => ['id', ...getFieldNames(target).filter(name => name !== 'id')]
|
|
54
|
+
|
|
44
55
|
// The proxy adds $ alias support and field access, making the handle satisfy the public type TResult at runtime
|
|
45
56
|
return new Proxy(handle, {
|
|
46
57
|
get(target, prop, _receiver) {
|
|
@@ -76,6 +87,22 @@ export function createHandleProxy<T extends object, TResult = T>(
|
|
|
76
87
|
}
|
|
77
88
|
return Reflect.has(target, prop)
|
|
78
89
|
},
|
|
90
|
+
|
|
91
|
+
ownKeys(target) {
|
|
92
|
+
return ownKeys(target)
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
96
|
+
if (typeof prop !== 'string' || !ownKeys(target).includes(prop)) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
const value = prop === 'id'
|
|
100
|
+
? Reflect.get(target, prop, target)
|
|
101
|
+
: (getFields(target) as Record<string, unknown>)[prop]
|
|
102
|
+
// Configurable: the target's own properties are configurable too, so the
|
|
103
|
+
// proxy invariants allow a descriptor that differs from the target's.
|
|
104
|
+
return { value, enumerable: true, configurable: true, writable: false }
|
|
105
|
+
},
|
|
79
106
|
}) as unknown as TResult
|
|
80
107
|
}
|
|
81
108
|
|
package/src/handles/types.ts
CHANGED
|
@@ -364,6 +364,45 @@ export type EntityAccessor<
|
|
|
364
364
|
readonly $data: TSelected | null
|
|
365
365
|
} & EntityFieldsAccessor<TEntity, TSelected, TSchema>
|
|
366
366
|
|
|
367
|
+
// ============================================================================
|
|
368
|
+
// SELECTION-ERASED VIEWS
|
|
369
|
+
// ============================================================================
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Selection-erased pointer to an entity of type `TEntity`.
|
|
373
|
+
*
|
|
374
|
+
* `EntityRef<TEntity>` means "an entity ref whose selection is the *whole*
|
|
375
|
+
* entity", so a ref carrying a narrower selection is (correctly) not assignable
|
|
376
|
+
* to it, and neither is a ref whose selection is still a free type parameter.
|
|
377
|
+
* Use `EntityRefLike` in parameter positions that only need entity *identity*
|
|
378
|
+
* and the selection-independent API (`id`, `$isNew`, `$errors`, `$on`, ...) and
|
|
379
|
+
* accept any selection, including one not yet known:
|
|
380
|
+
*
|
|
381
|
+
* ```ts
|
|
382
|
+
* // accepts EntityRef<Website, anything>
|
|
383
|
+
* function trackVisit(website: EntityRefLike<Website>): void { ... }
|
|
384
|
+
* ```
|
|
385
|
+
*
|
|
386
|
+
* It deliberately drops the field proxy: erasing the selection must never grant
|
|
387
|
+
* field access, because reading an unfetched field throws `UnfetchedFieldError`
|
|
388
|
+
* at runtime. To read fields, take an `EntityRef<TEntity, TSelected>` instead.
|
|
389
|
+
*
|
|
390
|
+
* `TEntityName` is erased to `string`, so refs produced with a literal entity
|
|
391
|
+
* name (hooks) and refs produced without one (createComponent) both fit.
|
|
392
|
+
*/
|
|
393
|
+
export type EntityRefLike<TEntity> = EntityRefInterface<TEntity, unknown>
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Selection-erased live accessor for an entity of type `TEntity`.
|
|
397
|
+
*
|
|
398
|
+
* Same erasure as {@link EntityRefLike}, but only satisfied by *live* accessors
|
|
399
|
+
* (`EntityAccessor` and `HasOneAccessor`), not by pointer-only refs. Like
|
|
400
|
+
* `EntityRefLike` it exposes no field proxy — see the note there.
|
|
401
|
+
*/
|
|
402
|
+
export type EntityAccessorLike<TEntity> = EntityRefLike<TEntity> & {
|
|
403
|
+
readonly $data: unknown
|
|
404
|
+
}
|
|
405
|
+
|
|
367
406
|
// ============================================================================
|
|
368
407
|
// ENTITY FIELDS MAPPING TYPES
|
|
369
408
|
// ============================================================================
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,9 @@ export type {
|
|
|
67
67
|
HasManyAccessor,
|
|
68
68
|
HasOneAccessor,
|
|
69
69
|
EntityAccessor,
|
|
70
|
+
// Selection-erased views (parameter positions that accept any selection)
|
|
71
|
+
EntityRefLike,
|
|
72
|
+
EntityAccessorLike,
|
|
70
73
|
// Type extraction helpers
|
|
71
74
|
ExtractHasOneEntityName,
|
|
72
75
|
ExtractHasManyEntityName,
|
|
@@ -237,13 +240,10 @@ export { MutationCollector, ContemberSchemaMutationAdapter, type SchemaNames } f
|
|
|
237
240
|
export type { MutationSchemaProvider, EntityMutationResult } from './contember/index.js'
|
|
238
241
|
|
|
239
242
|
// Undo/Redo
|
|
240
|
-
export { UndoManager } from './undo/index.js'
|
|
243
|
+
export { UndoManager, UnrecordedWriteError } from './undo/index.js'
|
|
241
244
|
export type {
|
|
242
245
|
UndoManagerConfig,
|
|
243
246
|
UndoState,
|
|
244
|
-
UndoEntry,
|
|
245
|
-
PartialStoreSnapshot,
|
|
246
|
-
StoreAffectedKeys,
|
|
247
247
|
} from './undo/index.js'
|
|
248
248
|
|
|
249
249
|
// Store types (for undo)
|