diff --git a/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx b/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx index a9f81845..d9496016 100644 --- a/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx +++ b/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx @@ -129,6 +129,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({ const batchPersister = new BatchPersister(adapter, store, dispatcher, { mutationCollector, undoManager: undoManager ?? undefined, + schema: schemaRegistry, defaultUpdateMode, }) diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index 51af2f0d..87247121 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -137,6 +137,9 @@ export class HasOneHandle /** * Gets the relation state. + * Falls back to snapshot data when no explicit relation state exists, + * so server-loaded has-one relations report 'connected' without + * requiring a prior RelationStore entry. */ get state(): 'connected' | 'disconnected' | 'deleted' | 'creating' { const relation = this.store.getRelation( @@ -144,7 +147,14 @@ export class HasOneHandle this.entityId, this.fieldName, ) - return relation?.state ?? 'disconnected' + if (relation) { + return relation.state + } + // No explicit relation state — check if snapshot has embedded data + if (this.relatedId !== null) { + return 'connected' + } + return 'disconnected' } /** diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 6421bfe0..c7999389 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -85,10 +85,12 @@ export class BatchPersister { options?: BatchPersisterOptions, ) { this.changeRegistry = new ChangeRegistry(store) - this.mutationCollector = options?.mutationCollector this.undoManager = options?.undoManager this.schema = options?.schema this.defaultUpdateMode = options?.defaultUpdateMode ?? 'optimistic' + // Use provided mutationCollector, or auto-create one from schema + this.mutationCollector = options?.mutationCollector + ?? (options?.schema ? new MutationCollector(store, options.schema) : undefined) } /** @@ -381,10 +383,13 @@ export class BatchPersister { entities: DirtyEntity[], scope: PersistScope, ): TransactionMutation[] { - // Tell MutationCollector which entities have their own top-level mutations - // so it skips generating nested updates for them + // Exclude only non-create entities from nesting — + // new entities should be nested inside their parent's mutation + // to maintain correct relation connections without transaction support. if (this.mutationCollector instanceof MutationCollector) { - const excludedIds = new Set(entities.map(e => e.entityId)) + const excludedIds = new Set( + entities.filter(e => e.changeType !== 'create').map(e => e.entityId), + ) this.mutationCollector.setExcludedEntities(excludedIds) } @@ -407,11 +412,14 @@ export class BatchPersister { // Field-specific collection data = this.collectFieldsData(entity.entityType, entity.entityId, scope.fields) } else if (entity.changeType === 'create') { - data = this.mutationCollector?.collectCreateData?.(entity.entityType, entity.entityId) - ?? this.collectCreateDataWithRelationCheck(entity) + const mc = this.mutationCollector + data = mc?.collectCreateData + ? mc.collectCreateData(entity.entityType, entity.entityId) + : this.collectCreateDataWithRelationCheck(entity) } else { - data = this.mutationCollector?.collectUpdateData(entity.entityType, entity.entityId) - ?? this.collectUpdateDataWithRelationCheck(entity) + data = this.mutationCollector + ? this.mutationCollector.collectUpdateData(entity.entityType, entity.entityId) + : this.collectUpdateDataWithRelationCheck(entity) } if (data && Object.keys(data).length > 0) { @@ -424,6 +432,15 @@ export class BatchPersister { } } + // Remove standalone create mutations for entities that were included + // as nested inline creates inside another entity's mutation. + if (this.mutationCollector instanceof MutationCollector) { + const nestedIds = this.mutationCollector.getNestedEntityIds() + if (nestedIds.size > 0) { + return mutations.filter(m => !(m.operation === 'create' && nestedIds.has(m.entityId))) + } + } + return mutations } diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index d71c1fbc..4032cadf 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -31,6 +31,7 @@ export interface EntityMutationResult { */ export class MutationCollector implements MutationDataCollector { private excludedEntityIds: ReadonlySet = new Set() + private readonly _nestedEntityIds: Set = new Set() constructor( private readonly store: SnapshotStore, @@ -44,6 +45,16 @@ export class MutationCollector implements MutationDataCollector { */ setExcludedEntities(ids: ReadonlySet): void { this.excludedEntityIds = ids + this._nestedEntityIds.clear() + } + + /** + * Returns IDs of entities that were included as nested inline creates + * inside another entity's mutation data. These entities don't need + * their own standalone top-level mutations. + */ + getNestedEntityIds(): ReadonlySet { + return this._nestedEntityIds } // ==================== Main Collection Methods ==================== @@ -178,7 +189,9 @@ export class MutationCollector implements MutationDataCollector { createData[fieldName] = relationOp } } else if (relationType === 'hasMany') { + // Try embedded data first, then fall back to RelationStore const relationOps = this.collectCreateManyRelation(value) + ?? this.collectHasManyOperations(entityType, entityId, fieldName) if (relationOps !== null && relationOps.length > 0) { createData[fieldName] = relationOps } @@ -316,6 +329,7 @@ export class MutationCollector implements MutationDataCollector { return { connect: { id: currentId } } } else if (currentId && isTempId(currentId)) { // Temp entity — generate inline create with its collected data + this._nestedEntityIds.add(currentId) const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) if (targetType) { const createData = this.collectCreateData(targetType, currentId) @@ -426,6 +440,7 @@ export class MutationCollector implements MutationDataCollector { // Created entities -> create (using entity snapshot data) if (targetType) { for (const tempId of hasManyState.createdEntities) { + this._nestedEntityIds.add(tempId) const itemSnapshot = this.store.getEntitySnapshot(targetType, tempId) if (itemSnapshot) { const createData = { ...itemSnapshot.data as Record }