Skip to content

Commit 7bd0d63

Browse files
authored
Merge pull request #23 from contember/fix/placeholder-hasone-materialization
fix: materialize placeholder-backed hasOne creating relations on persist
2 parents db668a5 + 1339efe commit 7bd0d63

3 files changed

Lines changed: 580 additions & 32 deletions

File tree

packages/bindx/src/persistence/MutationCollector.ts

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)