Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bindx-react/src/hooks/ContemberBindxProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({
const batchPersister = new BatchPersister(adapter, store, dispatcher, {
mutationCollector,
undoManager: undoManager ?? undefined,
schema: schemaRegistry,
defaultUpdateMode,
})

Expand Down
12 changes: 11 additions & 1 deletion packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,24 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>

/**
* 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(
this.entityType,
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'
}

/**
Expand Down
33 changes: 25 additions & 8 deletions packages/bindx/src/persistence/BatchPersister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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)
}

Expand All @@ -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) {
Expand All @@ -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
}

Expand Down
15 changes: 15 additions & 0 deletions packages/bindx/src/persistence/MutationCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface EntityMutationResult {
*/
export class MutationCollector implements MutationDataCollector {
private excludedEntityIds: ReadonlySet<string> = new Set()
private readonly _nestedEntityIds: Set<string> = new Set()

constructor(
private readonly store: SnapshotStore,
Expand All @@ -44,6 +45,16 @@ export class MutationCollector implements MutationDataCollector {
*/
setExcludedEntities(ids: ReadonlySet<string>): 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<string> {
return this._nestedEntityIds
}

// ==================== Main Collection Methods ====================
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, unknown> }
Expand Down