@@ -149,6 +149,12 @@ export class MutationCollector implements MutationDataCollector {
149149 // Collect scalar field changes
150150 this . collectScalarChanges ( entityType , snapshot , mutation )
151151
152+ // Materialize placeholder-backed creating-state hasOne relations before
153+ // collecting, so the collection phase can remain a pure read over the
154+ // store. Embedded data is not materialized here — for existing
155+ // (server-side) parents, hasMany state is already authoritative.
156+ this . materializeForUpdate ( entityType , entityId )
157+
152158 // Collect relation changes
153159 this . collectRelationChanges ( entityType , entityId , mutation )
154160
@@ -188,7 +194,7 @@ export class MutationCollector implements MutationDataCollector {
188194
189195 // Materialize embedded relation data into store entities,
190196 // then collect from store for consistent tracking and post-persist ID mapping
191- this . materializeEntityRelations ( entityType , entityId )
197+ this . materializeForCreate ( entityType , entityId )
192198
193199 // Collect relation values from store
194200 const relationFields = this . schemaProvider . getRelationFields ( entityType )
@@ -378,12 +384,21 @@ export class MutationCollector implements MutationDataCollector {
378384 return { delete : true }
379385
380386 case 'creating' :
381- // Create new entity with placeholder data
382- if ( Object . keys ( placeholderData ) . length > 0 ) {
383- const createData = this . processNestedData ( placeholderData )
384- return { create : createData }
387+ // Empty placeholderData is a legitimate no-op (user opened a create
388+ // form but typed nothing). Non-empty placeholderData reaching this
389+ // branch is an invariant violation — the materialization pass
390+ // (materializeForCreate / materializeForUpdate) must run before
391+ // collection and flip the relation to 'connected' with a tempId.
392+ // Reaching here means a caller skipped materialization, which
393+ // would produce a mutation the server accepts but whose returned
394+ // ID we can't map back (see issue #23).
395+ if ( Object . keys ( placeholderData ) . length === 0 ) {
396+ return null
385397 }
386- return null
398+ throw new Error (
399+ `Invariant: hasOne relation ${ entityType } .${ fieldName } in 'creating' state ` +
400+ `with non-empty placeholderData must be materialized before collection` ,
401+ )
387402
388403 default :
389404 return null
@@ -592,15 +607,19 @@ export class MutationCollector implements MutationDataCollector {
592607 return isPersistedId ( id )
593608 }
594609
595- // ==================== Embedded Data Materialization ====================
610+ // ==================== Materialization ====================
596611
597612 /**
598- * Materializes embedded relation data in an entity's snapshot into proper
599- * store entities and relations. This normalizes inline objects (e.g.
600- * `reviews: [{ reviewType: 'expert' }]`) into tracked entities with temp IDs,
601- * enabling proper post-persist ID mapping and commit.
613+ * Materializes all relation state into trackable store entities on the
614+ * create path: placeholder-backed 'creating' hasOne relations AND embedded
615+ * inline objects/arrays in the snapshot data. After this pass, the
616+ * collection phase is a pure read over the store.
617+ *
618+ * Called recursively for every newly materialized temp entity (both from
619+ * placeholder and embedded paths), since a newly-created entity can itself
620+ * carry further embedded or placeholder data.
602621 */
603- private materializeEntityRelations ( entityType : string , entityId : string ) : void {
622+ private materializeForCreate ( entityType : string , entityId : string ) : void {
604623 if ( ! this . schemaProvider . hasEntity ( entityType ) ) return
605624
606625 const snapshot = this . store . getEntitySnapshot ( entityType , entityId )
@@ -610,19 +629,76 @@ export class MutationCollector implements MutationDataCollector {
610629 const relationFields = this . schemaProvider . getRelationFields ( entityType )
611630
612631 for ( const fieldName of relationFields ) {
613- const value = data [ fieldName ]
614- if ( value === null || value === undefined ) continue
615-
616632 const relationType = this . schemaProvider . getRelationType ( entityType , fieldName )
617633
618- if ( relationType === 'hasOne' && typeof value === 'object' && ! Array . isArray ( value ) ) {
619- this . materializeEmbeddedHasOne ( entityType , entityId , fieldName , value as Record < string , unknown > )
620- } else if ( relationType === 'hasMany' && Array . isArray ( value ) && value . length > 0 ) {
621- this . materializeEmbeddedHasMany ( entityType , entityId , fieldName , value )
634+ if ( relationType === 'hasOne' ) {
635+ if ( this . materializePlaceholderHasOne ( entityType , entityId , fieldName ) ) continue
636+
637+ const value = data [ fieldName ]
638+ if ( value !== null && value !== undefined && typeof value === 'object' && ! Array . isArray ( value ) ) {
639+ this . materializeEmbeddedHasOne ( entityType , entityId , fieldName , value as Record < string , unknown > )
640+ }
641+ } else if ( relationType === 'hasMany' ) {
642+ const value = data [ fieldName ]
643+ if ( Array . isArray ( value ) && value . length > 0 ) {
644+ this . materializeEmbeddedHasMany ( entityType , entityId , fieldName , value )
645+ }
622646 }
623647 }
624648 }
625649
650+ /**
651+ * Materializes placeholder-backed 'creating' hasOne relations only. Safe
652+ * to call on existing (server-side) entities — never touches hasMany state
653+ * or embedded snapshot data, because for an already-persisted parent the
654+ * store-level hasMany/connected state is already authoritative.
655+ *
656+ * Newly created temp entities below still go through the full create-path
657+ * materialization, since they have no server state yet.
658+ */
659+ private materializeForUpdate ( entityType : string , entityId : string ) : void {
660+ if ( ! this . schemaProvider . hasEntity ( entityType ) ) return
661+
662+ for ( const fieldName of this . schemaProvider . getRelationFields ( entityType ) ) {
663+ if ( this . schemaProvider . getRelationType ( entityType , fieldName ) !== 'hasOne' ) continue
664+ this . materializePlaceholderHasOne ( entityType , entityId , fieldName )
665+ }
666+ }
667+
668+ /**
669+ * Materializes a hasOne relation in the 'creating' state with non-empty
670+ * placeholderData (PlaceholderHandle pattern). Creates a real store entity
671+ * from the placeholder data and flips the relation to 'connected' with a
672+ * temp ID, so subsequent collection goes through the standard
673+ * tempId-inline-create branch and post-persist ID mapping works.
674+ *
675+ * Returns true if materialization happened (caller should skip embedded
676+ * processing for this field).
677+ */
678+ private materializePlaceholderHasOne (
679+ entityType : string ,
680+ entityId : string ,
681+ fieldName : string ,
682+ ) : boolean {
683+ const relation = this . store . getRelation ( entityType , entityId , fieldName )
684+ if ( ! relation || relation . state !== 'creating' ) return false
685+ if ( Object . keys ( relation . placeholderData ) . length === 0 ) return false
686+
687+ const targetType = this . schemaProvider . getRelationTarget ( entityType , fieldName )
688+ if ( ! targetType ) return false
689+
690+ const tempId = this . store . createEntity ( targetType , relation . placeholderData )
691+ this . store . setRelation ( entityType , entityId , fieldName , {
692+ currentId : tempId ,
693+ state : 'connected' ,
694+ placeholderData : { } ,
695+ } )
696+
697+ // Newly created temp entity — walk its embedded/placeholder data too.
698+ this . materializeForCreate ( targetType , tempId )
699+ return true
700+ }
701+
626702 /**
627703 * Materializes an embedded hasMany array into store entities.
628704 * Skips if the hasMany state already has store-managed entities.
@@ -662,7 +738,7 @@ export class MutationCollector implements MutationDataCollector {
662738 this . store . addToHasMany ( entityType , entityId , fieldName , tempId )
663739
664740 // Recursively materialize nested relations in the new entity
665- this . materializeEntityRelations ( targetType , tempId )
741+ this . materializeForCreate ( targetType , tempId )
666742 }
667743
668744 // Clear embedded array from parent snapshot
@@ -712,7 +788,7 @@ export class MutationCollector implements MutationDataCollector {
712788 } )
713789
714790 // Recursively materialize nested relations
715- this . materializeEntityRelations ( targetType , tempId )
791+ this . materializeForCreate ( targetType , tempId )
716792 }
717793
718794 // Clear embedded data from parent snapshot
0 commit comments