@contember/bindx 0.1.0

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 (234) hide show
  1. package/dist/adapter/ContemberAdapter.d.ts +40 -0
  2. package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
  3. package/dist/adapter/MockAdapter.d.ts +81 -0
  4. package/dist/adapter/MockAdapter.d.ts.map +1 -0
  5. package/dist/adapter/MockQueryEngine.d.ts +43 -0
  6. package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
  7. package/dist/adapter/index.d.ts +5 -0
  8. package/dist/adapter/index.d.ts.map +1 -0
  9. package/dist/adapter/types.d.ts +184 -0
  10. package/dist/adapter/types.d.ts.map +1 -0
  11. package/dist/brand/ComponentBrand.d.ts +25 -0
  12. package/dist/brand/ComponentBrand.d.ts.map +1 -0
  13. package/dist/contember/index.d.ts +10 -0
  14. package/dist/contember/index.d.ts.map +1 -0
  15. package/dist/core/ActionDispatcher.d.ts +64 -0
  16. package/dist/core/ActionDispatcher.d.ts.map +1 -0
  17. package/dist/core/Disposable.d.ts +57 -0
  18. package/dist/core/Disposable.d.ts.map +1 -0
  19. package/dist/core/EntityLoader.d.ts +80 -0
  20. package/dist/core/EntityLoader.d.ts.map +1 -0
  21. package/dist/core/PersistenceManager.d.ts +126 -0
  22. package/dist/core/PersistenceManager.d.ts.map +1 -0
  23. package/dist/core/SelectionResolver.d.ts +21 -0
  24. package/dist/core/SelectionResolver.d.ts.map +1 -0
  25. package/dist/core/actionClassification.d.ts +48 -0
  26. package/dist/core/actionClassification.d.ts.map +1 -0
  27. package/dist/core/actions.d.ts +301 -0
  28. package/dist/core/actions.d.ts.map +1 -0
  29. package/dist/core/index.d.ts +4 -0
  30. package/dist/core/index.d.ts.map +1 -0
  31. package/dist/dataview/filterHandlers.d.ts +18 -0
  32. package/dist/dataview/filterHandlers.d.ts.map +1 -0
  33. package/dist/dataview/index.d.ts +3 -0
  34. package/dist/dataview/index.d.ts.map +1 -0
  35. package/dist/dataview/types.d.ts +164 -0
  36. package/dist/dataview/types.d.ts.map +1 -0
  37. package/dist/errors/UnfetchedFieldError.d.ts +11 -0
  38. package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
  39. package/dist/errors/errorPathResolver.d.ts +52 -0
  40. package/dist/errors/errorPathResolver.d.ts.map +1 -0
  41. package/dist/errors/index.d.ts +5 -0
  42. package/dist/errors/index.d.ts.map +1 -0
  43. package/dist/errors/pathMapper.d.ts +42 -0
  44. package/dist/errors/pathMapper.d.ts.map +1 -0
  45. package/dist/errors/types.d.ts +131 -0
  46. package/dist/errors/types.d.ts.map +1 -0
  47. package/dist/events/EventEmitter.d.ts +97 -0
  48. package/dist/events/EventEmitter.d.ts.map +1 -0
  49. package/dist/events/eventFactory.d.ts +40 -0
  50. package/dist/events/eventFactory.d.ts.map +1 -0
  51. package/dist/events/index.d.ts +15 -0
  52. package/dist/events/index.d.ts.map +1 -0
  53. package/dist/events/types.d.ts +264 -0
  54. package/dist/events/types.d.ts.map +1 -0
  55. package/dist/handles/BaseHandle.d.ts +64 -0
  56. package/dist/handles/BaseHandle.d.ts.map +1 -0
  57. package/dist/handles/EntityHandle.d.ts +208 -0
  58. package/dist/handles/EntityHandle.d.ts.map +1 -0
  59. package/dist/handles/FieldHandle.d.ts +111 -0
  60. package/dist/handles/FieldHandle.d.ts.map +1 -0
  61. package/dist/handles/HasManyListHandle.d.ts +166 -0
  62. package/dist/handles/HasManyListHandle.d.ts.map +1 -0
  63. package/dist/handles/HasOneHandle.d.ts +168 -0
  64. package/dist/handles/HasOneHandle.d.ts.map +1 -0
  65. package/dist/handles/PlaceholderHandle.d.ts +117 -0
  66. package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
  67. package/dist/handles/index.d.ts +8 -0
  68. package/dist/handles/index.d.ts.map +1 -0
  69. package/dist/handles/proxyFactory.d.ts +66 -0
  70. package/dist/handles/proxyFactory.d.ts.map +1 -0
  71. package/dist/handles/types.d.ts +430 -0
  72. package/dist/handles/types.d.ts.map +1 -0
  73. package/dist/index.d.ts +53 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/internal.d.ts +11 -0
  76. package/dist/internal.d.ts.map +1 -0
  77. package/dist/persistence/BatchPersister.d.ts +142 -0
  78. package/dist/persistence/BatchPersister.d.ts.map +1 -0
  79. package/dist/persistence/ChangeRegistry.d.ts +87 -0
  80. package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
  81. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
  82. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
  83. package/dist/persistence/MutationCollector.d.ts +96 -0
  84. package/dist/persistence/MutationCollector.d.ts.map +1 -0
  85. package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
  86. package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
  87. package/dist/persistence/index.d.ts +7 -0
  88. package/dist/persistence/index.d.ts.map +1 -0
  89. package/dist/persistence/types.d.ts +189 -0
  90. package/dist/persistence/types.d.ts.map +1 -0
  91. package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
  92. package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
  93. package/dist/roles/index.d.ts +11 -0
  94. package/dist/roles/index.d.ts.map +1 -0
  95. package/dist/roles/types.d.ts +242 -0
  96. package/dist/roles/types.d.ts.map +1 -0
  97. package/dist/schema/ContemberSchema.d.ts +98 -0
  98. package/dist/schema/ContemberSchema.d.ts.map +1 -0
  99. package/dist/schema/SchemaLoader.d.ts +28 -0
  100. package/dist/schema/SchemaLoader.d.ts.map +1 -0
  101. package/dist/schema/SchemaRegistry.d.ts +95 -0
  102. package/dist/schema/SchemaRegistry.d.ts.map +1 -0
  103. package/dist/schema/index.d.ts +6 -0
  104. package/dist/schema/index.d.ts.map +1 -0
  105. package/dist/schema/roles.d.ts +62 -0
  106. package/dist/schema/roles.d.ts.map +1 -0
  107. package/dist/schema/types.d.ts +179 -0
  108. package/dist/schema/types.d.ts.map +1 -0
  109. package/dist/selection/SelectionMetaCollector.d.ts +32 -0
  110. package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
  111. package/dist/selection/SelectionScope.d.ts +77 -0
  112. package/dist/selection/SelectionScope.d.ts.map +1 -0
  113. package/dist/selection/buildQuery.d.ts +45 -0
  114. package/dist/selection/buildQuery.d.ts.map +1 -0
  115. package/dist/selection/createFragment.d.ts +29 -0
  116. package/dist/selection/createFragment.d.ts.map +1 -0
  117. package/dist/selection/createSelectionBuilder.d.ts +18 -0
  118. package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
  119. package/dist/selection/index.d.ts +8 -0
  120. package/dist/selection/index.d.ts.map +1 -0
  121. package/dist/selection/queryTypes.d.ts +84 -0
  122. package/dist/selection/queryTypes.d.ts.map +1 -0
  123. package/dist/selection/types.d.ts +238 -0
  124. package/dist/selection/types.d.ts.map +1 -0
  125. package/dist/store/DirtyTracker.d.ts +26 -0
  126. package/dist/store/DirtyTracker.d.ts.map +1 -0
  127. package/dist/store/EntityMetaStore.d.ts +52 -0
  128. package/dist/store/EntityMetaStore.d.ts.map +1 -0
  129. package/dist/store/EntitySnapshotStore.d.ts +63 -0
  130. package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
  131. package/dist/store/ErrorStore.d.ts +82 -0
  132. package/dist/store/ErrorStore.d.ts.map +1 -0
  133. package/dist/store/RelationStore.d.ts +175 -0
  134. package/dist/store/RelationStore.d.ts.map +1 -0
  135. package/dist/store/SnapshotStore.d.ts +143 -0
  136. package/dist/store/SnapshotStore.d.ts.map +1 -0
  137. package/dist/store/SubscriptionManager.d.ts +90 -0
  138. package/dist/store/SubscriptionManager.d.ts.map +1 -0
  139. package/dist/store/TouchedStore.d.ts +24 -0
  140. package/dist/store/TouchedStore.d.ts.map +1 -0
  141. package/dist/store/entityId.d.ts +29 -0
  142. package/dist/store/entityId.d.ts.map +1 -0
  143. package/dist/store/index.d.ts +1 -0
  144. package/dist/store/index.d.ts.map +1 -0
  145. package/dist/store/snapshots.d.ts +144 -0
  146. package/dist/store/snapshots.d.ts.map +1 -0
  147. package/dist/undo/UndoManager.d.ts +120 -0
  148. package/dist/undo/UndoManager.d.ts.map +1 -0
  149. package/dist/undo/index.d.ts +3 -0
  150. package/dist/undo/index.d.ts.map +1 -0
  151. package/dist/undo/types.d.ts +74 -0
  152. package/dist/undo/types.d.ts.map +1 -0
  153. package/dist/utils/aliasGenerator.d.ts +25 -0
  154. package/dist/utils/aliasGenerator.d.ts.map +1 -0
  155. package/dist/utils/deepEqual.d.ts +6 -0
  156. package/dist/utils/deepEqual.d.ts.map +1 -0
  157. package/dist/utils/index.d.ts +3 -0
  158. package/dist/utils/index.d.ts.map +1 -0
  159. package/package.json +29 -0
  160. package/src/adapter/ContemberAdapter.ts +239 -0
  161. package/src/adapter/MockAdapter.ts +394 -0
  162. package/src/adapter/MockQueryEngine.ts +236 -0
  163. package/src/adapter/index.ts +21 -0
  164. package/src/adapter/types.ts +199 -0
  165. package/src/brand/ComponentBrand.ts +28 -0
  166. package/src/contember/index.ts +17 -0
  167. package/src/core/ActionDispatcher.ts +450 -0
  168. package/src/core/Disposable.ts +126 -0
  169. package/src/core/EntityLoader.ts +169 -0
  170. package/src/core/SelectionResolver.ts +78 -0
  171. package/src/core/actionClassification.ts +196 -0
  172. package/src/core/actions.ts +491 -0
  173. package/src/core/index.ts +20 -0
  174. package/src/dataview/filterHandlers.ts +443 -0
  175. package/src/dataview/index.ts +36 -0
  176. package/src/dataview/types.ts +220 -0
  177. package/src/errors/UnfetchedFieldError.ts +18 -0
  178. package/src/errors/errorPathResolver.ts +239 -0
  179. package/src/errors/index.ts +40 -0
  180. package/src/errors/pathMapper.ts +40 -0
  181. package/src/errors/types.ts +223 -0
  182. package/src/events/EventEmitter.ts +447 -0
  183. package/src/events/eventFactory.ts +430 -0
  184. package/src/events/index.ts +89 -0
  185. package/src/events/types.ts +374 -0
  186. package/src/handles/BaseHandle.ts +100 -0
  187. package/src/handles/EntityHandle.ts +538 -0
  188. package/src/handles/FieldHandle.ts +268 -0
  189. package/src/handles/HasManyListHandle.ts +500 -0
  190. package/src/handles/HasOneHandle.ts +500 -0
  191. package/src/handles/PlaceholderHandle.ts +321 -0
  192. package/src/handles/index.ts +37 -0
  193. package/src/handles/proxyFactory.ts +218 -0
  194. package/src/handles/types.ts +743 -0
  195. package/src/index.ts +384 -0
  196. package/src/internal.ts +19 -0
  197. package/src/persistence/BatchPersister.ts +911 -0
  198. package/src/persistence/ChangeRegistry.ts +150 -0
  199. package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
  200. package/src/persistence/MutationCollector.ts +567 -0
  201. package/src/persistence/MutationSchemaProvider.ts +33 -0
  202. package/src/persistence/index.ts +23 -0
  203. package/src/persistence/types.ts +220 -0
  204. package/src/schema/ContemberSchema.ts +132 -0
  205. package/src/schema/SchemaLoader.ts +93 -0
  206. package/src/schema/SchemaRegistry.ts +286 -0
  207. package/src/schema/index.ts +51 -0
  208. package/src/schema/roles.ts +83 -0
  209. package/src/schema/types.ts +238 -0
  210. package/src/selection/SelectionMetaCollector.ts +122 -0
  211. package/src/selection/SelectionScope.ts +208 -0
  212. package/src/selection/buildQuery.ts +134 -0
  213. package/src/selection/createFragment.ts +50 -0
  214. package/src/selection/createSelectionBuilder.ts +283 -0
  215. package/src/selection/index.ts +31 -0
  216. package/src/selection/queryTypes.ts +135 -0
  217. package/src/selection/types.ts +320 -0
  218. package/src/store/DirtyTracker.ts +117 -0
  219. package/src/store/EntityMetaStore.ts +143 -0
  220. package/src/store/EntitySnapshotStore.ts +260 -0
  221. package/src/store/ErrorStore.ts +251 -0
  222. package/src/store/RelationStore.ts +696 -0
  223. package/src/store/SnapshotStore.ts +765 -0
  224. package/src/store/SubscriptionManager.ts +241 -0
  225. package/src/store/TouchedStore.ts +42 -0
  226. package/src/store/entityId.ts +43 -0
  227. package/src/store/index.ts +2 -0
  228. package/src/store/snapshots.ts +203 -0
  229. package/src/undo/UndoManager.ts +418 -0
  230. package/src/undo/index.ts +9 -0
  231. package/src/undo/types.ts +84 -0
  232. package/src/utils/aliasGenerator.ts +56 -0
  233. package/src/utils/deepEqual.ts +33 -0
  234. package/src/utils/index.ts +2 -0
@@ -0,0 +1,500 @@
1
+ import { EntityRelatedHandle } from './BaseHandle.js'
2
+ import type { ActionDispatcher } from '../core/ActionDispatcher.js'
3
+ import type { SnapshotStore } from '../store/SnapshotStore.js'
4
+ import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
5
+ import type { SelectionMeta } from '../selection/types.js'
6
+ import {
7
+ connectRelation,
8
+ disconnectRelation,
9
+ deleteRelation,
10
+ addRelationError,
11
+ clearRelationErrors,
12
+ } from '../core/actions.js'
13
+ import {
14
+ FIELD_REF_META,
15
+ type HasOneRef,
16
+ type FieldRefMeta,
17
+ type SelectedEntityFields,
18
+ type Unsubscribe,
19
+ type EntityAccessor,
20
+ } from './types.js'
21
+ import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js'
22
+ import type {
23
+ EventTypeMap,
24
+ AfterEventTypes,
25
+ BeforeEventTypes,
26
+ EventListener,
27
+ Interceptor,
28
+ EntityPersistedEvent,
29
+ EntityPersistingEvent,
30
+ RelationConnectedEvent,
31
+ RelationDisconnectedEvent,
32
+ RelationConnectingEvent,
33
+ RelationDisconnectingEvent,
34
+ } from '../events/types.js'
35
+ import { EntityHandle } from './EntityHandle.js'
36
+ import { PlaceholderHandle } from './PlaceholderHandle.js'
37
+ import { createHandleProxy, HAS_ONE_HANDLE_PROPERTIES } from './proxyFactory.js'
38
+
39
+
40
+ /**
41
+ * HasOneHandle provides access to a has-one relation.
42
+ * Implements HasOneRef interface for JSX compatibility.
43
+ *
44
+ * @typeParam TEntity - The full entity type of the related entity
45
+ * @typeParam TSelected - The selected subset of fields (defaults to TEntity for backwards compatibility)
46
+ */
47
+ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity> extends EntityRelatedHandle implements HasOneRef<TEntity, TSelected> {
48
+ private entityHandleCache: EntityHandle<TEntity, TSelected> | null = null
49
+ private placeholderCache: PlaceholderHandle<TEntity, TSelected> | null = null
50
+
51
+ /** Runtime brand symbols for validation */
52
+ readonly __brands?: Set<symbol>
53
+
54
+ /** Type brand for schema - placeholder at runtime */
55
+ declare readonly __schema: Record<string, object>
56
+
57
+ // $ aliases - handled by proxy at runtime, declared for TypeScript
58
+ declare readonly $id: string
59
+ declare readonly $isDirty: boolean
60
+ declare readonly $state: 'connected' | 'disconnected' | 'deleted' | 'creating'
61
+ declare readonly $fields: SelectedEntityFields<TEntity, TSelected>
62
+ declare readonly $entity: EntityAccessor<TEntity, TSelected>
63
+ declare readonly $errors: readonly FieldError[]
64
+ declare readonly $hasError: boolean
65
+ declare $connect: (id: string) => void
66
+ declare $disconnect: () => void
67
+ declare $delete: () => void
68
+ declare $reset: () => void
69
+ declare $addError: (error: ErrorInput) => void
70
+ declare $clearErrors: () => void
71
+ declare $onConnect: (listener: EventListener<RelationConnectedEvent>) => Unsubscribe
72
+ declare $onDisconnect: (listener: EventListener<RelationDisconnectedEvent>) => Unsubscribe
73
+ declare $interceptConnect: (interceptor: Interceptor<RelationConnectingEvent>) => Unsubscribe
74
+ declare $interceptDisconnect: (interceptor: Interceptor<RelationDisconnectingEvent>) => Unsubscribe
75
+
76
+ private constructor(
77
+ parentEntityType: string,
78
+ parentEntityId: string,
79
+ private readonly fieldName: string,
80
+ private readonly targetType: string,
81
+ store: SnapshotStore,
82
+ dispatcher: ActionDispatcher,
83
+ private readonly schema: SchemaRegistry,
84
+ brands?: Set<symbol>,
85
+ private readonly selection?: SelectionMeta,
86
+ ) {
87
+ super(parentEntityType, parentEntityId, store, dispatcher)
88
+ this.__brands = brands
89
+ }
90
+
91
+ static create<TEntity extends object = object, TSelected = TEntity>(
92
+ parentEntityType: string,
93
+ parentEntityId: string,
94
+ fieldName: string,
95
+ targetType: string,
96
+ store: SnapshotStore,
97
+ dispatcher: ActionDispatcher,
98
+ schema: SchemaRegistry,
99
+ brands?: Set<symbol>,
100
+ selection?: SelectionMeta,
101
+ ): HasOneHandle<TEntity, TSelected> {
102
+ return createHandleProxy(new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection), {
103
+ knownProperties: HAS_ONE_HANDLE_PROPERTIES,
104
+ getFields: (target) => target.entity.$fields,
105
+ })
106
+ }
107
+
108
+ /**
109
+ * JSX field reference metadata for collection phase.
110
+ * Implements HasOneRef interface.
111
+ */
112
+ get [FIELD_REF_META](): FieldRefMeta {
113
+ return {
114
+ entityType: this.entityType,
115
+ entityId: this.entityId,
116
+ path: [this.fieldName],
117
+ fieldName: this.fieldName,
118
+ isArray: false,
119
+ isRelation: true,
120
+ targetType: this.targetType,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Subscribe to relation changes.
126
+ */
127
+ override subscribe(callback: () => void): () => void {
128
+ // Subscribe to both parent entity and relation state
129
+ const unsub1 = this.store.subscribeToEntity(this.entityType, this.entityId, callback)
130
+ const unsub2 = this.store.subscribeToRelation(
131
+ this.entityType,
132
+ this.entityId,
133
+ this.fieldName,
134
+ callback,
135
+ )
136
+
137
+ return () => {
138
+ unsub1()
139
+ unsub2()
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Gets the relation state.
145
+ */
146
+ get state(): 'connected' | 'disconnected' | 'deleted' | 'creating' {
147
+ const relation = this.store.getRelation(
148
+ this.entityType,
149
+ this.entityId,
150
+ this.fieldName,
151
+ )
152
+ return relation?.state ?? 'disconnected'
153
+ }
154
+
155
+ /**
156
+ * Gets the current related entity ID.
157
+ * Falls back to entity data if relation state is not initialized.
158
+ */
159
+ get relatedId(): string | null {
160
+ // First check relation state (for manual changes like connect/disconnect)
161
+ const relation = this.store.getRelation(
162
+ this.entityType,
163
+ this.entityId,
164
+ this.fieldName,
165
+ )
166
+ if (relation) {
167
+ return relation.currentId
168
+ }
169
+
170
+ // Fallback to entity snapshot data (for server-loaded data)
171
+ const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
172
+ if (parentSnapshot?.data) {
173
+ const relatedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
174
+ if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
175
+ return (relatedData as { id: string }).id
176
+ }
177
+ }
178
+
179
+ return null
180
+ }
181
+
182
+ /**
183
+ * Gets the related entity ID.
184
+ * Returns the actual ID if connected, or placeholder ID if disconnected.
185
+ * Implements HasOneRef interface.
186
+ */
187
+ get id(): string {
188
+ return this.entity.id
189
+ }
190
+
191
+ /**
192
+ * Gets the nested entity fields.
193
+ * Implements HasOneRef interface.
194
+ * Delegates to the entity (either real EntityHandle or PlaceholderHandle).
195
+ */
196
+ get fields(): SelectedEntityFields<TEntity, TSelected> {
197
+ return this.entity.$fields
198
+ }
199
+
200
+ /**
201
+ * Gets the related entity accessor with direct field access.
202
+ * Implements HasOneRef.$entity - returns EntityAccessor for the related entity.
203
+ * Returns PlaceholderHandle (with placeholder ID) if the relation is disconnected.
204
+ */
205
+ get entity(): EntityAccessor<TEntity, TSelected> {
206
+ const id = this.relatedId
207
+
208
+ if (id) {
209
+ // Ensure the related entity has a snapshot in the store
210
+ // (it may be embedded in the parent entity's data)
211
+ this.ensureRelatedEntitySnapshot(id)
212
+
213
+ // Connected - return real entity handle
214
+ if (!this.entityHandleCache || this.entityHandleCache.id !== id) {
215
+ this.entityHandleCache = EntityHandle.create<TEntity, TSelected>(
216
+ id,
217
+ this.targetType,
218
+ this.store,
219
+ this.dispatcher,
220
+ this.schema,
221
+ this.__brands,
222
+ this.selection,
223
+ )
224
+ }
225
+ // EntityHandle constructor returns a Proxy that implements EntityAccessor
226
+ return this.entityHandleCache as unknown as EntityAccessor<TEntity, TSelected>
227
+ }
228
+
229
+ // Disconnected - return placeholder handle
230
+ if (!this.placeholderCache) {
231
+ this.placeholderCache = PlaceholderHandle.create<TEntity, TSelected>(
232
+ this.entityType,
233
+ this.entityId,
234
+ this.fieldName,
235
+ this.targetType,
236
+ this.store,
237
+ this.dispatcher,
238
+ this.__brands,
239
+ )
240
+ }
241
+ // PlaceholderHandle implements EntityRef but for API consistency return as EntityAccessor
242
+ return this.placeholderCache as unknown as EntityAccessor<TEntity, TSelected>
243
+ }
244
+
245
+ /**
246
+ * Ensures the related entity has a snapshot in the store.
247
+ * If the related entity is embedded in the parent's data (not yet normalized),
248
+ * creates a snapshot from the embedded data.
249
+ */
250
+ private ensureRelatedEntitySnapshot(id: string): void {
251
+ // Register parent-child relationship for change propagation
252
+ // This needs to happen even if the snapshot already exists
253
+ this.store.registerParentChild(this.entityType, this.entityId, this.targetType, id)
254
+
255
+ // Check if snapshot already exists
256
+ if (this.store.hasEntity(this.targetType, id)) {
257
+ return
258
+ }
259
+
260
+ // Get embedded data from parent entity
261
+ const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
262
+ if (!parentSnapshot?.data) {
263
+ return
264
+ }
265
+
266
+ const embeddedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
267
+ if (!embeddedData || typeof embeddedData !== 'object') {
268
+ return
269
+ }
270
+
271
+ // Only use embedded data if its ID matches the expected related entity ID.
272
+ // After $connect changes the relation to a different entity, the parent's embedded
273
+ // data still contains the OLD related entity — using it would store stale data
274
+ // under the new entity's key.
275
+ const embeddedId = (embeddedData as Record<string, unknown>)['id']
276
+ if (embeddedId !== id) {
277
+ return
278
+ }
279
+
280
+ // Create snapshot from embedded data
281
+ // Skip notification to avoid triggering React state updates during render
282
+ this.store.setEntityData(
283
+ this.targetType,
284
+ id,
285
+ embeddedData as Record<string, unknown>,
286
+ true, // isServerData
287
+ true, // skipNotify - called during render, data already exists embedded in parent
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Checks if the relation is dirty.
293
+ */
294
+ get isDirty(): boolean {
295
+ const relation = this.store.getRelation(
296
+ this.entityType,
297
+ this.entityId,
298
+ this.fieldName,
299
+ )
300
+ if (!relation) return false
301
+
302
+ return (
303
+ relation.currentId !== relation.serverId ||
304
+ relation.state !== relation.serverState ||
305
+ Object.keys(relation.placeholderData).length > 0
306
+ )
307
+ }
308
+
309
+ /**
310
+ * Connects the relation to an entity.
311
+ */
312
+ connect(targetId: string): void {
313
+ this.assertNotDisposed()
314
+ this.dispatcher.dispatch(
315
+ connectRelation(this.entityType, this.entityId, this.fieldName, targetId),
316
+ )
317
+ }
318
+
319
+ /**
320
+ * Disconnects the relation.
321
+ */
322
+ disconnect(): void {
323
+ this.assertNotDisposed()
324
+ this.dispatcher.dispatch(
325
+ disconnectRelation(this.entityType, this.entityId, this.fieldName),
326
+ )
327
+ }
328
+
329
+ /**
330
+ * Marks the relation for deletion.
331
+ */
332
+ delete(): void {
333
+ this.assertNotDisposed()
334
+ this.dispatcher.dispatch(
335
+ deleteRelation(this.entityType, this.entityId, this.fieldName),
336
+ )
337
+ }
338
+
339
+ /**
340
+ * Resets the relation to server state.
341
+ */
342
+ reset(): void {
343
+ this.assertNotDisposed()
344
+ this.store.resetRelation(this.entityType, this.entityId, this.fieldName)
345
+ }
346
+
347
+ /**
348
+ * Disposes the handle.
349
+ */
350
+ override dispose(): void {
351
+ super.dispose()
352
+ this.entityHandleCache?.dispose()
353
+ this.entityHandleCache = null
354
+ }
355
+
356
+ /**
357
+ * Type brand - ensures HasOneRef<Author> is not assignable to HasOneRef<Tag>.
358
+ * This is a phantom property that only exists in the type system.
359
+ */
360
+ get __entityType(): TEntity {
361
+ return undefined as unknown as TEntity
362
+ }
363
+
364
+ /**
365
+ * Gets the list of errors on this relation.
366
+ */
367
+ get errors(): readonly FieldError[] {
368
+ return this.store.getRelationErrors(this.entityType, this.entityId, this.fieldName)
369
+ }
370
+
371
+ /**
372
+ * Checks if this relation has any errors.
373
+ */
374
+ get hasError(): boolean {
375
+ return this.errors.length > 0
376
+ }
377
+
378
+ /**
379
+ * Adds a client-side error to this relation.
380
+ */
381
+ addError(error: ErrorInput): void {
382
+ this.assertNotDisposed()
383
+ this.dispatcher.dispatch(
384
+ addRelationError(this.entityType, this.entityId, this.fieldName, createClientError(error)),
385
+ )
386
+ }
387
+
388
+ /**
389
+ * Clears all errors from this relation.
390
+ */
391
+ clearErrors(): void {
392
+ this.assertNotDisposed()
393
+ this.dispatcher.dispatch(
394
+ clearRelationErrors(this.entityType, this.entityId, this.fieldName),
395
+ )
396
+ }
397
+
398
+ // ==================== Event Subscriptions ====================
399
+
400
+ /**
401
+ * Subscribe to connection events.
402
+ */
403
+ onConnect(listener: EventListener<RelationConnectedEvent>): Unsubscribe {
404
+ const emitter = this.dispatcher.getEventEmitter()
405
+ return emitter.onField(
406
+ 'relation:connected',
407
+ this.entityType,
408
+ this.entityId,
409
+ this.fieldName,
410
+ listener,
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Subscribe to disconnection events.
416
+ */
417
+ onDisconnect(listener: EventListener<RelationDisconnectedEvent>): Unsubscribe {
418
+ const emitter = this.dispatcher.getEventEmitter()
419
+ return emitter.onField(
420
+ 'relation:disconnected',
421
+ this.entityType,
422
+ this.entityId,
423
+ this.fieldName,
424
+ listener,
425
+ )
426
+ }
427
+
428
+ /**
429
+ * Intercept connection (can cancel or modify target).
430
+ */
431
+ interceptConnect(interceptor: Interceptor<RelationConnectingEvent>): Unsubscribe {
432
+ const emitter = this.dispatcher.getEventEmitter()
433
+ return emitter.interceptField(
434
+ 'relation:connecting',
435
+ this.entityType,
436
+ this.entityId,
437
+ this.fieldName,
438
+ interceptor,
439
+ )
440
+ }
441
+
442
+ /**
443
+ * Intercept disconnection (can cancel).
444
+ */
445
+ interceptDisconnect(interceptor: Interceptor<RelationDisconnectingEvent>): Unsubscribe {
446
+ const emitter = this.dispatcher.getEventEmitter()
447
+ return emitter.interceptField(
448
+ 'relation:disconnecting',
449
+ this.entityType,
450
+ this.entityId,
451
+ this.fieldName,
452
+ interceptor,
453
+ )
454
+ }
455
+
456
+ // ==================== EntityRef-compatible Properties ====================
457
+ // These make HasOneAccessor structurally compatible with EntityAccessor
458
+
459
+ /** Raw data snapshot of the related entity - delegates to $entity */
460
+ get $data(): TSelected | null { return this.entity.$data }
461
+
462
+ /** Whether this entity is new - delegates to $entity */
463
+ get $isNew(): boolean { return this.entity.$isNew }
464
+
465
+ /** Server-assigned ID after persistence - delegates to $entity */
466
+ get $persistedId(): string | null { return this.entity.$persistedId }
467
+
468
+ /** Type brand for entity name */
469
+ get __entityName(): string { return this.targetType }
470
+
471
+ /** Clear all errors - delegates to $entity */
472
+ $clearAllErrors(): void { this.entity.$clearAllErrors() }
473
+
474
+ /** Subscribe to any event on the related entity */
475
+ $on<E extends AfterEventTypes>(
476
+ eventType: E,
477
+ listener: EventListener<EventTypeMap[E]>,
478
+ ): Unsubscribe {
479
+ return this.entity.$on(eventType, listener)
480
+ }
481
+
482
+ /** Intercept any before event on the related entity */
483
+ $intercept<E extends BeforeEventTypes>(
484
+ eventType: E,
485
+ interceptor: Interceptor<EventTypeMap[E]>,
486
+ ): Unsubscribe {
487
+ return this.entity.$intercept(eventType, interceptor)
488
+ }
489
+
490
+ /** Subscribe to persist success events on the related entity */
491
+ $onPersisted(listener: EventListener<EntityPersistedEvent>): Unsubscribe {
492
+ return this.entity.$onPersisted(listener)
493
+ }
494
+
495
+ /** Intercept persist on the related entity */
496
+ $interceptPersisting(interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe {
497
+ return this.entity.$interceptPersisting(interceptor)
498
+ }
499
+
500
+ }