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
48 changes: 48 additions & 0 deletions packages/bindx/src/persistence/BatchPersister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,11 @@ export class BatchPersister {
for (const entity of entities) {
let data: Record<string, unknown> | null = null

// The parent's update carries this row's nested `delete`, so a standalone
// update would target a row that no longer exists (issue #91). The entity
// stays in the dirty set so a persisting interceptor can still veto it.
if (entity.changeType === 'update' && this.store.isPlannedForDeleteByParent(entity.entityId)) continue

if (entity.changeType === 'delete') {
mutations.push({
entityType: entity.entityType,
Expand Down Expand Up @@ -1186,9 +1191,39 @@ export class BatchPersister {
)
if (outcome === 'conflict') addConflict(ownerKey, relationConflictMessage(change.entityType, change.entityId, change.fieldName))
}
this.purgeChildrenDeletedByParent(execution, keys)
return conflicts
}

/**
* Drops the entities whose rows a confirmed parent mutation deleted through a
* nested `delete`, the way a top-level delete already does — otherwise the
* snapshot lingers and the next save writes a row that is gone (issue #91).
*/
private purgeChildrenDeletedByParent(execution: PersistExecution, ownerKeys: ReadonlySet<string>): void {
const targets = targetEntityTypesByRelation(execution)
const deleted = new Set<string>()
const collect = (entityType: string, entityId: string, fieldName: string, childId: string): void => {
const targetType = targets.get(relationIdentityKey(entityType, entityId, fieldName))
if (targetType) deleted.add(entityIdentityKey(targetType, childId))
}
for (const change of execution.hasOneChanges) {
if (!ownerKeys.has(entityIdentityKey(change.entityType, change.entityId))) continue
if (change.transition.operation !== 'delete') continue
collect(change.entityType, change.entityId, change.fieldName, change.transition.targetId)
}
for (const change of execution.hasManyChanges) {
if (!ownerKeys.has(entityIdentityKey(change.entityType, change.entityId))) continue
for (const removal of change.removals) {
if (removal.type === 'delete') collect(change.entityType, change.entityId, change.fieldName, removal.itemId)
}
}
for (const key of deleted) {
const child = splitExecutionKey(key)
this.store.removeEntity(child.entityType, child.entityId)
}
}

private mapConfirmedIds(
entity: ExecutionEntity,
persistedId: string | undefined,
Expand Down Expand Up @@ -1565,3 +1600,16 @@ function splitExecutionKey(key: string): { entityType: string; entityId: string
entityId: separator < 0 ? key : key.slice(separator + 1),
}
}

function relationIdentityKey(entityType: string, entityId: string, fieldName: string): string {
return `${entityIdentityKey(entityType, entityId)}:${fieldName}`
}

/** Relation key → the entity type its children have, the only source of a nested child's type. */
function targetEntityTypesByRelation(execution: PersistExecution): ReadonlyMap<string, string> {
const targets = new Map<string, string>()
for (const field of execution.relationFields) {
targets.set(relationIdentityKey(field.entityType, field.entityId, field.fieldName), field.targetEntityType)
}
return targets
}
33 changes: 32 additions & 1 deletion packages/bindx/src/store/HasManyStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
import { PlannedDeleteIndex } from './PlannedDeleteIndex.js'
import { RelationEdgeIndex } from './RelationEdgeIndex.js'

type ReconciliationResult = 'applied' | 'conflict'
Expand Down Expand Up @@ -117,6 +118,12 @@ export class HasManyStore {
*/
private readonly edges = new RelationEdgeIndex()

/**
* Children some relation plans to remove with `delete`, maintained by
* {@link writeHasMany} / {@link deleteHasMany}, like {@link edges}.
*/
private readonly plannedDeletes = new PlannedDeleteIndex()

private mutationVersion = 0

/**
Expand All @@ -143,12 +150,14 @@ export class HasManyStore {
* keeps the index correct without tracking the reverse direction itself.
*/
private writeHasMany(key: string, state: StoredHasManyState): void {
const oldLive = liveHasManyChildIds(this.hasManyStates.get(key))
const previous = this.hasManyStates.get(key)
const oldLive = liveHasManyChildIds(previous)
const newLive = liveHasManyChildIds(state)
this.hasManyStates.set(key, state)
const parentKey = parentKeyFromRelationKey(key)
for (const id of newLive) if (!oldLive.has(id)) this.edges.addEdge(parentKey, id)
for (const id of oldLive) if (!newLive.has(id)) this.edges.removeEdge(parentKey, id)
this.reconcilePlannedDeletes(plannedDeleteChildIds(previous), plannedDeleteChildIds(state))
this.mutationVersion++
}

Expand All @@ -162,9 +171,21 @@ export class HasManyStore {
const parentKey = parentKeyFromRelationKey(key)
for (const id of liveHasManyChildIds(existing)) this.edges.removeEdge(parentKey, id)
this.hasManyStates.delete(key)
this.reconcilePlannedDeletes(plannedDeleteChildIds(existing), new Set())
this.mutationVersion++
}

/** Applies one relation's planned-delete diff to the refcounted index. */
private reconcilePlannedDeletes(previous: ReadonlySet<string>, next: ReadonlySet<string>): void {
for (const id of next) if (!previous.has(id)) this.plannedDeletes.retain(id)
for (const id of previous) if (!next.has(id)) this.plannedDeletes.release(id)
}

/** Whether any has-many relation plans to remove {@link childId} with `delete`. */
isPlannedForDelete(childId: string): boolean {
return this.plannedDeletes.has(childId)
}

/**
* Gets or creates has-many list state.
*/
Expand Down Expand Up @@ -733,6 +754,7 @@ export class HasManyStore {
clear(): void {
this.hasManyStates.clear()
this.edges.clear()
this.plannedDeletes.clear()
this.mutationVersion++
}
}
Expand All @@ -754,6 +776,15 @@ function liveHasManyChildIds(state: StoredHasManyState | undefined): Set<string>
return live
}

function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set<string> {
const ids = new Set<string>()
if (!state) return ids
for (const [id, type] of state.plannedRemovals) {
if (type === 'delete') ids.add(id)
}
return ids
}

interface HasManyReconciliation {
state: StoredHasManyState
result: ReconciliationResult
Expand Down
35 changes: 34 additions & 1 deletion packages/bindx/src/store/HasOneStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { HasOneRelationState } from '../handles/types.js'
import type { EntitySnapshot } from './snapshots.js'
import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
import { PlannedDeleteIndex } from './PlannedDeleteIndex.js'
import { RelationEdgeIndex } from './RelationEdgeIndex.js'

/**
Expand Down Expand Up @@ -48,6 +49,12 @@ export class HasOneStore {
*/
private readonly edges = new RelationEdgeIndex()

/**
* Targets this store plans to delete through their parent's mutation, maintained
* by {@link writeRelation} / {@link deleteRelation}, like {@link edges}.
*/
private readonly plannedDeletes = new PlannedDeleteIndex()

private mutationVersion = 0

/**
Expand All @@ -73,14 +80,16 @@ export class HasOneStore {
* tracking the reverse direction itself.
*/
private writeRelation(key: string, state: StoredRelationState): void {
const oldChild = liveHasOneChildId(this.relationStates.get(key))
const previous = this.relationStates.get(key)
const oldChild = liveHasOneChildId(previous)
const newChild = liveHasOneChildId(state)
this.relationStates.set(key, state)
if (oldChild !== newChild) {
const parentKey = parentKeyFromRelationKey(key)
if (oldChild !== null) this.edges.removeEdge(parentKey, oldChild)
if (newChild !== null) this.edges.addEdge(parentKey, newChild)
}
this.reconcilePlannedDelete(plannedDeleteChildId(previous), plannedDeleteChildId(state))
this.mutationVersion++
}

Expand All @@ -94,9 +103,22 @@ export class HasOneStore {
const child = liveHasOneChildId(existing)
if (child !== null) this.edges.removeEdge(parentKeyFromRelationKey(key), child)
this.relationStates.delete(key)
this.reconcilePlannedDelete(plannedDeleteChildId(existing), null)
this.mutationVersion++
}

/** Applies one relation's planned-delete diff to the refcounted index. */
private reconcilePlannedDelete(previous: string | null, next: string | null): void {
if (previous === next) return
if (previous !== null) this.plannedDeletes.release(previous)
if (next !== null) this.plannedDeletes.retain(next)
}

/** Whether this store plans to delete {@link childId} through its parent. */
isPlannedForDelete(childId: string): boolean {
return this.plannedDeletes.has(childId)
}

/**
* Gets or creates relation state.
*/
Expand Down Expand Up @@ -376,6 +398,7 @@ export class HasOneStore {
clear(): void {
this.relationStates.clear()
this.edges.clear()
this.plannedDeletes.clear()
this.mutationVersion++
}
}
Expand All @@ -390,6 +413,16 @@ function liveHasOneChildId(state: StoredRelationState | undefined): string | nul
return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null
}

/**
* The id the parent's `{ delete: true }` removes — `serverId`, not `currentId`,
* because that is the row MutationCollector deletes for a `deleted` relation, and a
* null serverId emits no delete at all.
*/
function plannedDeleteChildId(state: StoredRelationState | undefined): string | null {
if (!state || state.state !== 'deleted') return null
return state.serverId
}

interface HasOneReconciliation {
state: StoredRelationState
result: ReconciliationResult
Expand Down
33 changes: 33 additions & 0 deletions packages/bindx/src/store/PlannedDeleteIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Refcounted multiset of entity ids whose row a parent relation plans to delete
* through its own mutation ({@link HasManyStore} `plannedRemovals` of type `delete`,
* {@link HasOneStore} state `deleted`).
*
* Refcounted because more than one relation may plan the same child's deletion, so
* the id stays planned until the last of them drops it. Like {@link RelationEdgeIndex},
* the index knows nothing about the rules: turning relation state into a planned-delete
* id is the owning sub-store's job, done once per write in its chokepoint by diffing
* the previous state against the next.
*/
export class PlannedDeleteIndex {
private readonly counts = new Map<string, number>()

retain(childId: string): void {
this.counts.set(childId, (this.counts.get(childId) ?? 0) + 1)
}

release(childId: string): void {
const count = this.counts.get(childId)
if (count === undefined) return
if (count > 1) this.counts.set(childId, count - 1)
else this.counts.delete(childId)
}

has(childId: string): boolean {
return this.counts.has(childId)
}

clear(): void {
this.counts.clear()
}
}
10 changes: 10 additions & 0 deletions packages/bindx/src/store/RelationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ export class RelationStore implements Rekeyable {
return this.hasMany.getHasManyOrderedIds(key)
}

/**
* Whether some relation plans to delete {@link childId} through its parent's
* mutation — a has-many `delete` removal or a has-one target marked `deleted`.
* Such a row goes away with the parent's update, so the child must not be
* written on its own (see issue #91).
*/
isPlannedForDeleteByParent(childId: string): boolean {
return this.hasOne.isPlannedForDelete(childId) || this.hasMany.isPlannedForDelete(childId)
}

// ==================== Reachability / Reverse Lookup ====================

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/bindx/src/store/SnapshotStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,15 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
return this.relations.getHasMany(key)?.plannedRemovals
}

/**
* Whether some relation plans to delete {@link entityId} through its parent, i.e.
* the row goes away with the parent's update rather than with a mutation of its
* own (see issue #91).
*/
isPlannedForDeleteByParent(entityId: string): boolean {
return this.relations.isPlannedForDeleteByParent(entityId)
}

planHasManyConnection(
parentType: string,
parentId: string,
Expand Down
Loading