@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
@@ -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 createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection), (target) => target.entityRaw.fields)
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>>(handle, (target) => target.entityRaw.fields)
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
- * Falls back to snapshot data when no explicit relation state exists,
141
- * so server-loaded has-one relations report 'connected' without
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(): 'connected' | 'disconnected' | 'deleted' | 'creating' {
145
- const relation = this.store.getRelation(
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
- if (relation) {
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
- * Falls back to entity data if relation state is not initialized.
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
- // First check relation state (for manual changes like connect/disconnect)
166
- const relation = this.store.getRelation(
173
+ this.ensureEntry()
174
+ const relation = this.store.getPresentationRelation(
167
175
  this.entityType,
168
176
  this.entityId,
169
177
  this.fieldName,
170
178
  )
171
- if (relation) {
172
- return relation.currentId
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
- // Fallback to entity snapshot data (for server-loaded data)
176
- const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
177
- if (parentSnapshot?.data) {
178
- const relatedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
179
- if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
180
- return (relatedData as { id: string }).id
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
- return null
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.fieldName]
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.fieldName, embeddedData)) {
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.fieldName, embeddedData)
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.fieldName, embeddedData)
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
- const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
378
- this.dispatcher.dispatch(
379
- connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
380
- )
381
- return tempId
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
+ }