@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.
Files changed (121) hide show
  1. package/dist/adapter/MockAdapter.d.ts +4 -1
  2. package/dist/adapter/MockAdapter.d.ts.map +1 -1
  3. package/dist/core/ActionDispatcher.d.ts +4 -1
  4. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  5. package/dist/core/actions.d.ts +7 -1
  6. package/dist/core/actions.d.ts.map +1 -1
  7. package/dist/events/EventEmitter.d.ts +13 -1
  8. package/dist/events/EventEmitter.d.ts.map +1 -1
  9. package/dist/handles/BaseHandle.d.ts +14 -3
  10. package/dist/handles/BaseHandle.d.ts.map +1 -1
  11. package/dist/handles/EntityHandle.d.ts +14 -5
  12. package/dist/handles/EntityHandle.d.ts.map +1 -1
  13. package/dist/handles/FieldHandle.d.ts +11 -4
  14. package/dist/handles/FieldHandle.d.ts.map +1 -1
  15. package/dist/handles/HasManyListHandle.d.ts +36 -2
  16. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  17. package/dist/handles/HasOneHandle.d.ts +52 -9
  18. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  19. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  20. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  21. package/dist/handles/index.d.ts +1 -1
  22. package/dist/handles/index.d.ts.map +1 -1
  23. package/dist/handles/proxyFactory.d.ts +7 -1
  24. package/dist/handles/proxyFactory.d.ts.map +1 -1
  25. package/dist/handles/types.d.ts +33 -0
  26. package/dist/handles/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/persistence/BatchPersister.d.ts +53 -37
  30. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  31. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  32. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  33. package/dist/persistence/MutationCollector.d.ts +106 -3
  34. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  35. package/dist/persistence/PersistExecution.d.ts +26 -0
  36. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  37. package/dist/persistence/types.d.ts +8 -2
  38. package/dist/persistence/types.d.ts.map +1 -1
  39. package/dist/store/EntityMetaStore.d.ts +32 -10
  40. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  41. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  42. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  43. package/dist/store/ErrorStore.d.ts +3 -2
  44. package/dist/store/ErrorStore.d.ts.map +1 -1
  45. package/dist/store/HasManyStore.d.ts +214 -0
  46. package/dist/store/HasManyStore.d.ts.map +1 -0
  47. package/dist/store/HasOneStore.d.ts +159 -0
  48. package/dist/store/HasOneStore.d.ts.map +1 -0
  49. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  50. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  51. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  52. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  53. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  54. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  55. package/dist/store/RelationStore.d.ts +74 -137
  56. package/dist/store/RelationStore.d.ts.map +1 -1
  57. package/dist/store/RootRegistry.d.ts +25 -4
  58. package/dist/store/RootRegistry.d.ts.map +1 -1
  59. package/dist/store/SnapshotStore.d.ts +137 -13
  60. package/dist/store/SnapshotStore.d.ts.map +1 -1
  61. package/dist/store/SubscriptionManager.d.ts +63 -14
  62. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  63. package/dist/store/TouchedStore.d.ts +3 -2
  64. package/dist/store/TouchedStore.d.ts.map +1 -1
  65. package/dist/store/relationKey.d.ts +15 -0
  66. package/dist/store/relationKey.d.ts.map +1 -0
  67. package/dist/undo/UndoJournal.d.ts +133 -0
  68. package/dist/undo/UndoJournal.d.ts.map +1 -0
  69. package/dist/undo/UndoManager.d.ts +25 -82
  70. package/dist/undo/UndoManager.d.ts.map +1 -1
  71. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  72. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  73. package/dist/undo/index.d.ts +3 -1
  74. package/dist/undo/index.d.ts.map +1 -1
  75. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  76. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  77. package/dist/undo/types.d.ts +0 -48
  78. package/dist/undo/types.d.ts.map +1 -1
  79. package/package.json +2 -2
  80. package/src/adapter/MockAdapter.ts +15 -5
  81. package/src/core/ActionDispatcher.ts +56 -36
  82. package/src/core/actions.ts +8 -1
  83. package/src/events/EventEmitter.ts +78 -24
  84. package/src/handles/BaseHandle.ts +19 -2
  85. package/src/handles/EntityHandle.ts +48 -17
  86. package/src/handles/FieldHandle.ts +36 -10
  87. package/src/handles/HasManyListHandle.ts +120 -16
  88. package/src/handles/HasOneHandle.ts +177 -44
  89. package/src/handles/PlaceholderHandle.ts +184 -36
  90. package/src/handles/index.ts +3 -0
  91. package/src/handles/proxyFactory.ts +27 -0
  92. package/src/handles/types.ts +39 -0
  93. package/src/index.ts +4 -4
  94. package/src/persistence/BatchPersister.ts +810 -446
  95. package/src/persistence/ChangeRegistry.ts +27 -3
  96. package/src/persistence/MutationCollector.ts +265 -31
  97. package/src/persistence/PersistExecution.ts +217 -0
  98. package/src/persistence/types.ts +8 -2
  99. package/src/store/EntityMetaStore.ts +89 -45
  100. package/src/store/EntitySnapshotStore.ts +102 -19
  101. package/src/store/ErrorStore.ts +6 -5
  102. package/src/store/HasManyStore.ts +810 -0
  103. package/src/store/HasOneStore.ts +454 -0
  104. package/src/store/ReachabilityAnalyzer.ts +37 -0
  105. package/src/store/RekeyOrchestrator.ts +123 -0
  106. package/src/store/RelationEdgeIndex.ts +79 -0
  107. package/src/store/RelationStore.ts +176 -725
  108. package/src/store/RootRegistry.ts +50 -8
  109. package/src/store/SnapshotStore.ts +572 -101
  110. package/src/store/SubscriptionManager.ts +122 -75
  111. package/src/store/TouchedStore.ts +5 -2
  112. package/src/store/relationKey.ts +20 -0
  113. package/src/undo/UndoJournal.ts +258 -0
  114. package/src/undo/UndoManager.ts +151 -281
  115. package/src/undo/UnrecordedWriteError.ts +23 -0
  116. package/src/undo/index.ts +8 -7
  117. package/src/undo/rekeyJournalEntry.ts +145 -0
  118. package/src/undo/types.ts +0 -58
  119. package/dist/core/actionClassification.d.ts +0 -48
  120. package/dist/core/actionClassification.d.ts.map +0 -1
  121. 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 parentEntityId: string,
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 relation = this.store.getRelation(
104
- this.parentEntityType,
105
- this.parentEntityId,
106
- this.fieldName,
107
- )
108
- if (!relation || Object.keys(relation.placeholderData).length === 0) {
109
- return null
110
- }
111
- return relation.placeholderData as TSelected
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
- return false
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
- return null
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
- return true
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.placeholderId,
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.placeholderId,
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.placeholderId,
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
- * No-op for placeholder entities.
590
+ * Activates the subscription when the placeholder materializes.
471
591
  */
472
592
  on<E extends AfterEventTypes>(
473
- _eventType: E,
474
- _listener: EventListener<EventTypeMap[E]>,
593
+ eventType: E,
594
+ listener: EventListener<EventTypeMap[E]>,
475
595
  ): Unsubscribe {
476
- return () => {}
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
- * No-op for placeholder entities.
614
+ * Activates the interceptor when the placeholder materializes.
481
615
  */
482
616
  intercept<E extends BeforeEventTypes>(
483
- _eventType: E,
484
- _interceptor: Interceptor<EventTypeMap[E]>,
617
+ eventType: E,
618
+ interceptor: Interceptor<EventTypeMap[E]>,
485
619
  ): Unsubscribe {
486
- return () => {}
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
- * No-op for placeholder entities.
638
+ * Subscribes to persistence after materialization.
491
639
  */
492
- onPersisted(_listener: EventListener<EntityPersistedEvent>): Unsubscribe {
493
- return () => {}
640
+ onPersisted(listener: EventListener<EntityPersistedEvent>): Unsubscribe {
641
+ return this.on('entity:persisted', listener)
494
642
  }
495
643
 
496
644
  /**
497
- * No-op for placeholder entities.
645
+ * Intercepts persistence after materialization.
498
646
  */
499
- interceptPersisting(_interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe {
500
- return () => {}
647
+ interceptPersisting(interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe {
648
+ return this.intercept('entity:persisting', interceptor)
501
649
  }
502
650
 
503
651
  }
@@ -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
 
@@ -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)