From 987bc5ae76fd6f4db207dca17782e0887f4d9ec5 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 17:46:24 +0200 Subject: [PATCH 01/28] feat(bindx): reachability-based create detection (replaces eager orphan-purge) Resolve the orphan-`create` leak class (#47 + bughunt) structurally instead of patching each detach path. A created entity is a `create` iff it is reachable from a root through live relations; a detached created entity is simply unreachable and produces no mutation. This replaces the eager-purge machinery (cascade, purgeOrphanedCreated, isEntityReferenced refcounts, per-detach-path purges) with a single invariant. - RootRegistry + ReachabilityAnalyzer + RelationStore.getLiveChildIds; id->key index in EntitySnapshotStore for O(1) child resolution - createEntity auto-roots; registerParentChild un-roots (a child is anchored by its parent); top-level / useEntityList adds stay roots - DirtyTracker create branch gated on reachability; ActionDispatcher back to plain relation updates (isNeverPersisted kept only for DELETE_RELATION relation-state semantics: never-persisted has-one target reverts to disconnected, not deleted) - lazy sweep (sweepUnreachableCreated, post-persist) + React unmount cleanup reclaim detached-create snapshots; correctness never depends on either - pessimistic persist failure preserves the user's edits/creates for retry (P2) instead of stranding them at the server view - drop dead relation API (cancelHasManyConnection/Removal); removeFromHasMany returns boolean Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../bindx-react/src/hooks/useEntityList.ts | 13 ++ .../bindx-react/src/jsx/components/Entity.tsx | 12 ++ packages/bindx/src/core/ActionDispatcher.ts | 43 +++++-- .../bindx/src/persistence/BatchPersister.ts | 61 +++++++--- packages/bindx/src/store/DirtyTracker.ts | 10 +- packages/bindx/src/store/EntityMetaStore.ts | 10 ++ .../bindx/src/store/EntitySnapshotStore.ts | 34 ++++++ .../bindx/src/store/ReachabilityAnalyzer.ts | 80 ++++++++++++ packages/bindx/src/store/RelationStore.ts | 98 +++++++++------ packages/bindx/src/store/RootRegistry.ts | 48 ++++++++ packages/bindx/src/store/SnapshotStore.ts | 114 +++++++++++++----- 11 files changed, 424 insertions(+), 99 deletions(-) create mode 100644 packages/bindx/src/store/ReachabilityAnalyzer.ts create mode 100644 packages/bindx/src/store/RootRegistry.ts diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index db022619..78925b46 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -283,6 +283,19 @@ export function useEntityList( [store], ) + // Discard never-persisted drafts when the list unmounts. List-level creates are + // top-level roots (nothing anchors them), so the lazy sweep never reclaims them; + // removing them here unregisters the root and frees the snapshot. + useEffect(() => { + return () => { + for (const item of listStateRef.current.items) { + if (isTempId(item.id) && !store.getPersistedId(entityType, item.id)) { + store.removeEntity(entityType, item.id) + } + } + } + }, [store, entityType]) + // --- Snapshot --- const getSnapshot = useCallback((): UseEntityListResult => { const state = listStateRef.current diff --git a/packages/bindx-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index c0e459c8..77ec1530 100644 --- a/packages/bindx-react/src/jsx/components/Entity.tsx +++ b/packages/bindx-react/src/jsx/components/Entity.tsx @@ -185,6 +185,18 @@ function EntityCreateMode({ return id }, [entityType, store]) + // Discard a never-persisted create when this form unmounts, so its snapshot + // and root registration do not leak. A persisted entity (rekeyed to a server + // id) is left untouched. + useEffect(() => { + return () => { + const id = tempIdRef.current + if (id && !store.getPersistedId(entityType, id)) { + store.removeEntity(entityType, id) + } + } + }, [store, entityType]) + // Subscribe to store changes for this entity const subscribe = useCallback( (callback: () => void) => store.subscribeToEntity(entityType, tempId, callback), diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 50e9826e..09fb6af2 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -216,7 +216,7 @@ export class ActionDispatcher { ) break - case 'CONNECT_RELATION': + case 'CONNECT_RELATION': { this.store.setRelation( action.entityType, action.entityId, @@ -228,8 +228,9 @@ export class ActionDispatcher { ) this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.targetId) break + } - case 'DISCONNECT_RELATION': + case 'DISCONNECT_RELATION': { this.store.setRelation( action.entityType, action.entityId, @@ -241,17 +242,37 @@ export class ActionDispatcher { }, ) break + } - case 'DELETE_RELATION': - this.store.setRelation( - action.entityType, - action.entityId, - action.fieldName, - { - state: 'deleted', - }, - ) + case 'DELETE_RELATION': { + const deletedId = this.store.getRelation(action.entityType, action.entityId, action.fieldName)?.currentId + // Deleting a never-persisted target just cancels its creation — there is + // no server row to delete. Revert the relation to 'disconnected' so the + // parent is not spuriously dirtied by a 'deleted' state; reachability then + // drops the orphaned create. A server target gets the normal 'deleted'. + if (deletedId && this.store.isNeverPersisted(deletedId)) { + this.store.setRelation( + action.entityType, + action.entityId, + action.fieldName, + { + currentId: null, + state: 'disconnected', + placeholderData: {}, + }, + ) + } else { + this.store.setRelation( + action.entityType, + action.entityId, + action.fieldName, + { + state: 'deleted', + }, + ) + } break + } case 'RESET_RELATION': this.store.resetRelation( diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 8c5a1f7a..3410a33d 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -267,6 +267,11 @@ export class BatchPersister { // Unblock undo this.undoManager?.unblock() + + // Reclaim memory: drop snapshots of any created entities orphaned during + // editing. Runs once the persist has settled (state restored, persisting + // cleared), so live in-flight creates are never swept. + this.store.sweepUnreachableCreated() } } @@ -686,7 +691,7 @@ export class BatchPersister { s => s.entityType === entity.entityType && s.entityId === entity.entityId, ) if (capturedState) { - this.restoreAndCommitEntityState(capturedState) + this.restoreEntityState(capturedState, true) } } else { // Commit based on scope @@ -747,9 +752,19 @@ export class BatchPersister { mutationResult.errorMessage, ) - // Rollback optimistic changes if enabled (and not in pessimistic mode) - // In pessimistic mode, we're already at server state, no rollback needed - if (rollbackOnError && !isPessimistic) { + // In pessimistic mode the entity was reset to its server view before the + // transaction. On failure, restore its captured dirty state WITHOUT + // committing (P2) so the user's edits and creates survive for a retry + // instead of being stuck showing server data. Optimistic mode rolls back + // to server state only when rollbackOnError is set. + if (isPessimistic && capturedStates) { + const capturedState = capturedStates.find( + s => s.entityType === entity.entityType && s.entityId === entity.entityId, + ) + if (capturedState) { + this.restoreEntityState(capturedState, false) + } + } else if (rollbackOnError) { this.rollbackEntity(entity) } } @@ -805,21 +820,24 @@ export class BatchPersister { } /** - * Restores a captured entity state and commits it. - * Used in pessimistic mode after successful server confirmation. + * Restores a captured entity state (pessimistic mode), optionally committing it. + * + * - commit=true (server confirmed): restore the dirty data and commit it as the + * new server baseline. + * - commit=false (server rejected): restore the dirty data but leave it dirty, so + * the user's in-flight edits and creates survive for a retry (P2) instead of + * being stuck at the server view the pessimistic reset left behind. */ - private restoreAndCommitEntityState(capturedState: CapturedEntityState): void { + private restoreEntityState(capturedState: CapturedEntityState, commit: boolean): void { if (capturedState.snapshot) { - // Restore the entity data from captured snapshot this.store.setEntityData( capturedState.entityType, capturedState.entityId, capturedState.snapshot.data as Record, - true, // Mark as server data since it's now confirmed + commit, // server data only when confirmed; otherwise keep it as a dirty edit ) } - // Restore and commit relations for (const [relationKey, relationState] of capturedState.relations) { const fieldName = relationKey.split(':')[2] if (fieldName) { @@ -827,24 +845,27 @@ export class BatchPersister { currentId: relationState.currentId, state: relationState.state, }) - this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName) + if (commit) { + this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName) + } } } - // Restore and commit has-many states for (const [hasManyKey, hasManyState] of capturedState.hasManyStates) { const fieldName = hasManyKey.split(':')[2] if (fieldName) { this.store.restoreHasManyState(capturedState.entityType, capturedState.entityId, fieldName, hasManyState) - // Compute new server IDs: serverIds - removals + connections - const newServerIds = new Set(hasManyState.serverIds) - for (const removedId of hasManyState.plannedRemovals.keys()) { - newServerIds.delete(removedId) - } - for (const connectedId of hasManyState.plannedConnections) { - newServerIds.add(connectedId) + if (commit) { + // Compute new server IDs: serverIds - removals + connections + const newServerIds = new Set(hasManyState.serverIds) + for (const removedId of hasManyState.plannedRemovals.keys()) { + newServerIds.delete(removedId) + } + for (const connectedId of hasManyState.plannedConnections) { + newServerIds.add(connectedId) + } + this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds)) } - this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds)) } } } diff --git a/packages/bindx/src/store/DirtyTracker.ts b/packages/bindx/src/store/DirtyTracker.ts index 82a1fbf5..d597eb95 100644 --- a/packages/bindx/src/store/DirtyTracker.ts +++ b/packages/bindx/src/store/DirtyTracker.ts @@ -1,6 +1,7 @@ import type { EntitySnapshotStore } from './EntitySnapshotStore.js' import type { EntityMetaStore } from './EntityMetaStore.js' import type { RelationStore } from './RelationStore.js' +import type { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' import { deepEqual } from '../utils/deepEqual.js' interface DirtyEntity { @@ -20,10 +21,15 @@ export class DirtyTracker { private readonly entitySnapshots: EntitySnapshotStore, private readonly meta: EntityMetaStore, private readonly relations: RelationStore, + private readonly reachability: ReachabilityAnalyzer, ) {} getAllDirtyEntities(): DirtyEntity[] { const dirtyEntities: DirtyEntity[] = [] + // A created entity is a `create` only when reachable from a root through + // live relations. A created entity detached from every relation is + // unreachable and must not be reported, even if its snapshot still lingers. + const reachableCreated = this.reachability.computeReachableCreated() for (const key of this.entitySnapshots.keys()) { const [entityType, ...idParts] = key.split(':') @@ -41,7 +47,9 @@ export class DirtyTracker { } if (!this.meta.existsOnServer(entityKey)) { - dirtyEntities.push({ entityType, entityId, changeType: 'create' }) + if (reachableCreated.has(entityKey)) { + dirtyEntities.push({ entityType, entityId, changeType: 'create' }) + } continue } diff --git a/packages/bindx/src/store/EntityMetaStore.ts b/packages/bindx/src/store/EntityMetaStore.ts index 1a8eac08..f2de0bf5 100644 --- a/packages/bindx/src/store/EntityMetaStore.ts +++ b/packages/bindx/src/store/EntityMetaStore.ts @@ -115,6 +115,16 @@ export class EntityMetaStore { return !this.tempToPersistedId.has(key) } + /** + * Removes all metadata for an entity (load state, meta, persisting, temp mapping). + */ + remove(key: string): void { + this.loadStates.delete(key) + this.entityMetas.delete(key) + this.persistingEntities.delete(key) + this.tempToPersistedId.delete(key) + } + /** * Moves all metadata from oldKey to newKey. */ diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 47b487b4..a34c7c92 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -15,6 +15,14 @@ import { export class EntitySnapshotStore { private readonly snapshots = new Map() + /** + * Index from entity id to its composite key, so reachability walks can resolve + * a relation's child id (which carries no type) to a snapshot in O(1). Ids are + * globally unique (temp ids are minted unique; server ids are UUIDs), so a + * single key per id is unambiguous. + */ + private readonly idIndex = new Map() + get(key: string): EntitySnapshot | undefined { return this.snapshots.get(key) } @@ -53,6 +61,7 @@ export class EntitySnapshotStore { ) this.snapshots.set(key, newSnapshot) + this.idIndex.set(id, key) return newSnapshot } @@ -191,6 +200,10 @@ export class EntitySnapshotStore { * Removes an entity snapshot. */ remove(key: string): void { + const existing = this.snapshots.get(key) + if (existing) { + this.idIndex.delete(existing.id) + } this.snapshots.delete(key) } @@ -263,6 +276,7 @@ export class EntitySnapshotStore { const keys = new Set() for (const [key, snapshot] of snapshots) { this.snapshots.set(key, snapshot) + this.idIndex.set(snapshot.id, key) keys.add(key) } return keys @@ -289,14 +303,34 @@ export class EntitySnapshotStore { this.snapshots.delete(oldKey) this.snapshots.set(newKey, newSnapshot) + this.idIndex.delete(snapshot.id) + this.idIndex.set(newId, newKey) } keys(): IterableIterator { return this.snapshots.keys() } + /** + * Resolves an entity id (without its type) to its composite key in O(1). + */ + keyForId(id: string): string | undefined { + return this.idIndex.get(id) + } + + /** + * Finds a snapshot by its entity id alone (without knowing the entity type). + * Used during cascade purges where a relation only records child ids, not their + * types. Temp ids are globally unique, so the first match is unambiguous. + */ + findByEntityId(id: string): EntitySnapshot | undefined { + const key = this.idIndex.get(id) + return key ? this.snapshots.get(key) : undefined + } + clear(): void { this.snapshots.clear() + this.idIndex.clear() } } diff --git a/packages/bindx/src/store/ReachabilityAnalyzer.ts b/packages/bindx/src/store/ReachabilityAnalyzer.ts new file mode 100644 index 00000000..65baa295 --- /dev/null +++ b/packages/bindx/src/store/ReachabilityAnalyzer.ts @@ -0,0 +1,80 @@ +import type { EntitySnapshotStore } from './EntitySnapshotStore.js' +import type { EntityMetaStore } from './EntityMetaStore.js' +import type { RelationStore } from './RelationStore.js' +import type { RootRegistry } from './RootRegistry.js' + +/** + * Computes which never-persisted (created) entities are "live" — i.e. reachable + * from a root through live relations. + * + * This is the single invariant behind create detection: a created entity is a + * `create` to be sent to the server iff it is reachable from a root. Roots are + * server entities plus explicitly-registered top-level creates (see + * {@link RootRegistry}). A created entity that has been detached from every + * relation is simply unreachable and produces no mutation — no eager purge of + * its snapshot is required for correctness; memory cleanup is a separate + * concern handled by a lazy sweep. + * + * The graph is read from {@link RelationStore} (the source of truth for live + * relation membership), never from the subscription parent-child registry, + * which is append-mostly for notifications and does not reflect disconnects. + */ +export class ReachabilityAnalyzer { + constructor( + private readonly entitySnapshots: EntitySnapshotStore, + private readonly meta: EntityMetaStore, + private readonly relations: RelationStore, + private readonly roots: RootRegistry, + ) {} + + /** + * Returns the set of entity keys ("entityType:id") for created + * (never-persisted) entities reachable from a root through live relations. + */ + computeReachableCreated(): Set { + const reachableCreated = new Set() + const visited = new Set() + const stack: string[] = [] + + // Seed: every server entity is a root, and so is every in-flight (persisting) + // entity. The latter keeps a created entity live while it is mid-persist — + // e.g. pessimistic mode temporarily resets an update parent to its server + // view, which would otherwise make a freshly-created child look unreachable + // during the async transaction window. + for (const key of this.entitySnapshots.keys()) { + if (this.meta.existsOnServer(key) || this.meta.isPersisting(key)) { + stack.push(key) + } + } + // Seed: explicitly-registered top-level creates (no persisted parent). + for (const key of this.roots.keys()) { + if (this.entitySnapshots.has(key)) { + stack.push(key) + } + } + + while (stack.length > 0) { + const key = stack.pop()! + if (visited.has(key)) continue + visited.add(key) + + // A created entity reached from a root is a live create. Server roots + // are not creates; they fall through to update/delete detection. + if (!this.meta.existsOnServer(key)) { + reachableCreated.add(key) + } + + // Walk through this entity's live child edges. The traversal continues + // through created entities too, so a created chain (created child of a + // created child of a root) is fully covered. + for (const childId of this.relations.getLiveChildIds(`${key}:`)) { + const childKey = this.entitySnapshots.keyForId(childId) + if (childKey && !visited.has(childKey)) { + stack.push(childKey) + } + } + } + + return reachableCreated + } +} diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index d3525411..617b21b6 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -252,22 +252,6 @@ export class RelationStore { } } - /** - * Cancels a planned removal for a has-many item. - */ - cancelHasManyRemoval(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return - - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.delete(itemId) - this.hasManyStates.set(key, { - ...existing, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) - } - /** * Plans a connection for a has-many item. */ @@ -302,22 +286,6 @@ export class RelationStore { } } - /** - * Cancels a planned connection for a has-many item. - */ - cancelHasManyConnection(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return - - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.delete(itemId) - this.hasManyStates.set(key, { - ...existing, - plannedConnections: newPlannedConnections, - version: existing.version + 1, - }) - } - /** * Commits has-many state after successful persist. */ @@ -419,11 +387,11 @@ export class RelationStore { * Removes an entity from a has-many relation. * For newly created entities (via add()), cancels the connection. * For existing server entities, plans the specified removal type. - * Returns the key if a removal was planned (caller should notify), or null if handled inline. + * Returns true if the state changed (caller should notify), false if it was a no-op. */ - removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): 'planned_removal' | 'cancelled_connection' | null { + removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean { const existing = this.hasManyStates.get(key) - if (!existing) return null + if (!existing) return false const isCreatedEntity = existing.createdEntities.has(itemId) @@ -458,10 +426,66 @@ export class RelationStore { } this.hasManyStates.set(key, newState) - return 'cancelled_connection' + return true } else { this.planHasManyRemoval(key, itemId, removalType) - return 'planned_removal' + return true + } + } + + /** + * Collects the ids of child entities currently reachable through an entity's + * LIVE relations (key prefix "parentType:parentId:"). Used by reachability-based + * create detection to walk the relation graph from roots. + * + * Live edges are: + * - has-one: currentId, when the relation is not disconnected/deleted + * (a disconnected relation has a null currentId; a 'deleted' relation is + * removing its target, so the target is not anchored by it). + * - has-many: effective members = (serverIds ∪ plannedConnections ∪ + * createdEntities) minus plannedRemovals. + */ + getLiveChildIds(keyPrefix: string): string[] { + const ids = new Set() + + for (const [key, state] of this.relationStates) { + if (!key.startsWith(keyPrefix)) continue + if (state.currentId !== null && state.state !== 'deleted') { + ids.add(state.currentId) + } + } + + for (const [key, state] of this.hasManyStates) { + if (!key.startsWith(keyPrefix)) continue + for (const id of state.serverIds) { + if (!state.plannedRemovals.has(id)) ids.add(id) + } + for (const id of state.plannedConnections) { + if (!state.plannedRemovals.has(id)) ids.add(id) + } + for (const id of state.createdEntities) { + if (!state.plannedRemovals.has(id)) ids.add(id) + } + } + + return Array.from(ids) + } + + /** + * Removes all relation and has-many state owned by an entity (keys under the + * given owner prefix). Called by removeEntity so a removed entity leaves no + * stale relation state behind. + */ + removeOwnedRelations(keyPrefix: string): void { + for (const key of [...this.relationStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.relationStates.delete(key) + } + } + for (const key of [...this.hasManyStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.hasManyStates.delete(key) + } } } diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts new file mode 100644 index 00000000..3f393446 --- /dev/null +++ b/packages/bindx/src/store/RootRegistry.ts @@ -0,0 +1,48 @@ +/** + * Tracks explicitly-created top-level entities ("roots"). + * + * Reachability-based create detection treats a created entity as a `create` + * only when it is reachable from a root through live relations. Roots are: + * - every entity that exists on the server (tracked via EntityMetaStore), and + * - explicitly-created top-level entities that have no persisted parent. + * + * The first set is derivable from metadata. The second set is NOT derivable + * from the relation graph — a top-level `` or a `useEntityList` + * add has no parent relation anchoring it, so it must be registered here when + * created and unregistered when removed. + * + * Keys use the same composite format as the rest of the store: "entityType:id". + */ +export class RootRegistry { + private readonly roots = new Set() + + register(key: string): void { + this.roots.add(key) + } + + unregister(key: string): void { + this.roots.delete(key) + } + + has(key: string): boolean { + return this.roots.has(key) + } + + keys(): IterableIterator { + return this.roots.keys() + } + + /** + * Moves a root entry from oldKey to newKey (used after persist rekeys a temp + * id to a server-assigned id). + */ + rekey(oldKey: string, newKey: string): void { + if (this.roots.delete(oldKey)) { + this.roots.add(newKey) + } + } + + clear(): void { + this.roots.clear() + } +} diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index f846f8f4..be9db213 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -13,6 +13,8 @@ import { TouchedStore } from './TouchedStore.js' import { generateTempId } from './entityId.js' import { DirtyTracker } from './DirtyTracker.js' import { EntitySnapshotStore } from './EntitySnapshotStore.js' +import { RootRegistry } from './RootRegistry.js' +import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' export type { EntityMeta } from './EntityMetaStore.js' @@ -44,6 +46,8 @@ export class SnapshotStore implements SnapshotVersionBumper { private readonly relations = new RelationStore() private readonly meta = new EntityMetaStore() private readonly touched = new TouchedStore() + private readonly roots = new RootRegistry() + private readonly reachability: ReachabilityAnalyzer private readonly dirtyTracker: DirtyTracker /** @@ -55,7 +59,8 @@ export class SnapshotStore implements SnapshotVersionBumper { private readonly lastPropagatedData = new Map() constructor() { - this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations) + this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots) + this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability) } // ==================== Key Generation ==================== @@ -238,14 +243,44 @@ export class SnapshotStore implements SnapshotVersionBumper { this.notifyEntitySubscribers(key) } + /** + * Removes a single entity and all of its own per-entity state (snapshot, + * metadata, root registration, errors, touched state, propagation tracking, + * and owned relation state). + * + * This does NOT cascade into descendants. With reachability-based create + * detection a detached created entity is simply unreachable and produces no + * mutation, so stranded descendants are harmless — they are collected by the + * lazy memory sweep rather than by an eager cascade. + */ removeEntity(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + const keyPrefix = `${key}:` + this.entitySnapshots.remove(key) - this.meta.clearLoadState(key) + this.meta.remove(key) + this.roots.unregister(key) + this.errors.clearAllErrors(key, keyPrefix) + this.touched.clearForEntity(keyPrefix) this.clearPropagatedDataForEntity(entityType, id) + this.relations.removeOwnedRelations(keyPrefix) + this.notifyEntitySubscribers(key) } + /** + * Whether an entity (looked up by id alone) exists in the store and has never + * been persisted to the server. Used to decide relation-state semantics when a + * has-one target is deleted — deleting a never-persisted target reverts the + * relation to 'disconnected' rather than 'deleted', since there is no server + * row to delete. + */ + isNeverPersisted(entityId: string): boolean { + const snapshot = this.entitySnapshots.findByEntityId(entityId) + if (!snapshot) return false + return !this.meta.existsOnServer(`${snapshot.entityType}:${snapshot.id}`) + } + // ==================== Load State (delegated to EntityMetaStore) ==================== getLoadState(entityType: string, id: string): { status: LoadStatus; error?: FieldError } | undefined { @@ -306,9 +341,24 @@ export class SnapshotStore implements SnapshotVersionBumper { this.setEntityData(entityType, id, data, false) this.setExistsOnServer(entityType, id, false) + // A freshly created entity is pending-persist by default — a root for + // reachability-based create detection. It stops being a root the moment a + // relation anchors it as a child (see registerParentChild). A top-level + // create (, useEntityList add) is never anchored, so it stays + // a root and is reported as a `create`. + this.roots.register(this.getEntityKey(entityType, id)) + return id } + /** + * Unregisters a top-level root (e.g. when its owning component unmounts or the + * entity is removed from a top-level list). + */ + unregisterRootEntity(entityType: string, id: string): void { + this.roots.unregister(this.getEntityKey(entityType, id)) + } + mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void { // Use raw keys (bypass resolveId) since we're the ones creating the mapping const oldKey = `${entityType}:${tempId}` @@ -319,6 +369,10 @@ export class SnapshotStore implements SnapshotVersionBumper { // Register redirect so future lookups by temp ID resolve to persisted key this.rekeyedEntities.set(oldKey, newKey) + // Keep root registration consistent across the rekey (the persisted entity + // is now a server root via existsOnServer, but keep the registry aligned). + this.roots.rekey(oldKey, newKey) + // Rekey entity snapshot (moves data, updates id field) this.entitySnapshots.rekey(oldKey, newKey, persistedId) @@ -402,18 +456,6 @@ export class SnapshotStore implements SnapshotVersionBumper { this.notifyRelationSubscribers(key) } - cancelHasManyRemoval( - parentType: string, - parentId: string, - fieldName: string, - itemId: string, - alias?: string, - ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - this.relations.cancelHasManyRemoval(key, itemId) - this.notifyRelationSubscribers(key) - } - getHasManyPlannedRemovals( parentType: string, parentId: string, @@ -436,18 +478,6 @@ export class SnapshotStore implements SnapshotVersionBumper { this.notifyRelationSubscribers(key) } - cancelHasManyConnection( - parentType: string, - parentId: string, - fieldName: string, - itemId: string, - alias?: string, - ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - this.relations.cancelHasManyConnection(key, itemId) - this.notifyRelationSubscribers(key) - } - getHasManyPlannedConnections( parentType: string, parentId: string, @@ -513,10 +543,10 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - const result = this.relations.removeFromHasMany(key, itemId, removalType) - if (result === 'planned_removal') { - this.notifyRelationSubscribers(key) - } else if (result === 'cancelled_connection') { + // Cancelling the add of a never-persisted child just removes it from the + // list; its now-unreachable snapshot is no longer reported as a `create` and + // is collected by the lazy memory sweep. + if (this.relations.removeFromHasMany(key, itemId, removalType)) { this.notifyRelationSubscribers(key) } } @@ -807,6 +837,9 @@ export class SnapshotStore implements SnapshotVersionBumper { const parentKey = this.getEntityKey(parentType, parentId) const childKey = this.getEntityKey(childType, childId) this.subscriptions.registerParentChild(parentKey, childKey) + // A child anchored by a parent relation is no longer a top-level root; its + // reachability now flows through the parent. (No-op for server children.) + this.roots.unregister(childKey) } unregisterParentChild(parentType: string, parentId: string, childType: string, childId: string): void { @@ -870,6 +903,26 @@ export class SnapshotStore implements SnapshotVersionBumper { return this.dirtyTracker.getAllDirtyEntities() } + /** + * Removes the snapshots of created (never-persisted) entities that are no longer + * reachable from any root — the lazy memory sweep that replaces eager purge. + * + * Correctness never depends on this (unreachable creates are already excluded + * from getAllDirtyEntities); it only reclaims memory. Run it off the hot path, + * e.g. once a persist settles. Shared (diamond) children are kept because + * reachability reports them as live as long as any parent references them. + */ + sweepUnreachableCreated(): void { + const reachable = this.reachability.computeReachableCreated() + for (const key of [...this.entitySnapshots.keys()]) { + if (this.meta.existsOnServer(key)) continue + if (reachable.has(key)) continue + const separator = key.indexOf(':') + if (separator === -1) continue + this.removeEntity(key.slice(0, separator), key.slice(separator + 1)) + } + } + getDirtyFields(entityType: string, entityId: string): string[] { return this.dirtyTracker.getDirtyFields(entityType, entityId) } @@ -892,6 +945,7 @@ export class SnapshotStore implements SnapshotVersionBumper { this.relations.clear() this.errors.clear() this.touched.clear() + this.roots.clear() this.lastPropagatedData.clear() this.subscriptions.notify() From e43a0e845bbecd70295784bf87da541032a4cfb5 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 17:46:34 +0200 Subject: [PATCH 02/28] test(bindx): reachability create detection + adapt suite to new model - reachabilityCreateDetection: lingering-orphan, diamond, cascade-drop, sweep and pessimistic-window cases proving the gate independent of any eager purge - rewrite orphan / hasMany-remove tests to assert no-create behavior instead of the eager-purge mechanism (a detached snapshot may linger until the lazy sweep) - pessimistic tests assert P2 preserve-for-retry on failure (update + create) - update removeFromHasMany / REMOVE_FROM_LIST call sites for dropped itemType / targetType params Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../hasManyRemoveCreatedOrphan.test.ts | 98 +++++++++ .../handles/orphanCreatedEntityPurge.test.ts | 201 ++++++++++++++++++ tests/unit/persistence/pessimistic.test.ts | 42 +++- .../store/reachabilityCreateDetection.test.ts | 143 +++++++++++++ tests/unit/store/snapshotStore.test.ts | 20 -- 5 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 tests/unit/handles/hasManyRemoveCreatedOrphan.test.ts create mode 100644 tests/unit/handles/orphanCreatedEntityPurge.test.ts create mode 100644 tests/unit/store/reachabilityCreateDetection.test.ts diff --git a/tests/unit/handles/hasManyRemoveCreatedOrphan.test.ts b/tests/unit/handles/hasManyRemoveCreatedOrphan.test.ts new file mode 100644 index 00000000..5c4ae730 --- /dev/null +++ b/tests/unit/handles/hasManyRemoveCreatedOrphan.test.ts @@ -0,0 +1,98 @@ +// Regression test for https://github.com/contember/bindx/issues/47 +// +// Removing a never-persisted child from a has-many (the documented "cancels the +// add operation" path) drops the relation link but leaves the created entity's +// snapshot in the store, so `getAllDirtyEntities()` keeps reporting it as a +// `create`. A subsequent global `persistAll` then tries to create a child that +// the user already removed — and the server rejects it for missing required +// fields. +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + EventEmitter, + HasManyListHandle, + SchemaRegistry, + type SchemaDefinition, + type HasManyAccessor, +} from '@contember/bindx' +import { createTestDispatcher } from '../shared/unitTestHelpers.js' + +interface TestArticle { + id: string + title: string + comments?: Array<{ id: string; text: string }> +} +interface TestComment { + id: string + text: string +} +interface TestSchema { + Article: TestArticle + Comment: TestComment + [key: string]: object +} + +// `comments` is a oneHasMany with a non-nullable owning FK — so `remove()` +// resolves to `delete` for server rows, and to "cancel the add" for rows that +// only exist client-side. This mirrors a real footer/socials editor. +const testSchemaDefinition: SchemaDefinition = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + comments: { type: 'hasMany', target: 'Comment', relationKind: 'oneHasMany' }, + }, + }, + Comment: { + fields: { + id: { type: 'scalar' }, + text: { type: 'scalar' }, + }, + }, + }, +} + +describe('HasManyListHandle remove() of a created entity', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let eventEmitter: EventEmitter + let schema: SchemaRegistry + + beforeEach(() => { + const setup = createTestDispatcher() + store = setup.store + dispatcher = setup.dispatcher + eventEmitter = setup.eventEmitter + schema = new SchemaRegistry(testSchemaDefinition) + }) + + function createHandle(): HasManyAccessor { + return HasManyListHandle.create('Article', 'a-1', 'comments', 'Comment', store, dispatcher, schema) + } + + test('should not leave an orphan create in the store after add() then remove()', () => { + // Parent loaded from the server with an empty has-many. + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', comments: [] }, true) + + const handle = createHandle() + const tempId = handle.add({ text: 'draft' }) + + // The freshly-added child is in the list... + expect(handle.items.some(i => i.id === tempId)).toBe(true) + + // ...and now the user removes it before persisting. + handle.remove(tempId) + + // The relation no longer references it (this part already works). + expect(handle.items.some(i => i.id === tempId)).toBe(false) + + // And it must no longer be reported as a create — otherwise a global + // persistAll picks it up and tries to create the removed row. (The snapshot + // may linger until the lazy sweep; reachability is what makes it harmless.) + const dirty = store.getAllDirtyEntities() + const orphan = dirty.find(e => e.entityType === 'Comment' && e.entityId === tempId) + expect(orphan).toBeUndefined() + }) +}) diff --git a/tests/unit/handles/orphanCreatedEntityPurge.test.ts b/tests/unit/handles/orphanCreatedEntityPurge.test.ts new file mode 100644 index 00000000..4e346143 --- /dev/null +++ b/tests/unit/handles/orphanCreatedEntityPurge.test.ts @@ -0,0 +1,201 @@ +// Regression tests for the orphan-created-entity leak class (issue #47 and the +// bug-hunt that followed it), now backed by reachability-based create detection. +// +// Root cause: a never-persisted created entity (existsOnServer=false, with a +// snapshot) that becomes logically detached from every relation must not be +// reported as a `create`. Otherwise getAllDirtyEntities() keeps reporting a +// phantom `create`, a global persistAll tries to create a row the user removed, +// and the form stays dirty. +// +// These assert the OBSERVABLE behavior (getAllDirtyEntities reports no create) +// across has-one detach, reset paths, cascade into descendants, and +// scheduleForDeletion. The detached snapshot may linger in the store until the +// lazy memory sweep — correctness no longer depends on eager purge. +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + HasManyListHandle, + HasOneHandle, + SchemaRegistry, + type SchemaDefinition, +} from '@contember/bindx' +import { createTestDispatcher } from '../shared/unitTestHelpers.js' + +interface TestSchema { + Article: { id: string; title: string } + Comment: { id: string; text: string } + Author: { id: string; name: string } + Reaction: { id: string; kind: string } + [key: string]: object +} + +const def: SchemaDefinition = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + comments: { type: 'hasMany', target: 'Comment', relationKind: 'oneHasMany' }, + author: { type: 'hasOne', target: 'Author', nullable: true }, + }, + }, + Comment: { + fields: { + id: { type: 'scalar' }, + text: { type: 'scalar' }, + reactions: { type: 'hasMany', target: 'Reaction', relationKind: 'oneHasMany' }, + author: { type: 'hasOne', target: 'Author', nullable: true }, + }, + }, + Author: { fields: { id: { type: 'scalar' }, name: { type: 'scalar' } } }, + Reaction: { fields: { id: { type: 'scalar' }, kind: { type: 'scalar' } } }, + }, +} + +describe('orphan created-entity purge', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let schema: SchemaRegistry + + beforeEach(() => { + const s = createTestDispatcher() + store = s.store + dispatcher = s.dispatcher + schema = new SchemaRegistry(def) + }) + + const creates = () => store.getAllDirtyEntities().filter(e => e.changeType === 'create') + + const hasMany = (parentType: string, parentId: string, field: string, itemType: string) => + HasManyListHandle.create(parentType, parentId, field, itemType, store, dispatcher, schema) + const hasOne = (parentType: string, parentId: string, field: string, targetType: string) => + HasOneHandle.create(parentType, parentId, field, targetType, store, dispatcher, schema) + + describe('has-many reset', () => { + test('reset() after add() purges created children', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', comments: [] }, true) + const h = hasMany('Article', 'a-1', 'comments', 'Comment') + h.add({ text: 'one' }) + h.add({ text: 'two' }) + h.reset() + expect(creates()).toEqual([]) + }) + + test('reset() keeps server children and reverts planned removals', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', comments: [{ id: 'c-1', text: 'srv' }] }, true) + const h = hasMany('Article', 'a-1', 'comments', 'Comment') + h.items // materialize server child + h.add({ text: 'draft' }) + h.remove('c-1') + h.reset() + expect(creates()).toEqual([]) + expect(store.hasEntity('Comment', 'c-1')).toBe(true) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'comments')).toEqual(['c-1']) + }) + }) + + describe('has-one detach', () => { + test('create() then disconnect() purges the created target', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: null }, true) + const h = hasOne('Article', 'a-1', 'author', 'Author') + h.$create({ name: 'draft' }) + h.$disconnect() + expect(creates()).toEqual([]) + }) + + test('create() then reset() purges the created target', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: null }, true) + const h = hasOne('Article', 'a-1', 'author', 'Author') + h.$create({ name: 'draft' }) + h.$reset() + expect(creates()).toEqual([]) + }) + + test('create() then delete() cancels the create (no phantom)', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: null }, true) + const h = hasOne('Article', 'a-1', 'author', 'Author') + h.$create({ name: 'draft' }) + h.$delete() + expect(store.getAllDirtyEntities()).toEqual([]) + }) + + test('create() then connect(other) purges the displaced created target', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: null }, true) + store.setEntityData('Author', 'au-1', { id: 'au-1', name: 'Existing' }, true) + const h = hasOne('Article', 'a-1', 'author', 'Author') + h.$create({ name: 'draft' }) + h.$connect('au-1') + expect(creates()).toEqual([]) + }) + + test('disconnect() of a SERVER target does not delete or orphan it', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: { id: 'au-1', name: 'Srv' } }, true) + const h = hasOne('Article', 'a-1', 'author', 'Author') + h.$entity // materialize server target + h.$disconnect() + expect(creates()).toEqual([]) + expect(store.hasEntity('Author', 'au-1')).toBe(true) + expect(store.isScheduledForDeletion('Author', 'au-1')).toBe(false) + }) + }) + + describe('cascade into descendants', () => { + test('remove(child) drops its created grandchildren and has-one targets from creates', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', comments: [] }, true) + const comments = hasMany('Article', 'a-1', 'comments', 'Comment') + const tempC = comments.add({ text: 'draft' }) + hasMany('Comment', tempC, 'reactions', 'Reaction').add({ kind: 'like' }) + hasOne('Comment', tempC, 'author', 'Author').$create({ name: 'sub' }) + comments.remove(tempC) + // The whole detached subtree is unreachable, so nothing is reported. + expect(creates()).toEqual([]) + }) + + test('has-one disconnect cascades through a created chain', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', author: null }, true) + const top = hasOne('Article', 'a-1', 'author', 'Author') + const tempAuthor = top.$create({ name: 'mid' }) + // nested created has-one under the created author + hasOne('Author', tempAuthor, 'author', 'Author').$create({ name: 'deep' }) + top.$disconnect() + expect(creates()).toEqual([]) + }) + + test('removing a child with created descendants reports no create', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', comments: [] }, true) + const comments = hasMany('Article', 'a-1', 'comments', 'Comment') + const tempC = comments.add({ text: 'draft' }) + hasMany('Comment', tempC, 'reactions', 'Reaction').add({ kind: 'like' }) + comments.remove(tempC) + // Child and its created reaction are both unreachable from any root. + expect(creates()).toEqual([]) + }) + }) + + describe('reset of a whole entity (rollback path)', () => { + test('resetAllRelations purges created children added after load', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T', comments: [] }, true) + hasMany('Article', 'a-1', 'comments', 'Comment').add({ text: 'draft' }) + store.resetAllRelations('Article', 'a-1') + expect(creates()).toEqual([]) + }) + }) + + describe('scheduleForDeletion of a never-persisted entity', () => { + test('reports neither a create nor a delete (cancels the create)', () => { + const id = store.createEntity('Comment', { text: 'x' }) + store.scheduleForDeletion('Comment', id) + // Never on the server + scheduled for deletion => nothing to persist. + expect(store.getAllDirtyEntities()).toEqual([]) + }) + + test('still schedules a server entity for deletion', () => { + store.setEntityData('Comment', 'c-1', { id: 'c-1', text: 'srv' }, true) + store.scheduleForDeletion('Comment', 'c-1') + expect(store.getAllDirtyEntities()).toEqual([ + { entityType: 'Comment', entityId: 'c-1', changeType: 'delete' }, + ]) + }) + }) +}) diff --git a/tests/unit/persistence/pessimistic.test.ts b/tests/unit/persistence/pessimistic.test.ts index ce02047c..684006da 100644 --- a/tests/unit/persistence/pessimistic.test.ts +++ b/tests/unit/persistence/pessimistic.test.ts @@ -110,7 +110,7 @@ describe('pessimistic update mode', () => { expect(finalSnapshot?.serverData).toEqual({ id: '1', title: 'Updated Title' }) }) - test('should revert to server state when persist fails in pessimistic mode', async () => { + test('should preserve local edits for retry when persist fails in pessimistic mode', async () => { const adapter = createFailingAdapter() const persister = new BatchPersister(adapter, store, dispatcher) @@ -126,10 +126,36 @@ describe('pessimistic update mode', () => { // Should fail expect(result.success).toBe(false) - // State should be back to original (server state) + // P2: the user's edit is restored and kept dirty so they can fix the error + // and retry — not stuck showing server data the pessimistic reset left. const finalSnapshot = store.getEntitySnapshot('Article', '1') - expect((finalSnapshot?.data as { title: string })?.title).toBe('Original Title') + expect((finalSnapshot?.data as { title: string })?.title).toBe('Failed Update') expect((finalSnapshot?.serverData as { title: string })?.title).toBe('Original Title') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: '1', + changeType: 'update', + }) + }) + + test('should preserve a created entity for retry when persist fails in pessimistic mode', async () => { + const adapter = createFailingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + const tempId = store.createEntity('Article', { title: 'Draft' }) + + const result = await persister.persist('Article', tempId, { updateMode: 'pessimistic' }) + expect(result.success).toBe(false) + + // P2: the create survives as a pending create the user can retry — not + // stranded in a half-state nor dropped by the post-persist sweep. + expect(store.hasEntity('Article', tempId)).toBe(true) + expect((store.getEntitySnapshot('Article', tempId)?.data as { title: string })?.title).toBe('Draft') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: tempId, + changeType: 'create', + }) }) test('should commit changes on success with optimistic mode', async () => { @@ -262,7 +288,7 @@ describe('pessimistic update mode', () => { expect((store.getEntitySnapshot('Article', '2')?.data as { title: string })?.title).toBe('Updated 2') }) - test('should keep server state on batch failure in pessimistic mode', async () => { + test('should preserve local edits for retry on batch failure in pessimistic mode', async () => { const adapter = createFailingAdapter() const persister = new BatchPersister(adapter, store, dispatcher) @@ -280,9 +306,11 @@ describe('pessimistic update mode', () => { // Should fail expect(result.success).toBe(false) - // Both should be at server state (original) - expect((store.getEntitySnapshot('Article', '1')?.data as { title: string })?.title).toBe('Title 1') - expect((store.getEntitySnapshot('Article', '2')?.data as { title: string })?.title).toBe('Title 2') + // P2: both edits are preserved (kept dirty) for a retry rather than reverted. + expect((store.getEntitySnapshot('Article', '1')?.data as { title: string })?.title).toBe('Updated 1') + expect((store.getEntitySnapshot('Article', '2')?.data as { title: string })?.title).toBe('Updated 2') + expect((store.getEntitySnapshot('Article', '1')?.serverData as { title: string })?.title).toBe('Title 1') + expect((store.getEntitySnapshot('Article', '2')?.serverData as { title: string })?.title).toBe('Title 2') }) }) }) diff --git a/tests/unit/store/reachabilityCreateDetection.test.ts b/tests/unit/store/reachabilityCreateDetection.test.ts new file mode 100644 index 00000000..1a4608e5 --- /dev/null +++ b/tests/unit/store/reachabilityCreateDetection.test.ts @@ -0,0 +1,143 @@ +// Reachability-based create detection (Phase 1+2a of the dirty-detection rework). +// +// A created (never-persisted) entity is reported as a `create` ONLY when it is +// reachable from a root through live relations. Roots are server entities plus +// freshly-created entities that have not been anchored as a child of any relation +// (createEntity auto-roots; registerParentChild un-roots). +// +// These tests exercise the analyzer directly through the store's public API. They +// deliberately construct lingering orphan snapshots (created, then unrooted / +// disconnected without an eager purge) to prove the reachability gate excludes +// them — the behavior that lets us delete the eager-purge machinery in Phase 2b. +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' + +describe('reachability create detection', () => { + let store: SnapshotStore + + beforeEach(() => { + store = new SnapshotStore() + }) + + const creates = () => store.getAllDirtyEntities().filter(e => e.changeType === 'create') + const createIds = () => creates().map(e => e.entityId).sort() + + test('a freshly created top-level entity is a create (auto-rooted)', () => { + const id = store.createEntity('Article', { title: 'New' }) + expect(creates()).toEqual([{ entityType: 'Article', entityId: id, changeType: 'create' }]) + }) + + test('an unrooted lingering orphan is NOT a create, even though its snapshot exists', () => { + const id = store.createEntity('Comment', { text: 'x' }) + // Simulate a created entity that has been detached from everything but whose + // snapshot has not been purged. Under the old snapshot-existence rule this was + // a phantom create; under reachability it is correctly ignored. + store.unregisterRootEntity('Comment', id) + + expect(store.getAllDirtyEntities()).toEqual([]) + expect(store.hasEntity('Comment', id)).toBe(true) + }) + + test('a created child of a server parent is reachable via the relation', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const cId = store.createEntity('Comment', { text: 'new' }) + store.addToHasMany('Article', 'a-1', 'comments', cId) + store.registerParentChild('Article', 'a-1', 'Comment', cId) + + expect(creates()).toEqual([{ entityType: 'Comment', entityId: cId, changeType: 'create' }]) + }) + + test('a created chain under a server root is fully reported', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const cId = store.createEntity('Comment', { text: 'c' }) + store.addToHasMany('Article', 'a-1', 'comments', cId) + store.registerParentChild('Article', 'a-1', 'Comment', cId) + const rId = store.createEntity('Reaction', { kind: 'like' }) + store.addToHasMany('Comment', cId, 'reactions', rId) + store.registerParentChild('Comment', cId, 'Reaction', rId) + + expect(createIds()).toEqual([cId, rId].sort()) + }) + + test('detaching the root drops the whole created subtree without any cascade purge', () => { + const cId = store.createEntity('Comment', { text: 'c' }) + const rId = store.createEntity('Reaction', { kind: 'like' }) + store.addToHasMany('Comment', cId, 'reactions', rId) + store.registerParentChild('Comment', cId, 'Reaction', rId) + + // Both snapshots still exist; only the root link is gone. + store.unregisterRootEntity('Comment', cId) + + expect(store.getAllDirtyEntities()).toEqual([]) + expect(store.hasEntity('Comment', cId)).toBe(true) + expect(store.hasEntity('Reaction', rId)).toBe(true) + }) + + test('disconnecting a has-one drops the created target from creates', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const auId = store.createEntity('Author', { name: 'draft' }) + store.setRelation('Article', 'a-1', 'author', { currentId: auId, state: 'connected' }) + store.registerParentChild('Article', 'a-1', 'Author', auId) + + expect(creates()).toEqual([{ entityType: 'Author', entityId: auId, changeType: 'create' }]) + + // Disconnect at the relation level; the Author snapshot lingers but is no + // longer reachable. + store.setRelation('Article', 'a-1', 'author', { currentId: null, state: 'disconnected' }) + + expect(creates()).toEqual([]) + expect(store.hasEntity('Author', auId)).toBe(true) + }) + + test('a created entity shared by two server parents stays a create until both drop it', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'A' }, true) + store.setEntityData('Article', 'a-2', { id: 'a-2', title: 'B' }, true) + const tagId = store.createEntity('Tag', { name: 'shared' }) + store.addToHasMany('Article', 'a-1', 'tags', tagId) + store.registerParentChild('Article', 'a-1', 'Tag', tagId) + store.planHasManyConnection('Article', 'a-2', 'tags', tagId) + store.registerParentChild('Article', 'a-2', 'Tag', tagId) + + expect(createIds()).toEqual([tagId]) + + // Drop from the first list; still reachable via the second. + store.removeFromHasMany('Article', 'a-1', 'tags', tagId, 'disconnect') + expect(createIds()).toEqual([tagId]) + }) + + test('sweepUnreachableCreated removes detached orphans but keeps live creates', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + // Live create: child of a server parent. + const cId = store.createEntity('Comment', { text: 'live' }) + store.addToHasMany('Article', 'a-1', 'comments', cId) + store.registerParentChild('Article', 'a-1', 'Comment', cId) + // Live create: top-level root. + const topId = store.createEntity('Article', { title: 'draft' }) + // Orphan: created then detached, referenced by nothing. + const orphanId = store.createEntity('Comment', { text: 'orphan' }) + store.unregisterRootEntity('Comment', orphanId) + + store.sweepUnreachableCreated() + + expect(store.hasEntity('Comment', orphanId)).toBe(false) + expect(store.hasEntity('Comment', cId)).toBe(true) + expect(store.hasEntity('Article', topId)).toBe(true) + expect(createIds()).toEqual([cId, topId].sort()) + }) + + test('server entity edits and deletions are unaffected by the create gate', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Edited') + expect(store.getAllDirtyEntities()).toEqual([ + { entityType: 'Article', entityId: 'a-1', changeType: 'update' }, + ]) + + store.setEntityData('Article', 'a-2', { id: 'a-2', title: 'D' }, true) + store.scheduleForDeletion('Article', 'a-2') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: 'a-2', + changeType: 'delete', + }) + }) +}) diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index 262726df..2cd7cd4e 100644 --- a/tests/unit/store/snapshotStore.test.ts +++ b/tests/unit/store/snapshotStore.test.ts @@ -439,26 +439,6 @@ describe('SnapshotStore', () => { }) }) - describe('cancelHasManyRemoval', () => { - test('should cancel planned removal', () => { - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) - store.planHasManyRemoval('Article', 'a-1', 'tags', 't-1', 'disconnect') - store.cancelHasManyRemoval('Article', 'a-1', 'tags', 't-1') - - const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedRemovals.has('t-1')).toBe(false) - }) - - test('should cancel delete-type planned removal', () => { - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) - store.planHasManyRemoval('Article', 'a-1', 'tags', 't-1', 'delete') - store.cancelHasManyRemoval('Article', 'a-1', 'tags', 't-1') - - const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedRemovals.has('t-1')).toBe(false) - }) - }) - describe('planHasManyConnection', () => { test('should plan connection', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags') From dfc19661ffa914e533e3eba7657c45991f4cbd23 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 19:20:53 +0200 Subject: [PATCH 03/28] =?UTF-8?q?fix(bindx):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20pessimistic=20partial-failure=20data=20loss,=20u?= =?UTF-8?q?nmount=20cleanup,=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PERSIST-1: on a failed pessimistic transaction restore ALL captured states (not only the ones whose own mutation failed) and gate the post-persist sweep to full success, so a succeeded-but-reset parent and its inline-created child survive a partial batch failure (the default non-atomic path) instead of being stranded/swept - PERSIST-2: restore relation placeholderData on commit=false so an inline has-one create and its dirty signal survive a pessimistic retry - REACT-1: make the create-mode / list unmount cleanup reachability-aware (unregisterRootEntity + sweepUnreachableCreated) so a draft connected into another live parent (diamond) is preserved instead of hard-removed - REACT-2: re-seed the create-mode draft under the same temp id so the form survives a React StrictMode mount cycle - PERF-1: early-out computeReachableCreated when no never-persisted snapshot exists, keeping the common update-only dirty check off the O(V*(R+H)) graph walk - cleanup: drop dead RootRegistry.has() and EntitySnapshotStore.findByEntityId (isNeverPersisted resolves via keyForId), extract ActionDispatcher disconnectRelation helper, guard idIndex removal against id collisions, document removeEntity's no-inbound-cleanup contract - tests: partial-failure pessimistic (incl. inline-child survival), unmount cleanup (discard / persisted-kept / diamond / StrictMode), getLiveChildIds deleted-edge Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T4RHL9A7AGr5gMq6d4Qhk7 --- .../bindx-react/src/hooks/useEntityList.ts | 13 +- .../bindx-react/src/jsx/components/Entity.tsx | 24 +- packages/bindx/src/core/ActionDispatcher.ts | 36 ++- .../bindx/src/persistence/BatchPersister.ts | 53 +++-- .../bindx/src/store/EntitySnapshotStore.ts | 20 +- .../bindx/src/store/ReachabilityAnalyzer.ts | 24 +- packages/bindx/src/store/RootRegistry.ts | 4 - packages/bindx/src/store/SnapshotStore.ts | 25 ++- tests/cases/entityUnmountCleanup.test.tsx | 207 ++++++++++++++++++ tests/unit/persistence/pessimistic.test.ts | 107 +++++++++ .../store/reachabilityCreateDetection.test.ts | 20 ++ 11 files changed, 455 insertions(+), 78 deletions(-) create mode 100644 tests/cases/entityUnmountCleanup.test.tsx diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 78925b46..15390e63 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -284,15 +284,22 @@ export function useEntityList( ) // Discard never-persisted drafts when the list unmounts. List-level creates are - // top-level roots (nothing anchors them), so the lazy sweep never reclaims them; - // removing them here unregisters the root and frees the snapshot. + // top-level roots (nothing else anchors them); dropping the root and running the + // reachability-aware sweep frees a truly-orphaned draft while preserving one that + // was meanwhile connected into another live parent (the diamond / shared-create + // case). A persisted draft (rekeyed to a server id) is left untouched. useEffect(() => { return () => { + let unrooted = false for (const item of listStateRef.current.items) { if (isTempId(item.id) && !store.getPersistedId(entityType, item.id)) { - store.removeEntity(entityType, item.id) + store.unregisterRootEntity(entityType, item.id) + unrooted = true } } + if (unrooted) { + store.sweepUnreachableCreated() + } } }, [store, entityType]) diff --git a/packages/bindx-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index 77ec1530..f229ca83 100644 --- a/packages/bindx-react/src/jsx/components/Entity.tsx +++ b/packages/bindx-react/src/jsx/components/Entity.tsx @@ -185,17 +185,27 @@ function EntityCreateMode({ return id }, [entityType, store]) - // Discard a never-persisted create when this form unmounts, so its snapshot - // and root registration do not leak. A persisted entity (rekeyed to a server - // id) is left untouched. + // Lifecycle of the draft entity: + // - Re-seed it under the SAME temp id if a previous unmount's cleanup removed + // it. This keeps the form bound across a React StrictMode mount→cleanup→mount + // cycle (effects run twice in dev), where the memoized id survives but the + // snapshot would otherwise be gone. No-op on first mount and after the entity + // was persisted (rekeyed to a server id). + // - On unmount, discard a never-persisted draft. Drop its root registration and + // let the reachability-aware sweep reclaim it ONLY if nothing else references + // it — a draft connected into another live parent stays reachable and survives + // (the diamond / shared-create case). A persisted entity is left untouched. useEffect(() => { + if (!store.getPersistedId(entityType, tempId) && !store.hasEntity(entityType, tempId)) { + store.createEntity(entityType, { id: tempId }) + } return () => { - const id = tempIdRef.current - if (id && !store.getPersistedId(entityType, id)) { - store.removeEntity(entityType, id) + if (!store.getPersistedId(entityType, tempId)) { + store.unregisterRootEntity(entityType, tempId) + store.sweepUnreachableCreated() } } - }, [store, entityType]) + }, [store, entityType, tempId]) // Subscribe to store changes for this entity const subscribe = useCallback( diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 09fb6af2..bd66eda0 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -177,6 +177,20 @@ export class ActionDispatcher { } } + /** + * Reverts a has-one relation to the disconnected state. Shared by + * DISCONNECT_RELATION and the never-persisted arm of DELETE_RELATION (deleting a + * never-created target just cancels its creation — there is no server row to + * delete), so the "disconnected" semantics live in one place. + */ + private disconnectRelation(entityType: string, entityId: string, fieldName: string): void { + this.store.setRelation(entityType, entityId, fieldName, { + currentId: null, + state: 'disconnected', + placeholderData: {}, + }) + } + /** * Executes an action, updating the store. */ @@ -231,16 +245,7 @@ export class ActionDispatcher { } case 'DISCONNECT_RELATION': { - this.store.setRelation( - action.entityType, - action.entityId, - action.fieldName, - { - currentId: null, - state: 'disconnected', - placeholderData: {}, - }, - ) + this.disconnectRelation(action.entityType, action.entityId, action.fieldName) break } @@ -251,16 +256,7 @@ export class ActionDispatcher { // parent is not spuriously dirtied by a 'deleted' state; reachability then // drops the orphaned create. A server target gets the normal 'deleted'. if (deletedId && this.store.isNeverPersisted(deletedId)) { - this.store.setRelation( - action.entityType, - action.entityId, - action.fieldName, - { - currentId: null, - state: 'disconnected', - placeholderData: {}, - }, - ) + this.disconnectRelation(action.entityType, action.entityId, action.fieldName) } else { this.store.setRelation( action.entityType, diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 3410a33d..f0e3339c 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -222,6 +222,7 @@ export class BatchPersister { capturedStates = this.captureEntityStates(sortedEntities) } + let result: PersistenceResult | undefined try { // Build mutations BEFORE resetting state (for pessimistic mode) // The mutations need to contain the dirty data to send to server @@ -229,13 +230,14 @@ export class BatchPersister { if (mutations.length === 0) { // Nothing to persist - return { + result = { success: true, results: [], successCount: 0, failedCount: 0, skippedCount: 0, } + return result } // For pessimistic mode, reset entities to server state after capturing @@ -254,7 +256,8 @@ export class BatchPersister { const transactionResult = await this.executeTransaction(mutations, options?.signal) // Process results with captured state for pessimistic mode - return this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates) + result = this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates) + return result } finally { // Clear in-flight status @@ -269,9 +272,12 @@ export class BatchPersister { this.undoManager?.unblock() // Reclaim memory: drop snapshots of any created entities orphaned during - // editing. Runs once the persist has settled (state restored, persisting - // cleared), so live in-flight creates are never swept. - this.store.sweepUnreachableCreated() + // editing. Only after a FULLY successful persist — on failure the captured + // creates/edits were restored for retry (see processTransactionResult), and + // sweeping then could reclaim a create the user still intends to save. + if (result?.success) { + this.store.sweepUnreachableCreated() + } } } @@ -752,19 +758,10 @@ export class BatchPersister { mutationResult.errorMessage, ) - // In pessimistic mode the entity was reset to its server view before the - // transaction. On failure, restore its captured dirty state WITHOUT - // committing (P2) so the user's edits and creates survive for a retry - // instead of being stuck showing server data. Optimistic mode rolls back - // to server state only when rollbackOnError is set. - if (isPessimistic && capturedStates) { - const capturedState = capturedStates.find( - s => s.entityType === entity.entityType && s.entityId === entity.entityId, - ) - if (capturedState) { - this.restoreEntityState(capturedState, false) - } - } else if (rollbackOnError) { + // Optimistic mode rolls a FAILED entity back to server state only when + // rollbackOnError is set. Pessimistic restore is handled below for ALL + // captured entities, not just the failed ones. + if (!isPessimistic && rollbackOnError) { this.rollbackEntity(entity) } } @@ -788,6 +785,20 @@ export class BatchPersister { } } } + + // Pessimistic mode: the transaction failed, so from the user's point of view + // nothing was committed. Undo the pre-transaction server-view reset for EVERY + // captured entity (not only the ones whose individual mutation failed) and keep + // the restored data dirty (commit=false) so the user can retry. Restoring even + // the entities whose mutation happened to succeed in the non-atomic sequential + // fallback is what re-establishes their relation edges to inline-created + // children — keeping those creates reachable so the post-persist sweep does not + // reclaim a child the user still intends to save (P2). + if (isPessimistic && capturedStates) { + for (const capturedState of capturedStates) { + this.restoreEntityState(capturedState, false) + } + } } return { @@ -844,6 +855,12 @@ export class BatchPersister { this.store.setRelation(capturedState.entityType, capturedState.entityId, fieldName, { currentId: relationState.currentId, state: relationState.state, + // Restore placeholderData too: a has-one in the 'creating' state carries + // its inline create payload here, and getDirtyRelations treats a + // non-empty placeholderData as a dirty signal. Dropping it on a + // commit=false retry would lose the inline data and silently mark the + // relation clean. (For commit=true it is cleared by commitRelation below.) + placeholderData: relationState.placeholderData, }) if (commit) { this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName) diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index a34c7c92..4d0fc363 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -201,7 +201,12 @@ export class EntitySnapshotStore { */ remove(key: string): void { const existing = this.snapshots.get(key) - if (existing) { + // Only drop the id→key entry when it still points at THIS key. Ids are + // globally unique by construction (server UUIDs / minted temp ids), but if a + // caller-supplied id ever collided across types, last-writer-wins means + // another snapshot now owns the entry — removing this one must not strand + // that survivor as id-unresolvable. + if (existing && this.idIndex.get(existing.id) === key) { this.idIndex.delete(existing.id) } this.snapshots.delete(key) @@ -313,21 +318,14 @@ export class EntitySnapshotStore { /** * Resolves an entity id (without its type) to its composite key in O(1). + * Relation state records only child ids, not their types, so this is how a + * reachability walk (and {@link SnapshotStore.isNeverPersisted}) maps a child + * id back to its snapshot. Ids are globally unique, so the mapping is unambiguous. */ keyForId(id: string): string | undefined { return this.idIndex.get(id) } - /** - * Finds a snapshot by its entity id alone (without knowing the entity type). - * Used during cascade purges where a relation only records child ids, not their - * types. Temp ids are globally unique, so the first match is unambiguous. - */ - findByEntityId(id: string): EntitySnapshot | undefined { - const key = this.idIndex.get(id) - return key ? this.snapshots.get(key) : undefined - } - clear(): void { this.snapshots.clear() this.idIndex.clear() diff --git a/packages/bindx/src/store/ReachabilityAnalyzer.ts b/packages/bindx/src/store/ReachabilityAnalyzer.ts index 65baa295..10e211de 100644 --- a/packages/bindx/src/store/ReachabilityAnalyzer.ts +++ b/packages/bindx/src/store/ReachabilityAnalyzer.ts @@ -36,16 +36,28 @@ export class ReachabilityAnalyzer { const visited = new Set() const stack: string[] = [] - // Seed: every server entity is a root, and so is every in-flight (persisting) - // entity. The latter keeps a created entity live while it is mid-persist — - // e.g. pessimistic mode temporarily resets an update parent to its server - // view, which would otherwise make a freshly-created child look unreachable - // during the async transaction window. + // Seed roots and, in the same pass, detect whether any never-persisted + // (created) snapshot exists at all. Every server entity is a root, and so is + // every in-flight (persisting) entity — the latter keeps a created entity + // live while it is mid-persist (e.g. pessimistic mode temporarily resets an + // update parent to its server view, which would otherwise make a freshly + // created child look unreachable during the async transaction window). + let hasCreated = false for (const key of this.entitySnapshots.keys()) { - if (this.meta.existsOnServer(key) || this.meta.isPersisting(key)) { + const onServer = this.meta.existsOnServer(key) + if (!onServer) hasCreated = true + if (onServer || this.meta.isPersisting(key)) { stack.push(key) } } + + // Fast path: with no created snapshot anywhere, nothing can be a reachable + // create. Skip the O(V·(R+H)) graph walk entirely — the common update-only + // form/grid pays only the single existsOnServer scan above (which the seed + // loop performs regardless) instead of walking every server entity's edges + // on every dirty check (this runs on each store notification, ≈ keystroke). + if (!hasCreated) return reachableCreated + // Seed: explicitly-registered top-level creates (no persisted parent). for (const key of this.roots.keys()) { if (this.entitySnapshots.has(key)) { diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts index 3f393446..01466e00 100644 --- a/packages/bindx/src/store/RootRegistry.ts +++ b/packages/bindx/src/store/RootRegistry.ts @@ -24,10 +24,6 @@ export class RootRegistry { this.roots.delete(key) } - has(key: string): boolean { - return this.roots.has(key) - } - keys(): IterableIterator { return this.roots.keys() } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index be9db213..19e9eaea 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -248,10 +248,13 @@ export class SnapshotStore implements SnapshotVersionBumper { * metadata, root registration, errors, touched state, propagation tracking, * and owned relation state). * - * This does NOT cascade into descendants. With reachability-based create - * detection a detached created entity is simply unreachable and produces no - * mutation, so stranded descendants are harmless — they are collected by the - * lazy memory sweep rather than by an eager cascade. + * This does NOT cascade into descendants, and it does NOT strip inbound edges + * (other entities' has-many membership / has-one currentId that point AT the + * removed id). Callers must therefore only remove an entity that is unreachable + * — i.e. no live parent relation references it — so the graph cannot dangle. The + * lazy {@link sweepUnreachableCreated} guarantees this by construction; the React + * unmount cleanup routes through it (via {@link unregisterRootEntity} + a sweep) + * rather than calling removeEntity on a possibly-referenced id. */ removeEntity(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) @@ -276,9 +279,9 @@ export class SnapshotStore implements SnapshotVersionBumper { * row to delete. */ isNeverPersisted(entityId: string): boolean { - const snapshot = this.entitySnapshots.findByEntityId(entityId) - if (!snapshot) return false - return !this.meta.existsOnServer(`${snapshot.entityType}:${snapshot.id}`) + const key = this.entitySnapshots.keyForId(entityId) + if (!key) return false + return !this.meta.existsOnServer(key) } // ==================== Load State (delegated to EntityMetaStore) ==================== @@ -352,8 +355,12 @@ export class SnapshotStore implements SnapshotVersionBumper { } /** - * Unregisters a top-level root (e.g. when its owning component unmounts or the - * entity is removed from a top-level list). + * Unregisters a top-level root WITHOUT removing its snapshot. Called by the React + * unmount cleanup (a `` form / `useEntityList` draft) before a + * {@link sweepUnreachableCreated} pass: dropping the root lets the sweep reclaim a + * truly-orphaned create, while a create still anchored by another live relation + * stays reachable and survives. (Un-rooting also happens implicitly via + * {@link registerParentChild} and the rekey in {@link mapTempIdToPersistedId}.) */ unregisterRootEntity(entityType: string, id: string): void { this.roots.unregister(this.getEntityKey(entityType, id)) diff --git a/tests/cases/entityUnmountCleanup.test.tsx b/tests/cases/entityUnmountCleanup.test.tsx new file mode 100644 index 00000000..8d0ad6bf --- /dev/null +++ b/tests/cases/entityUnmountCleanup.test.tsx @@ -0,0 +1,207 @@ +import '../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React, { StrictMode } from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + hasOne, + hasMany, + Entity, + useBindxContext, + useEntityList, + type SnapshotStore, +} from '@contember/bindx-react' + +afterEach(() => { + cleanup() +}) + +interface Author { id: string; name: string } +interface Article { id: string; title: string; author: Author; tags: Tag[] } +interface Tag { id: string; label: string } + +interface TestSchema { + Article: Article + Author: Author + Tag: Tag +} + +const schema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + author: hasOne('Author'), + tags: hasMany('Tag'), + }, + }, + Author: { fields: { id: scalar(), name: scalar() } }, + Tag: { fields: { id: scalar(), label: scalar() } }, + }, +}) + +const entityDefs = { + Article: entityDef
('Article'), + Author: entityDef('Author'), + Tag: entityDef('Tag'), +} as const + +function CaptureStore({ onStore }: { onStore: (s: SnapshotStore) => void }): null { + onStore(useBindxContext().store) + return null +} + +const isCreate = (store: SnapshotStore, type: string, id: string): boolean => + store.getAllDirtyEntities().some(e => e.entityType === type && e.entityId === id && e.changeType === 'create') + +describe('Entity create-mode unmount cleanup', () => { + test('discards a never-persisted draft on unmount', async () => { + const adapter = new MockAdapter({}) + let store!: SnapshotStore + let draftId!: string + + const { unmount } = render( + + { store = s }} /> + + {author => { draftId = author.id; return {author.id} }} + + , + ) + + await waitFor(() => expect(store.hasEntity('Author', draftId)).toBe(true)) + expect(isCreate(store, 'Author', draftId)).toBe(true) + + act(() => { unmount() }) + + // The draft (a top-level root referenced by nothing) is reclaimed. + expect(store.hasEntity('Author', draftId)).toBe(false) + expect(isCreate(store, 'Author', draftId)).toBe(false) + }) + + test('leaves a persisted (rekeyed) entity untouched on unmount', async () => { + const adapter = new MockAdapter({}) + let store!: SnapshotStore + let draftId!: string + + const { unmount } = render( + + { store = s }} /> + + {author => { draftId = author.id; return {author.id} }} + + , + ) + + await waitFor(() => expect(store.hasEntity('Author', draftId)).toBe(true)) + + // Simulate a successful persist: temp id rekeyed to a server id. + act(() => { store.mapTempIdToPersistedId('Author', draftId, 'author-server-1') }) + + act(() => { unmount() }) + + // The now-persisted entity must survive the unmount. + expect(store.hasEntity('Author', 'author-server-1')).toBe(true) + }) + + test('preserves a draft still referenced by another live parent (diamond)', async () => { + const adapter = new MockAdapter({}) + let store!: SnapshotStore + let draftId!: string + + const { unmount } = render( + + { store = s }} /> + + {author => { draftId = author.id; return {author.id} }} + + , + ) + + await waitFor(() => expect(store.hasEntity('Author', draftId)).toBe(true)) + + // Meanwhile the draft is connected into a live server parent (un-roots it). + act(() => { + store.setEntityData('Article', 'art-1', { id: 'art-1', title: 'T' }, true) + store.setRelation('Article', 'art-1', 'author', { currentId: draftId, state: 'connected' }) + store.registerParentChild('Article', 'art-1', 'Author', draftId) + }) + + act(() => { unmount() }) + + // The form unmounted, but the draft is still reachable through art-1 — it must + // survive and remain a pending create (the diamond / shared-create case). + expect(store.hasEntity('Author', draftId)).toBe(true) + expect(isCreate(store, 'Author', draftId)).toBe(true) + }) + + test('survives a React StrictMode mount cycle (re-seeds the draft)', async () => { + const adapter = new MockAdapter({}) + let store!: SnapshotStore + let draftId!: string + + render( + + + { store = s }} /> + + {author => { draftId = author.id; return {author.id} }} + + + , + ) + + // StrictMode runs mount→cleanup→mount; the cleanup removes the draft, so the + // re-seed must re-establish it under the same id or the form is bound to a + // phantom entity. + await waitFor(() => expect(store.hasEntity('Author', draftId)).toBe(true)) + expect(isCreate(store, 'Author', draftId)).toBe(true) + }) +}) + +describe('useEntityList unmount cleanup', () => { + test('discards a never-persisted list draft on unmount', async () => { + const adapter = new MockAdapter({}) + let store!: SnapshotStore + let draftId: string | undefined + + function List(): React.ReactElement { + const list = useEntityList(entityDefs.Author, {}, a => a.name()) + return ( + + ) + } + + const { container, unmount } = render( + + { store = s }} /> + + , + ) + + // Wait until the list has loaded (its empty server page) before adding. + await waitFor(() => + expect(container.querySelector('[data-testid="add"]')?.getAttribute('data-status')).toBe('ready'), + ) + + act(() => { (container.querySelector('[data-testid="add"]') as HTMLButtonElement).click() }) + expect(draftId).toBeDefined() + expect(store.hasEntity('Author', draftId!)).toBe(true) + + act(() => { unmount() }) + + expect(store.hasEntity('Author', draftId!)).toBe(false) + expect(isCreate(store, 'Author', draftId!)).toBe(false) + }) +}) diff --git a/tests/unit/persistence/pessimistic.test.ts b/tests/unit/persistence/pessimistic.test.ts index 684006da..14da2299 100644 --- a/tests/unit/persistence/pessimistic.test.ts +++ b/tests/unit/persistence/pessimistic.test.ts @@ -3,11 +3,39 @@ import { SnapshotStore, ActionDispatcher, BatchPersister, + SchemaRegistry, + type SchemaDefinition, type BackendAdapter, type TransactionResult, type TransactionMutation, } from '@contember/bindx' +interface PersistSchema { + Article: { id: string; title: string; comments?: Array<{ id: string; text: string }> } + Comment: { id: string; text: string } + [key: string]: object +} + +// Minimal schema enabling relation persistence (auto-creates a MutationCollector), +// so a created child nests into its parent's mutation. +const persistSchema: SchemaDefinition = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + comments: { type: 'hasMany', target: 'Comment', relationKind: 'oneHasMany' }, + }, + }, + Comment: { + fields: { + id: { type: 'scalar' }, + text: { type: 'scalar' }, + }, + }, + }, +} + // Helper to create a mock adapter function createMockAdapter(options?: { failAll?: boolean @@ -312,5 +340,84 @@ describe('pessimistic update mode', () => { expect((store.getEntitySnapshot('Article', '1')?.serverData as { title: string })?.title).toBe('Title 1') expect((store.getEntitySnapshot('Article', '2')?.serverData as { title: string })?.title).toBe('Title 2') }) + + // PERSIST-1 regression: on a PARTIAL failure (one mutation succeeds, the + // transaction fails overall via the non-atomic sequential fallback — the only + // path any real adapter takes), the succeeded-but-reset entity must NOT be + // stranded at the server view. Every captured entity is restored for retry. + test('should restore even the individually-succeeded entity on a partial batch failure', async () => { + // One mutation succeeds, the other fails → transaction reported as failed. + const adapter: BackendAdapter = { + query: mock(() => Promise.resolve([])), + persist: mock((_entityType: string, id: string) => + id === '2' + ? Promise.resolve({ ok: false, errorMessage: 'Failed 2' }) + : Promise.resolve({ ok: true }), + ), + create: mock(() => Promise.resolve({ ok: true, data: {} })), + delete: mock(() => Promise.resolve({ ok: true })), + } + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', '1', { id: '1', title: 'Title 1' }, true) + store.setEntityData('Article', '2', { id: '2', title: 'Title 2' }, true) + store.setFieldValue('Article', '1', ['title'], 'Updated 1') + store.setFieldValue('Article', '2', ['title'], 'Updated 2') + + const result = await persister.persistAll({ updateMode: 'pessimistic' }) + expect(result.success).toBe(false) + + // '1' succeeded individually but the transaction failed — it must be restored + // to its dirty edit (not left showing the reset server view) and kept dirty. + expect((store.getEntitySnapshot('Article', '1')?.data as { title: string })?.title).toBe('Updated 1') + expect((store.getEntitySnapshot('Article', '2')?.data as { title: string })?.title).toBe('Updated 2') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', entityId: '1', changeType: 'update', + }) + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', entityId: '2', changeType: 'update', + }) + }) + + // PERSIST-1 regression: an inline-created child of a reset parent must survive a + // partial batch failure — restore-all re-establishes the parent's edge so the + // child stays reachable, and the post-persist sweep runs only on success. + test('should preserve an inline-created child of a succeeded parent on partial batch failure', async () => { + const schema = new SchemaRegistry(persistSchema) + const adapter: BackendAdapter = { + query: mock(() => Promise.resolve([])), + persist: mock((_entityType: string, id: string) => + id === '2' + ? Promise.resolve({ ok: false, errorMessage: 'Failed 2' }) + : Promise.resolve({ ok: true, data: { id } }), + ), + create: mock(() => Promise.resolve({ ok: true, data: {} })), + delete: mock(() => Promise.resolve({ ok: true })), + } + const persister = new BatchPersister(adapter, store, dispatcher, { schema }) + + // Server parent A with a created child + a dirty title (so A is an update that + // gets reset pre-transaction), plus an unrelated server parent B that fails. + store.setEntityData('Article', '1', { id: '1', title: 'A', comments: [] }, true) + store.setEntityData('Article', '2', { id: '2', title: 'B' }, true) + store.setFieldValue('Article', '1', ['title'], 'A edited') + store.setFieldValue('Article', '2', ['title'], 'B edited') + + const childId = store.createEntity('Comment', { text: 'draft' }) + store.addToHasMany('Article', '1', 'comments', childId) + store.registerParentChild('Article', '1', 'Comment', childId) + + const result = await persister.persistAll({ updateMode: 'pessimistic' }) + expect(result.success).toBe(false) + + // The created child must NOT be swept: A's edge is restored (reachable again) + // and the sweep is gated to full success. It survives as a pending create. + expect(store.hasEntity('Comment', childId)).toBe(true) + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Comment', entityId: childId, changeType: 'create', + }) + // A's own edit is preserved for retry as well. + expect((store.getEntitySnapshot('Article', '1')?.data as { title: string })?.title).toBe('A edited') + }) }) }) diff --git a/tests/unit/store/reachabilityCreateDetection.test.ts b/tests/unit/store/reachabilityCreateDetection.test.ts index 1a4608e5..32bff85d 100644 --- a/tests/unit/store/reachabilityCreateDetection.test.ts +++ b/tests/unit/store/reachabilityCreateDetection.test.ts @@ -140,4 +140,24 @@ describe('reachability create detection', () => { changeType: 'delete', }) }) + + // getLiveChildIds contract: a has-one edge in the 'deleted' state must NOT count + // its target as a live child — the target is being removed by that edge, so it no + // longer anchors a created entity reachable only through it. (Server targets stay + // reachable as roots regardless; this guards the created-only case.) + test('a created target reached only through a deleted has-one is NOT a create', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const auId = store.createEntity('Author', { name: 'draft' }) + + // Reach the created Author solely through a has-one whose state is 'deleted', + // and strip its top-level root so the deleted edge is its only path. + store.setRelation('Article', 'a-1', 'author', { currentId: auId, state: 'deleted' }) + store.unregisterRootEntity('Author', auId) + + expect(creates()).toEqual([]) + + // Contrast: a live ('connected') edge to the same created target makes it a create. + store.setRelation('Article', 'a-1', 'author', { currentId: auId, state: 'connected' }) + expect(creates()).toEqual([{ entityType: 'Author', entityId: auId, changeType: 'create' }]) + }) }) From b433c80d3acc96a2069821aa362c35e5ffd7c65c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:33:33 +0200 Subject: [PATCH 04/28] perf(bindx): memoize reachability walk via sub-store mutation counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeReachableCreated() is an O(E+R) walk run on every dirty check and every post-persist sweep, previously recomputed from scratch each time. Each sub-store the walk reads (entity snapshots, meta, relations, roots) now exposes a monotonic getMutationVersion() bumped only when graph-relevant state changes — the entity key set / id index, existsOnServer / isPersisting, the root set, and relation edges. Pure value edits (setFieldValue/updateFields/...) and the per-render no-op getOrCreate* calls do not bump any counter, so the hot path stays warm. ReachabilityAnalyzer caches its result keyed on the sum of those counters. The sum is strictly increasing, so an unchanged sum proves nothing relevant changed and the cached set is returned without re-walking. All RelationStore map writes are routed through writeRelation/writeHasMany helpers so the bump cannot be missed; EntitySnapshotStore and EntityMetaStore bump selectively to avoid invalidating on value-only edits; RootRegistry bumps only on an actual change so the per-render registerParentChild -> unregister no-op does not thrash the cache. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/EntityMetaStore.ts | 39 +++++++-- .../bindx/src/store/EntitySnapshotStore.ts | 18 ++++ .../bindx/src/store/ReachabilityAnalyzer.ts | 37 ++++++++ packages/bindx/src/store/RelationStore.ts | 84 ++++++++++++------- packages/bindx/src/store/RootRegistry.ts | 24 +++++- 5 files changed, 167 insertions(+), 35 deletions(-) diff --git a/packages/bindx/src/store/EntityMetaStore.ts b/packages/bindx/src/store/EntityMetaStore.ts index f2de0bf5..297d535a 100644 --- a/packages/bindx/src/store/EntityMetaStore.ts +++ b/packages/bindx/src/store/EntityMetaStore.ts @@ -39,6 +39,18 @@ export class EntityMetaStore { /** Mapping from temp ID to persisted ID (keyed by "entityType:tempId") */ private readonly tempToPersistedId = new Map() + /** + * Monotonic counter bumped when reachability-relevant metadata changes — + * `existsOnServer` and `isPersisting` (which seed the reachability roots) plus + * entity removal/rekey. Load state and deletion scheduling do NOT bump it. + * Used by {@link ReachabilityAnalyzer} to memoize its walk. + */ + private mutationVersion = 0 + + getMutationVersion(): number { + return this.mutationVersion + } + // ==================== Load State ==================== getLoadState(key: string): EntityLoadState | undefined { @@ -60,8 +72,15 @@ export class EntityMetaStore { } setExistsOnServer(key: string, existsOnServer: boolean): void { - const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false } - this.entityMetas.set(key, { ...existing, existsOnServer }) + const existing = this.entityMetas.get(key) + if (existing && existing.existsOnServer === existsOnServer) { + return + } + this.entityMetas.set(key, { + existsOnServer, + isScheduledForDeletion: existing?.isScheduledForDeletion ?? false, + }) + this.mutationVersion++ } existsOnServer(key: string): boolean { @@ -90,9 +109,12 @@ export class EntityMetaStore { setPersisting(key: string, isPersisting: boolean): void { if (isPersisting) { - this.persistingEntities.add(key) - } else { - this.persistingEntities.delete(key) + if (!this.persistingEntities.has(key)) { + this.persistingEntities.add(key) + this.mutationVersion++ + } + } else if (this.persistingEntities.delete(key)) { + this.mutationVersion++ } } @@ -123,6 +145,7 @@ export class EntityMetaStore { this.entityMetas.delete(key) this.persistingEntities.delete(key) this.tempToPersistedId.delete(key) + this.mutationVersion++ } /** @@ -152,6 +175,8 @@ export class EntityMetaStore { this.tempToPersistedId.delete(oldKey) this.tempToPersistedId.set(newKey, persistedId) } + + this.mutationVersion++ } // ==================== Bulk Operations ==================== @@ -168,9 +193,12 @@ export class EntityMetaStore { } importMetas(metas: Map): void { + let imported = false for (const [key, meta] of metas) { this.entityMetas.set(key, { ...meta }) + imported = true } + if (imported) this.mutationVersion++ } clear(): void { @@ -178,5 +206,6 @@ export class EntityMetaStore { this.entityMetas.clear() this.persistingEntities.clear() this.tempToPersistedId.clear() + this.mutationVersion++ } } diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 4d0fc363..3292e5f6 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -23,6 +23,19 @@ export class EntitySnapshotStore { */ private readonly idIndex = new Map() + /** + * Monotonic counter bumped only when the set of keys or the id→key index + * changes (new key, removal, rekey, bulk import, clear). Pure value edits + * (setFieldValue/updateFields/commit/reset/...) do NOT bump it — they cannot + * change reachability — so the per-keystroke edit path keeps the + * {@link ReachabilityAnalyzer} cache warm. + */ + private mutationVersion = 0 + + getMutationVersion(): number { + return this.mutationVersion + } + get(key: string): EntitySnapshot | undefined { return this.snapshots.get(key) } @@ -62,6 +75,7 @@ export class EntitySnapshotStore { this.snapshots.set(key, newSnapshot) this.idIndex.set(id, key) + if (!existing) this.mutationVersion++ return newSnapshot } @@ -208,6 +222,7 @@ export class EntitySnapshotStore { // that survivor as id-unresolvable. if (existing && this.idIndex.get(existing.id) === key) { this.idIndex.delete(existing.id) + this.mutationVersion++ } this.snapshots.delete(key) } @@ -284,6 +299,7 @@ export class EntitySnapshotStore { this.idIndex.set(snapshot.id, key) keys.add(key) } + if (keys.size > 0) this.mutationVersion++ return keys } @@ -310,6 +326,7 @@ export class EntitySnapshotStore { this.snapshots.set(newKey, newSnapshot) this.idIndex.delete(snapshot.id) this.idIndex.set(newId, newKey) + this.mutationVersion++ } keys(): IterableIterator { @@ -329,6 +346,7 @@ export class EntitySnapshotStore { clear(): void { this.snapshots.clear() this.idIndex.clear() + this.mutationVersion++ } } diff --git a/packages/bindx/src/store/ReachabilityAnalyzer.ts b/packages/bindx/src/store/ReachabilityAnalyzer.ts index 10e211de..5ed9460b 100644 --- a/packages/bindx/src/store/ReachabilityAnalyzer.ts +++ b/packages/bindx/src/store/ReachabilityAnalyzer.ts @@ -18,6 +18,14 @@ import type { RootRegistry } from './RootRegistry.js' * The graph is read from {@link RelationStore} (the source of truth for live * relation membership), never from the subscription parent-child registry, * which is append-mostly for notifications and does not reflect disconnects. + * + * The walk is O(E+R) and runs on every dirty check and post-persist sweep, so + * its result is memoized. Each sub-store the walk reads exposes a monotonic + * `getMutationVersion()` that bumps only when graph-relevant state changes + * (entity key set, `existsOnServer`/`isPersisting`, roots, relation edges). + * Their sum is a cache key: it is strictly increasing, so an unchanged sum + * proves nothing relevant changed and the cached set can be returned. Pure + * field edits do not bump any counter, keeping the cache warm on the hot path. */ export class ReachabilityAnalyzer { constructor( @@ -27,11 +35,40 @@ export class ReachabilityAnalyzer { private readonly roots: RootRegistry, ) {} + private cache: { version: number; result: Set } | null = null + /** * Returns the set of entity keys ("entityType:id") for created * (never-persisted) entities reachable from a root through live relations. + * + * The returned set is the cached instance and is owned by the analyzer — + * callers must treat it as read-only (the current consumers only call + * `.has(...)` on it). */ computeReachableCreated(): Set { + const version = this.graphVersion() + const cached = this.cache + if (cached !== null && cached.version === version) { + return cached.result + } + + const result = this.walk() + this.cache = { version, result } + return result + } + + /** + * Sum of the graph-relevant mutation counters across the sub-stores the walk + * reads. Monotonic, so an unchanged value means no relevant mutation happened. + */ + private graphVersion(): number { + return this.entitySnapshots.getMutationVersion() + + this.meta.getMutationVersion() + + this.relations.getMutationVersion() + + this.roots.getMutationVersion() + } + + private walk(): Set { const reachableCreated = new Set() const visited = new Set() const stack: string[] = [] diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 617b21b6..24ff870a 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -56,6 +56,29 @@ export class RelationStore { /** Has-many list states keyed by "parentType:parentId:fieldName" */ private readonly hasManyStates = new Map() + /** + * Monotonic counter bumped on every actual write to relation/has-many state. + * Used by {@link ReachabilityAnalyzer} to memoize its walk. All writes funnel + * through {@link writeRelation}/{@link writeHasMany} (plus the delete/clear + * paths), so a read-only `getOrCreate*` call on the per-render materialize + * path — which does not write when the entry already matches — never bumps it. + */ + private mutationVersion = 0 + + getMutationVersion(): number { + return this.mutationVersion + } + + private writeRelation(key: string, state: StoredRelationState): void { + this.relationStates.set(key, state) + this.mutationVersion++ + } + + private writeHasMany(key: string, state: StoredHasManyState): void { + this.hasManyStates.set(key, state) + this.mutationVersion++ + } + // ==================== Has-One Relations ==================== /** @@ -66,7 +89,7 @@ export class RelationStore { initial: Omit, ): StoredRelationState { if (!this.relationStates.has(key)) { - this.relationStates.set(key, { ...initial, version: 0 }) + this.writeRelation(key, { ...initial, version: 0 }) } return this.relationStates.get(key)! @@ -103,7 +126,7 @@ export class RelationStore { } } - this.relationStates.set(key, { + this.writeRelation(key, { currentId: 'currentId' in updates ? updates.currentId! : serverId, serverId, state: 'state' in updates ? updates.state! : serverState, @@ -112,7 +135,7 @@ export class RelationStore { version: 0, }) } else { - this.relationStates.set(key, { + this.writeRelation(key, { ...existing, ...updates, version: existing.version + 1, @@ -127,7 +150,7 @@ export class RelationStore { const existing = this.relationStates.get(key) if (!existing) return - this.relationStates.set(key, { + this.writeRelation(key, { ...existing, serverId: existing.currentId, serverState: existing.state === 'creating' ? 'connected' : existing.state, @@ -143,7 +166,7 @@ export class RelationStore { const existing = this.relationStates.get(key) if (!existing) return - this.relationStates.set(key, { + this.writeRelation(key, { ...existing, currentId: existing.serverId, state: existing.serverState, @@ -160,7 +183,7 @@ export class RelationStore { getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(serverIds ?? []), orderedIds: null, plannedRemovals: new Map(), @@ -171,7 +194,7 @@ export class RelationStore { } else if (serverIds !== undefined) { const newServerIds = new Set(serverIds) if (!setsEqual(existing.serverIds, newServerIds)) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, serverIds: newServerIds, orderedIds: null, @@ -197,7 +220,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(serverIds), orderedIds: null, plannedRemovals: new Map(), @@ -206,7 +229,7 @@ export class RelationStore { version: 0, }) } else { - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, serverIds: new Set(serverIds), orderedIds: null, @@ -222,7 +245,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(), orderedIds: null, plannedRemovals: new Map([[itemId, type]]), @@ -241,7 +264,7 @@ export class RelationStore { } const newCreatedEntities = new Set(existing.createdEntities) newCreatedEntities.delete(itemId) - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, plannedRemovals: newPlannedRemovals, @@ -259,7 +282,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(), orderedIds: null, plannedRemovals: new Map(), @@ -276,7 +299,7 @@ export class RelationStore { if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { newOrderedIds = [...newOrderedIds, itemId] } - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, plannedConnections: newPlannedConnections, @@ -292,7 +315,7 @@ export class RelationStore { commitHasMany(key: string, newServerIds: string[]): void { const existing = this.hasManyStates.get(key) - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(newServerIds), orderedIds: null, plannedRemovals: new Map(), @@ -309,7 +332,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) return - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: existing.serverIds, orderedIds: null, plannedRemovals: new Map(), @@ -327,7 +350,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(), orderedIds: [itemId], plannedRemovals: new Map(), @@ -342,7 +365,7 @@ export class RelationStore { newCreatedEntities.add(itemId) const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) const newOrderedIds = [...currentOrderedIds, itemId] - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, plannedConnections: newPlannedConnections, @@ -361,7 +384,7 @@ export class RelationStore { const existing = this.hasManyStates.get(key) if (!existing) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(), orderedIds: [itemId], plannedRemovals: new Map(), @@ -374,7 +397,7 @@ export class RelationStore { newPlannedConnections.add(itemId) const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) const newOrderedIds = [...currentOrderedIds, itemId] - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, plannedConnections: newPlannedConnections, @@ -425,7 +448,7 @@ export class RelationStore { } } - this.hasManyStates.set(key, newState) + this.writeHasMany(key, newState) return true } else { this.planHasManyRemoval(key, itemId, removalType) @@ -477,16 +500,20 @@ export class RelationStore { * stale relation state behind. */ removeOwnedRelations(keyPrefix: string): void { + let changed = false for (const key of [...this.relationStates.keys()]) { if (key.startsWith(keyPrefix)) { this.relationStates.delete(key) + changed = true } } for (const key of [...this.hasManyStates.keys()]) { if (key.startsWith(keyPrefix)) { this.hasManyStates.delete(key) + changed = true } } + if (changed) this.mutationVersion++ } /** @@ -507,7 +534,7 @@ export class RelationStore { if (movedItem === undefined) return newOrderedIds.splice(toIndex, 0, movedItem) - this.hasManyStates.set(key, { + this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, version: existing.version + 1, @@ -533,7 +560,7 @@ export class RelationStore { * Used in pessimistic mode after successful server confirmation. */ restoreHasManyState(key: string, state: StoredHasManyState): void { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(state.serverIds), orderedIds: state.orderedIds ? [...state.orderedIds] : null, plannedRemovals: new Map(state.plannedRemovals), @@ -699,7 +726,7 @@ export class RelationStore { importRelationStates(states: Map): string[] { const keys: string[] = [] for (const [key, state] of states) { - this.relationStates.set(key, { + this.writeRelation(key, { ...state, placeholderData: { ...state.placeholderData }, }) @@ -715,7 +742,7 @@ export class RelationStore { importHasManyStates(states: Map): string[] { const keys: string[] = [] for (const [key, state] of states) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds: new Set(state.serverIds), orderedIds: state.orderedIds ? [...state.orderedIds] : null, plannedRemovals: new Map(state.plannedRemovals), @@ -743,7 +770,7 @@ export class RelationStore { if (serverId === oldId) { serverId = newId; changed = true } if (changed) { - this.relationStates.set(key, { ...state, currentId, serverId, version: state.version + 1 }) + this.writeRelation(key, { ...state, currentId, serverId, version: state.version + 1 }) } } @@ -795,7 +822,7 @@ export class RelationStore { } if (changed) { - this.hasManyStates.set(key, { + this.writeHasMany(key, { serverIds, orderedIds, plannedRemovals, @@ -819,7 +846,7 @@ export class RelationStore { } for (const [oldKey, value] of toMoveRelations) { this.relationStates.delete(oldKey) - this.relationStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) } const toMoveHasMany: [string, StoredHasManyState][] = [] @@ -830,7 +857,7 @@ export class RelationStore { } for (const [oldKey, value] of toMoveHasMany) { this.hasManyStates.delete(oldKey) - this.hasManyStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) } } @@ -840,6 +867,7 @@ export class RelationStore { clear(): void { this.relationStates.clear() this.hasManyStates.clear() + this.mutationVersion++ } } diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts index 01466e00..466fea0e 100644 --- a/packages/bindx/src/store/RootRegistry.ts +++ b/packages/bindx/src/store/RootRegistry.ts @@ -16,12 +16,26 @@ export class RootRegistry { private readonly roots = new Set() + /** + * Monotonic counter bumped whenever the root set actually changes. Used by + * {@link ReachabilityAnalyzer} to memoize the reachability walk. Bumps happen + * only on a real change so the per-render `registerParentChild` → `unregister` + * call (almost always a no-op for an already-anchored child) does not + * needlessly invalidate the cache. + */ + private mutationVersion = 0 + register(key: string): void { - this.roots.add(key) + if (!this.roots.has(key)) { + this.roots.add(key) + this.mutationVersion++ + } } unregister(key: string): void { - this.roots.delete(key) + if (this.roots.delete(key)) { + this.mutationVersion++ + } } keys(): IterableIterator { @@ -35,10 +49,16 @@ export class RootRegistry { rekey(oldKey: string, newKey: string): void { if (this.roots.delete(oldKey)) { this.roots.add(newKey) + this.mutationVersion++ } } clear(): void { this.roots.clear() + this.mutationVersion++ + } + + getMutationVersion(): number { + return this.mutationVersion } } From 80016d43ea2908fb39ed4c8c2da21d03197fa2d9 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:33:38 +0200 Subject: [PATCH 05/28] test(bindx): cover reachability memoization cache hit/invalidation White-box tests drive ReachabilityAnalyzer directly and spy on getLiveChildIds to assert the cache hits when nothing changes (incl. across pure field edits) and misses on each graph-affecting mutation type. A black-box test proves SnapshotStore propagates counter bumps end-to-end through getAllDirtyEntities (no stale create set). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../store/reachabilityMemoization.test.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/unit/store/reachabilityMemoization.test.ts diff --git a/tests/unit/store/reachabilityMemoization.test.ts b/tests/unit/store/reachabilityMemoization.test.ts new file mode 100644 index 00000000..e5d42dff --- /dev/null +++ b/tests/unit/store/reachabilityMemoization.test.ts @@ -0,0 +1,186 @@ +// Memoization of the reachability walk (PR 1 of the store/persistence debt program). +// +// computeReachableCreated() is an O(E+R) walk run on every dirty check and every +// post-persist sweep. It is memoized behind a cache key that sums monotonic +// mutation counters from the four sub-stores it reads (entity snapshots, meta, +// relations, roots). The cache must: +// - HIT when nothing graph-relevant changed (incl. across pure field edits), and +// - MISS (recompute, no stale result) on any graph-affecting mutation. +// +// The white-box tests drive ReachabilityAnalyzer directly and spy on +// RelationStore.getLiveChildIds (called once per visited node) to observe whether +// a walk actually happened. The black-box test proves the SnapshotStore wiring +// propagates counter bumps end-to-end through getAllDirtyEntities. +import { describe, test, expect } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { ReachabilityAnalyzer } from '../../../packages/bindx/src/store/ReachabilityAnalyzer.js' +import { EntitySnapshotStore } from '../../../packages/bindx/src/store/EntitySnapshotStore.js' +import { EntityMetaStore } from '../../../packages/bindx/src/store/EntityMetaStore.js' +import { RelationStore } from '../../../packages/bindx/src/store/RelationStore.js' +import { RootRegistry } from '../../../packages/bindx/src/store/RootRegistry.js' + +interface Harness { + entitySnapshots: EntitySnapshotStore + meta: EntityMetaStore + relations: RelationStore + roots: RootRegistry + analyzer: ReachabilityAnalyzer + walkCount: () => number +} + +function createHarness(): Harness { + const entitySnapshots = new EntitySnapshotStore() + const meta = new EntityMetaStore() + const relations = new RelationStore() + const roots = new RootRegistry() + + // Spy: getLiveChildIds is invoked once per node the walk visits, so a stable + // count across a call proves the cache served it without re-walking. + let calls = 0 + const original = relations.getLiveChildIds.bind(relations) + relations.getLiveChildIds = (keyPrefix: string): string[] => { + calls++ + return original(keyPrefix) + } + + const analyzer = new ReachabilityAnalyzer(entitySnapshots, meta, relations, roots) + return { entitySnapshots, meta, relations, roots, analyzer, walkCount: () => calls } +} + +// Server Article a1 with one created (never-persisted) Comment child c1 connected +// through its has-many. c1 is reachable from the server root, so it is a create. +function seedServerParentWithCreatedChild(h: Harness): void { + h.entitySnapshots.setData('Article:a1', 'a1', 'Article', { id: 'a1' }, true) + h.meta.setExistsOnServer('Article:a1', true) + h.entitySnapshots.setData('Comment:c1', 'c1', 'Comment', { id: 'c1' }, false) + h.relations.addToHasMany('Article:a1:comments', 'c1') +} + +const sortedKeys = (set: Set): string[] => [...set].sort() + +describe('reachability memoization', () => { + test('recomputes once and returns the cached set while nothing changes', () => { + const h = createHarness() + seedServerParentWithCreatedChild(h) + + const first = h.analyzer.computeReachableCreated() + expect(sortedKeys(first)).toEqual(['Comment:c1']) + const callsAfterFirst = h.walkCount() + expect(callsAfterFirst).toBeGreaterThan(0) + + const second = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBe(callsAfterFirst) // cache hit — no re-walk + expect(second).toBe(first) // same cached instance + }) + + test('a pure field edit does NOT invalidate the cache', () => { + const h = createHarness() + seedServerParentWithCreatedChild(h) + h.analyzer.computeReachableCreated() + const calls = h.walkCount() + + // Value-only edits change snapshot data/version but not the key set or any + // relation edge, so they must not bump any reachability counter. + h.entitySnapshots.setFieldValue('Article:a1', ['title'], 'Edited') + h.entitySnapshots.updateFields('Comment:c1', { text: 'changed' }) + + h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBe(calls) // still cached + }) + + test('adding another created child invalidates the cache and is reflected', () => { + const h = createHarness() + seedServerParentWithCreatedChild(h) + h.analyzer.computeReachableCreated() + const calls = h.walkCount() + + h.entitySnapshots.setData('Comment:c2', 'c2', 'Comment', { id: 'c2' }, false) + h.relations.addToHasMany('Article:a1:comments', 'c2') + + const result = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBeGreaterThan(calls) // recomputed + expect(sortedKeys(result)).toEqual(['Comment:c1', 'Comment:c2']) + }) + + test('removing a created child invalidates the cache and drops it', () => { + const h = createHarness() + seedServerParentWithCreatedChild(h) + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual(['Comment:c1']) + const calls = h.walkCount() + + h.relations.removeFromHasMany('Article:a1:comments', 'c1', 'disconnect') + + const result = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBeGreaterThan(calls) + expect(sortedKeys(result)).toEqual([]) + }) + + test('flipping existsOnServer invalidates the cache', () => { + const h = createHarness() + // A top-level created entity (root) — reported as a create until it exists. + h.entitySnapshots.setData('Draft:d1', 'd1', 'Draft', { id: 'd1' }, false) + h.roots.register('Draft:d1') + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual(['Draft:d1']) + + h.meta.setExistsOnServer('Draft:d1', true) + + // Flipping the last created entity to a server entity invalidates the cache; the + // recompute then takes the no-created-snapshot fast path (no node walk), so — as + // in the root (un)register case — invalidation is proven by the result alone: a + // stale cache would still report the old create instead of the empty set. + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual([]) // now a server entity, not a create + }) + + test('toggling persisting invalidates the cache', () => { + const h = createHarness() + // A created entity reachable from nothing: not a create on its own, but it + // becomes live while it is persisting (the in-flight seed). + h.entitySnapshots.setData('Comment:x1', 'x1', 'Comment', { id: 'x1' }, false) + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual([]) + const calls = h.walkCount() + + h.meta.setPersisting('Comment:x1', true) + + const result = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBeGreaterThan(calls) + expect(sortedKeys(result)).toEqual(['Comment:x1']) + }) + + test('registering and unregistering a root invalidates the cache', () => { + const h = createHarness() + h.entitySnapshots.setData('Draft:d2', 'd2', 'Draft', { id: 'd2' }, false) + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual([]) + + h.roots.register('Draft:d2') + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual(['Draft:d2']) + + // A no-op unregister (key absent) must NOT change the result and stays correct. + h.roots.unregister('Draft:absent') + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual(['Draft:d2']) + + h.roots.unregister('Draft:d2') + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual([]) + }) + + test('end-to-end: SnapshotStore propagates bumps through getAllDirtyEntities', () => { + const store = new SnapshotStore() + store.setEntityData('Article', 'a1', { id: 'a1', title: 'T' }, true) + const cId = store.createEntity('Comment', { text: 'c' }) + store.addToHasMany('Article', 'a1', 'comments', cId) + store.registerParentChild('Article', 'a1', 'Comment', cId) + + const createIds = (): string[] => + store + .getAllDirtyEntities() + .filter(e => e.changeType === 'create') + .map(e => e.entityId) + + expect(createIds()).toEqual([cId]) // populates the cache + // A field edit must not stale the create set. + store.setFieldValue('Article', 'a1', ['title'], 'Edited') + expect(createIds()).toEqual([cId]) + // A graph change must be reflected, not served from a stale cache. + store.removeFromHasMany('Article', 'a1', 'comments', cId, 'disconnect') + expect(createIds()).toEqual([]) + }) +}) From f3a19390f75f3d0f3253908ed89219ed6a744a78 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:47:36 +0200 Subject: [PATCH 06/28] refactor(bindx): centralize temp-id rekey in RekeyOrchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temp→persisted rekey was a 9-store fan-out hand-written inline in SnapshotStore.mapTempIdToPersistedId, and the same temp→persisted fact was stored twice in two formats: SnapshotStore.rekeyedEntities ("Type:tempId" → "Type:persistedId") and EntityMetaStore.tempToPersistedId ("Type:key" → persistedId). Introduce RekeyOrchestrator as the single owner of temp→persisted identity. Both key resolution (resolveKey/resolveId) and persisted-id queries (getPersistedId/isNewEntity) now derive from its one map, so the two duplicate maps are gone. EntityMetaStore sheds tempToPersistedId and its getPersistedId/isNewEntity/mapTempIdToPersistedId API; the exists-on-server flip folds into its rekey(). Each participating sub-store implements a uniform Rekeyable.rekey(ctx) interface, and the orchestrator drives them in one explicit, documented order (previously load-bearing but implicit in the inline sequence). SubscriptionManager keeps its own closure-redirect chain, which tracks relation-key prefixes and stale unsubscribe closures rather than entity identity. clear() now also clears the redirect map (previously leaked). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/EntityMetaStore.ts | 62 +++------- .../bindx/src/store/EntitySnapshotStore.ts | 22 ++-- packages/bindx/src/store/ErrorStore.ts | 11 +- packages/bindx/src/store/RekeyOrchestrator.ts | 112 ++++++++++++++++++ packages/bindx/src/store/RelationStore.ts | 13 +- packages/bindx/src/store/RootRegistry.ts | 14 ++- packages/bindx/src/store/SnapshotStore.ts | 76 ++++-------- .../bindx/src/store/SubscriptionManager.ts | 7 +- packages/bindx/src/store/TouchedStore.ts | 7 +- 9 files changed, 202 insertions(+), 122 deletions(-) create mode 100644 packages/bindx/src/store/RekeyOrchestrator.ts diff --git a/packages/bindx/src/store/EntityMetaStore.ts b/packages/bindx/src/store/EntityMetaStore.ts index 297d535a..a4b940f7 100644 --- a/packages/bindx/src/store/EntityMetaStore.ts +++ b/packages/bindx/src/store/EntityMetaStore.ts @@ -1,6 +1,6 @@ import type { LoadStatus } from './snapshots.js' -import { isPersistedId, isPlaceholderId } from './entityId.js' import type { FieldError } from '../errors/types.js' +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' /** * Entity load state tracking. @@ -26,7 +26,7 @@ export interface EntityMeta { * Keys are pre-computed composite strings (e.g., "entityType:id"). * Follows the same pattern as ErrorStore and RelationStore. */ -export class EntityMetaStore { +export class EntityMetaStore implements Rekeyable { /** Load states keyed by "entityType:id" */ private readonly loadStates = new Map() @@ -36,9 +36,6 @@ export class EntityMetaStore { /** Persisting status keyed by "entityType:id" */ private readonly persistingEntities = new Set() - /** Mapping from temp ID to persisted ID (keyed by "entityType:tempId") */ - private readonly tempToPersistedId = new Map() - /** * Monotonic counter bumped when reachability-relevant metadata changes — * `existsOnServer` and `isPersisting` (which seed the reachability roots) plus @@ -118,65 +115,41 @@ export class EntityMetaStore { } } - // ==================== Temp ID Mapping ==================== - - mapTempIdToPersistedId(key: string, persistedId: string): void { - this.tempToPersistedId.set(key, persistedId) - this.setExistsOnServer(key, true) - } - - getPersistedId(key: string, id: string): string | null { - if (isPlaceholderId(id)) return null - if (isPersistedId(id)) return id - return this.tempToPersistedId.get(key) ?? null - } - - isNewEntity(key: string, id: string): boolean { - if (isPlaceholderId(id)) return true - if (isPersistedId(id)) return false - return !this.tempToPersistedId.has(key) - } - /** - * Removes all metadata for an entity (load state, meta, persisting, temp mapping). + * Removes all metadata for an entity (load state, meta, persisting). */ remove(key: string): void { this.loadStates.delete(key) this.entityMetas.delete(key) this.persistingEntities.delete(key) - this.tempToPersistedId.delete(key) this.mutationVersion++ } /** - * Moves all metadata from oldKey to newKey. + * Moves all metadata from the temp key to the persisted key. The entity has + * just been confirmed by the server, so it is also marked as existing there + * (this replaces the former separate mapTempIdToPersistedId step). */ - rekey(oldKey: string, newKey: string): void { - const meta = this.entityMetas.get(oldKey) + rekey(ctx: RekeyContext): void { + const meta = this.entityMetas.get(ctx.oldKey) if (meta) { - this.entityMetas.delete(oldKey) - this.entityMetas.set(newKey, meta) + this.entityMetas.delete(ctx.oldKey) + this.entityMetas.set(ctx.newKey, meta) } - const loadState = this.loadStates.get(oldKey) + const loadState = this.loadStates.get(ctx.oldKey) if (loadState) { - this.loadStates.delete(oldKey) - this.loadStates.set(newKey, loadState) - } - - if (this.persistingEntities.has(oldKey)) { - this.persistingEntities.delete(oldKey) - this.persistingEntities.add(newKey) + this.loadStates.delete(ctx.oldKey) + this.loadStates.set(ctx.newKey, loadState) } - // Move temp ID mapping - const persistedId = this.tempToPersistedId.get(oldKey) - if (persistedId !== undefined) { - this.tempToPersistedId.delete(oldKey) - this.tempToPersistedId.set(newKey, persistedId) + if (this.persistingEntities.has(ctx.oldKey)) { + this.persistingEntities.delete(ctx.oldKey) + this.persistingEntities.add(ctx.newKey) } this.mutationVersion++ + this.setExistsOnServer(ctx.newKey, true) } // ==================== Bulk Operations ==================== @@ -205,7 +178,6 @@ export class EntityMetaStore { this.loadStates.clear() this.entityMetas.clear() this.persistingEntities.clear() - this.tempToPersistedId.clear() this.mutationVersion++ } } diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 3292e5f6..71f4a8d0 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -2,6 +2,7 @@ import { createEntitySnapshot, type EntitySnapshot, } from './snapshots.js' +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' /** * Manages entity snapshots — core CRUD for immutable entity data. @@ -12,7 +13,7 @@ import { * Follows the same sub-store pattern as ErrorStore, RelationStore, etc. * SnapshotStore delegates entity snapshot operations here. */ -export class EntitySnapshotStore { +export class EntitySnapshotStore implements Rekeyable { private readonly snapshots = new Map() /** @@ -304,28 +305,29 @@ export class EntitySnapshotStore { } /** - * Moves a snapshot from oldKey to newKey, updating the id in the snapshot data. + * Moves a snapshot from the temp key to the persisted key, updating the id in + * the snapshot data. */ - rekey(oldKey: string, newKey: string, newId: string): void { - const snapshot = this.snapshots.get(oldKey) + rekey(ctx: RekeyContext): void { + const snapshot = this.snapshots.get(ctx.oldKey) if (!snapshot) return // Update id field in data and serverData - const data = { ...snapshot.data as Record, id: newId } - const serverData = { ...snapshot.serverData as Record, id: newId } + const data = { ...snapshot.data as Record, id: ctx.newId } + const serverData = { ...snapshot.serverData as Record, id: ctx.newId } const newSnapshot = createEntitySnapshot( - newId, + ctx.newId, snapshot.entityType, data, serverData, snapshot.version + 1, ) - this.snapshots.delete(oldKey) - this.snapshots.set(newKey, newSnapshot) + this.snapshots.delete(ctx.oldKey) + this.snapshots.set(ctx.newKey, newSnapshot) this.idIndex.delete(snapshot.id) - this.idIndex.set(newId, newKey) + this.idIndex.set(ctx.newId, ctx.newKey) this.mutationVersion++ } diff --git a/packages/bindx/src/store/ErrorStore.ts b/packages/bindx/src/store/ErrorStore.ts index 4e783d41..ca8759a3 100644 --- a/packages/bindx/src/store/ErrorStore.ts +++ b/packages/bindx/src/store/ErrorStore.ts @@ -1,5 +1,6 @@ import type { ErrorState, FieldError } from '../errors/types.js' import { filterStickyErrors } from '../errors/types.js' +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' /** * Manages error state for fields, entities, and relations. @@ -9,7 +10,7 @@ import { filterStickyErrors } from '../errors/types.js' * - Entity errors: "entityType:id" * - Relation errors: "entityType:id:relationName" */ -export class ErrorStore { +export class ErrorStore implements Rekeyable { /** Field errors keyed by "entityType:id:fieldName" */ private readonly fieldErrors = new Map() @@ -252,10 +253,10 @@ export class ErrorStore { /** * Rekeys all errors from oldKeyPrefix to newKeyPrefix. */ - rekey(oldEntityKey: string, newEntityKey: string, oldKeyPrefix: string, newKeyPrefix: string): void { - this.rekeyMap(this.entityErrors, oldEntityKey, newEntityKey) - this.rekeyByPrefix(this.fieldErrors, oldKeyPrefix, newKeyPrefix) - this.rekeyByPrefix(this.relationErrors, oldKeyPrefix, newKeyPrefix) + rekey(ctx: RekeyContext): void { + this.rekeyMap(this.entityErrors, ctx.oldKey, ctx.newKey) + this.rekeyByPrefix(this.fieldErrors, ctx.oldKeyPrefix, ctx.newKeyPrefix) + this.rekeyByPrefix(this.relationErrors, ctx.oldKeyPrefix, ctx.newKeyPrefix) } private rekeyMap(map: Map, oldKey: string, newKey: string): void { diff --git a/packages/bindx/src/store/RekeyOrchestrator.ts b/packages/bindx/src/store/RekeyOrchestrator.ts new file mode 100644 index 00000000..87895500 --- /dev/null +++ b/packages/bindx/src/store/RekeyOrchestrator.ts @@ -0,0 +1,112 @@ +import { isPersistedId, isPlaceholderId } from './entityId.js' + +/** + * Describes a single temp→persisted rekey in every shape the participating + * sub-stores need (full key, owner prefix, bare id). + */ +export interface RekeyContext { + /** Old composite key, "entityType:tempId". */ + readonly oldKey: string + /** New composite key, "entityType:persistedId". */ + readonly newKey: string + /** Old owner prefix, "entityType:tempId:". */ + readonly oldKeyPrefix: string + /** New owner prefix, "entityType:persistedId:". */ + readonly newKeyPrefix: string + /** The temp id being replaced. */ + readonly oldId: string + /** The server-assigned id. */ + readonly newId: string +} + +/** + * A store that owns per-entity state keyed (directly or by prefix) on an entity + * id and can migrate it from a temp id to its persisted id. + */ +export interface Rekeyable { + rekey(ctx: RekeyContext): void +} + +/** + * Single source of temp→persisted identity, and the one place the rekey fan-out + * is sequenced. + * + * Owns the only id-redirect map: "entityType:tempId" → persistedId. Both key + * resolution ({@link resolveKey}/{@link resolveId}) and persisted-id queries + * ({@link getPersistedId}/{@link isNewEntity}) derive from it, so there is no + * second copy to keep in sync — the former `SnapshotStore.rekeyedEntities` and + * `EntityMetaStore.tempToPersistedId` are both gone. `SubscriptionManager` keeps + * its own closure-redirect chain, which tracks relation-key prefixes and stale + * unsubscribe closures rather than entity identity, so it stays internal there. + */ +export class RekeyOrchestrator { + /** "entityType:tempId" → persistedId. The single identity-redirect map. */ + private readonly tempToPersisted = new Map() + + /** + * @param participants the sub-stores to migrate, in the exact order + * {@link rekey} must visit them (the order is load-bearing — see rekey()). + */ + constructor(private readonly participants: readonly Rekeyable[]) {} + + /** Resolves an entity key, following a temp→persisted redirect if present. */ + resolveKey(entityType: string, id: string): string { + const persisted = this.tempToPersisted.get(`${entityType}:${id}`) + return persisted !== undefined ? `${entityType}:${persisted}` : `${entityType}:${id}` + } + + /** Resolves an entity id to its persisted id if it has been rekeyed. */ + resolveId(entityType: string, id: string): string { + return this.tempToPersisted.get(`${entityType}:${id}`) ?? id + } + + /** The persisted id for an entity, or null if it has none yet. */ + getPersistedId(entityType: string, id: string): string | null { + if (isPlaceholderId(id)) return null + if (isPersistedId(id)) return id + return this.tempToPersisted.get(`${entityType}:${id}`) ?? null + } + + /** Whether an entity has never been persisted (no server-assigned id yet). */ + isNewEntity(entityType: string, id: string): boolean { + if (isPlaceholderId(id)) return true + if (isPersistedId(id)) return false + return !this.tempToPersisted.has(`${entityType}:${id}`) + } + + /** + * Migrates an entity from its temp id to its server-assigned id across every + * participating store. + * + * Ordering contract (do not reorder — each step relies on the previous): + * 0. register the redirect, so any resolveKey/resolveId during the fan-out + * already sees the persisted key; + * 1. roots — keep the root registry aligned; + * 2. entity snapshot — move data, rewrite the id field and id index; + * 3. meta — move metadata/load/persisting, then mark exists-on-server; + * 4. subscriptions — move entity + relation subscribers and parent links; + * 5. relations — rekey owned relation keys, then replace value references; + * 6. errors / touched / propagation — move the remaining per-entity state. + * The caller performs the final notification on the new key. + */ + rekey(entityType: string, tempId: string, persistedId: string): void { + const ctx: RekeyContext = { + oldKey: `${entityType}:${tempId}`, + newKey: `${entityType}:${persistedId}`, + oldKeyPrefix: `${entityType}:${tempId}:`, + newKeyPrefix: `${entityType}:${persistedId}:`, + oldId: tempId, + newId: persistedId, + } + + this.tempToPersisted.set(ctx.oldKey, persistedId) + + for (const participant of this.participants) { + participant.rekey(ctx) + } + } + + clear(): void { + this.tempToPersisted.clear() + } +} diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 24ff870a..6df92170 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -1,5 +1,6 @@ import type { HasOneRelationState } from '../handles/types.js' import type { EntitySnapshot } from './snapshots.js' +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false @@ -49,7 +50,7 @@ export interface StoredRelationState { * Relation keys use the format "parentType:parentId:fieldName". * Notification is handled by callers via callback returns. */ -export class RelationStore { +export class RelationStore implements Rekeyable { /** Relation states keyed by "parentType:parentId:fieldName" */ private readonly relationStates = new Map() @@ -834,6 +835,16 @@ export class RelationStore { } } + /** + * Migrates an entity's relation state for a temp→persisted rekey: first the + * relation/has-many keys it owns (the parent id in the key), then every value + * reference to its id from other entities' relations. + */ + rekey(ctx: RekeyContext): void { + this.rekeyOwner(ctx.oldKeyPrefix, ctx.newKeyPrefix) + this.replaceEntityId(ctx.oldId, ctx.newId) + } + /** * Rekeys relation/hasMany entries owned by an entity (changes the parent ID in the key). */ diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts index 466fea0e..5aee96ed 100644 --- a/packages/bindx/src/store/RootRegistry.ts +++ b/packages/bindx/src/store/RootRegistry.ts @@ -13,7 +13,9 @@ * * Keys use the same composite format as the rest of the store: "entityType:id". */ -export class RootRegistry { +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' + +export class RootRegistry implements Rekeyable { private readonly roots = new Set() /** @@ -43,12 +45,12 @@ export class RootRegistry { } /** - * Moves a root entry from oldKey to newKey (used after persist rekeys a temp - * id to a server-assigned id). + * Moves a root entry from the temp key to the persisted key (used after + * persist rekeys a temp id to a server-assigned id). */ - rekey(oldKey: string, newKey: string): void { - if (this.roots.delete(oldKey)) { - this.roots.add(newKey) + rekey(ctx: RekeyContext): void { + if (this.roots.delete(ctx.oldKey)) { + this.roots.add(ctx.newKey) this.mutationVersion++ } } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 19e9eaea..decb0db9 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -15,6 +15,7 @@ import { DirtyTracker } from './DirtyTracker.js' import { EntitySnapshotStore } from './EntitySnapshotStore.js' import { RootRegistry } from './RootRegistry.js' import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' +import { RekeyOrchestrator } from './RekeyOrchestrator.js' export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' export type { EntityMeta } from './EntityMetaStore.js' @@ -49,6 +50,7 @@ export class SnapshotStore implements SnapshotVersionBumper { private readonly roots = new RootRegistry() private readonly reachability: ReachabilityAnalyzer private readonly dirtyTracker: DirtyTracker + private readonly rekeyOrchestrator: RekeyOrchestrator /** * Tracks the last embedded data reference propagated from parent to child. @@ -61,16 +63,25 @@ export class SnapshotStore implements SnapshotVersionBumper { constructor() { this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots) this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability) + // The participants are visited in this exact order on every rekey — see the + // ordering contract in RekeyOrchestrator.rekey(). Propagation tracking lives + // on this store, so it joins as a small inline adapter. + this.rekeyOrchestrator = new RekeyOrchestrator([ + this.roots, + this.entitySnapshots, + this.meta, + this.subscriptions, + this.relations, + this.errors, + this.touched, + { rekey: ctx => this.rekeyPropagatedData(ctx.oldKeyPrefix, ctx.newKeyPrefix) }, + ]) } // ==================== Key Generation ==================== - /** Maps temp ID entity keys to their persisted ID entity keys for transparent resolution */ - private readonly rekeyedEntities = new Map() - private getEntityKey(entityType: string, id: string): string { - const key = `${entityType}:${id}` - return this.rekeyedEntities.get(key) ?? key + return this.rekeyOrchestrator.resolveKey(entityType, id) } private getRelationKey(parentType: string, parentId: string, fieldName: string): string { @@ -82,13 +93,7 @@ export class SnapshotStore implements SnapshotVersionBumper { * Resolves an ID to its persisted ID if it has been rekeyed. */ private resolveId(entityType: string, id: string): string { - const key = `${entityType}:${id}` - const rekeyed = this.rekeyedEntities.get(key) - if (rekeyed) { - // Extract the ID from the rekeyed key (format: "EntityType:id") - return rekeyed.slice(entityType.length + 1) - } - return id + return this.rekeyOrchestrator.resolveId(entityType, id) } // ==================== SnapshotVersionBumper ==================== @@ -367,52 +372,20 @@ export class SnapshotStore implements SnapshotVersionBumper { } mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void { - // Use raw keys (bypass resolveId) since we're the ones creating the mapping - const oldKey = `${entityType}:${tempId}` - const newKey = `${entityType}:${persistedId}` - const oldKeyPrefix = `${entityType}:${tempId}:` - const newKeyPrefix = `${entityType}:${persistedId}:` - - // Register redirect so future lookups by temp ID resolve to persisted key - this.rekeyedEntities.set(oldKey, newKey) - - // Keep root registration consistent across the rekey (the persisted entity - // is now a server root via existsOnServer, but keep the registry aligned). - this.roots.rekey(oldKey, newKey) - - // Rekey entity snapshot (moves data, updates id field) - this.entitySnapshots.rekey(oldKey, newKey, persistedId) - - // Rekey metadata FIRST, then set persisted mapping (which sets existsOnServer) - this.meta.rekey(oldKey, newKey) - this.meta.mapTempIdToPersistedId(newKey, persistedId) + // The orchestrator owns the temp→persisted redirect and drives the rekey + // fan-out across every sub-store in its documented order. + this.rekeyOrchestrator.rekey(entityType, tempId, persistedId) - // Rekey subscriptions, parent-child relationships, and relation subscribers - this.subscriptions.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix) - - // Rekey relations/hasMany owned by this entity (parent key changes) - this.relations.rekeyOwner(oldKeyPrefix, newKeyPrefix) - - // Replace tempId with persistedId in all relation/hasMany VALUE references - this.relations.replaceEntityId(tempId, persistedId) - - // Rekey errors, touched state, and propagation tracking - this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix) - this.touched.rekey(oldKeyPrefix, newKeyPrefix) - this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix) - - // Notify on the NEW key so React picks up the change - this.notifyEntitySubscribers(newKey) + // Notify on the NEW key so React picks up the change. + this.notifyEntitySubscribers(`${entityType}:${persistedId}`) } getPersistedId(entityType: string, id: string): string | null { - const key = this.getEntityKey(entityType, id) - return this.meta.getPersistedId(key, id) + return this.rekeyOrchestrator.getPersistedId(entityType, id) } isNewEntity(entityType: string, id: string): boolean { - const key = this.getEntityKey(entityType, id) - return this.meta.isNewEntity(key, id) + return this.rekeyOrchestrator.isNewEntity(entityType, id) } // ==================== Has-Many State (delegated to RelationStore) ==================== @@ -953,6 +926,7 @@ export class SnapshotStore implements SnapshotVersionBumper { this.errors.clear() this.touched.clear() this.roots.clear() + this.rekeyOrchestrator.clear() this.lastPropagatedData.clear() this.subscriptions.notify() diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index a736deaf..b4ef7c1d 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -1,3 +1,5 @@ +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' + type Subscriber = () => void /** @@ -18,7 +20,7 @@ export interface SnapshotVersionBumper { * - Parent-child change propagation * - Global version tracking for change detection */ -export class SubscriptionManager { +export class SubscriptionManager implements Rekeyable { /** Subscribers per entity key */ private readonly entitySubscribers = new Map>() @@ -265,7 +267,8 @@ export class SubscriptionManager { * Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix. * Registers redirects so unsubscribe closures can find migrated callbacks. */ - rekey(oldKey: string, newKey: string, oldKeyPrefix: string, newKeyPrefix: string): void { + rekey(ctx: RekeyContext): void { + const { oldKey, newKey, oldKeyPrefix, newKeyPrefix } = ctx // Register redirect for entity key (update existing chains first) for (const [fromKey, toKey] of this.rekeyedKeys) { if (toKey === oldKey) { diff --git a/packages/bindx/src/store/TouchedStore.ts b/packages/bindx/src/store/TouchedStore.ts index 305910f7..b7bea344 100644 --- a/packages/bindx/src/store/TouchedStore.ts +++ b/packages/bindx/src/store/TouchedStore.ts @@ -1,3 +1,5 @@ +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' + /** * Manages touched state for entity fields. * @@ -6,7 +8,7 @@ * * Keys are pre-computed composite strings (e.g., "entityType:id:fieldName"). */ -export class TouchedStore { +export class TouchedStore implements Rekeyable { /** Touched state keyed by "entityType:id:fieldName" */ private readonly touchedFields = new Map() @@ -39,7 +41,8 @@ export class TouchedStore { /** * Rekeys all touched fields from oldKeyPrefix to newKeyPrefix. */ - rekey(oldKeyPrefix: string, newKeyPrefix: string): void { + rekey(ctx: RekeyContext): void { + const { oldKeyPrefix, newKeyPrefix } = ctx const toMove: [string, boolean][] = [] for (const [key, value] of this.touchedFields) { if (key.startsWith(oldKeyPrefix)) { From 010c7b72c0034e4631fa4cd1d5d2c979c68999a8 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:47:41 +0200 Subject: [PATCH 07/28] test(bindx): cover RekeyOrchestrator resolution and ordering contract Pins resolveKey/resolveId/getPersistedId/isNewEntity across placeholder, temp, and persisted ids; asserts rekey visits every participant exactly once in order with a fully-derived context; and an end-to-end check that SnapshotStore resolves a created entity after persist via the orchestrator. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- tests/unit/store/rekeyOrchestrator.test.ts | 107 +++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/unit/store/rekeyOrchestrator.test.ts diff --git a/tests/unit/store/rekeyOrchestrator.test.ts b/tests/unit/store/rekeyOrchestrator.test.ts new file mode 100644 index 00000000..4d4fe573 --- /dev/null +++ b/tests/unit/store/rekeyOrchestrator.test.ts @@ -0,0 +1,107 @@ +// RekeyOrchestrator (PR 2 of the store/persistence debt program). +// +// The orchestrator is the single owner of temp→persisted identity and the one +// place the rekey fan-out is sequenced. These tests pin its resolution logic +// (resolveKey/resolveId/getPersistedId/isNewEntity, formerly split between +// SnapshotStore.rekeyedEntities and EntityMetaStore.tempToPersistedId) and its +// ordering contract (each participant visited exactly once, in order, with a +// fully-derived context). The end-to-end fan-out across the real sub-stores is +// covered by tests/subscriptionRekey.test.ts. +import { describe, test, expect } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { RekeyOrchestrator, type RekeyContext, type Rekeyable } from '../../../packages/bindx/src/store/RekeyOrchestrator.js' + +const TEMP = '__temp_1' +const PLACEHOLDER = '__placeholder_1' +const SERVER = 'srv-1' + +describe('RekeyOrchestrator', () => { + test('resolveKey/resolveId redirect a temp id to its persisted id after rekey', () => { + const o = new RekeyOrchestrator([]) + + // Before rekey: identity. + expect(o.resolveKey('Article', TEMP)).toBe(`Article:${TEMP}`) + expect(o.resolveId('Article', TEMP)).toBe(TEMP) + + o.rekey('Article', TEMP, SERVER) + + expect(o.resolveKey('Article', TEMP)).toBe(`Article:${SERVER}`) + expect(o.resolveId('Article', TEMP)).toBe(SERVER) + // A persisted id (or an unrelated type) is never redirected. + expect(o.resolveKey('Article', SERVER)).toBe(`Article:${SERVER}`) + expect(o.resolveKey('Comment', TEMP)).toBe(`Comment:${TEMP}`) + }) + + test('getPersistedId/isNewEntity reflect placeholder, temp, and persisted ids', () => { + const o = new RekeyOrchestrator([]) + + // Placeholder is always "new" and has no persisted id. + expect(o.getPersistedId('Article', PLACEHOLDER)).toBeNull() + expect(o.isNewEntity('Article', PLACEHOLDER)).toBe(true) + + // A persisted-looking id is itself the persisted id and is not new. + expect(o.getPersistedId('Article', SERVER)).toBe(SERVER) + expect(o.isNewEntity('Article', SERVER)).toBe(false) + + // A temp id has no persisted id until it is rekeyed. + expect(o.getPersistedId('Article', TEMP)).toBeNull() + expect(o.isNewEntity('Article', TEMP)).toBe(true) + + o.rekey('Article', TEMP, SERVER) + + expect(o.getPersistedId('Article', TEMP)).toBe(SERVER) + expect(o.isNewEntity('Article', TEMP)).toBe(false) + }) + + test('rekey visits every participant exactly once, in order', () => { + const calls: string[] = [] + const spy = (name: string): Rekeyable => ({ rekey: () => calls.push(name) }) + + const o = new RekeyOrchestrator([spy('a'), spy('b'), spy('c')]) + o.rekey('Article', TEMP, SERVER) + + expect(calls).toEqual(['a', 'b', 'c']) + }) + + test('rekey passes a fully-derived context to participants', () => { + let captured: RekeyContext | undefined + const o = new RekeyOrchestrator([{ rekey: ctx => { captured = ctx } }]) + + o.rekey('Article', TEMP, SERVER) + + expect(captured).toEqual({ + oldKey: `Article:${TEMP}`, + newKey: `Article:${SERVER}`, + oldKeyPrefix: `Article:${TEMP}:`, + newKeyPrefix: `Article:${SERVER}:`, + oldId: TEMP, + newId: SERVER, + }) + }) + + test('clear forgets all redirects', () => { + const o = new RekeyOrchestrator([]) + o.rekey('Article', TEMP, SERVER) + expect(o.resolveId('Article', TEMP)).toBe(SERVER) + + o.clear() + expect(o.resolveId('Article', TEMP)).toBe(TEMP) + expect(o.getPersistedId('Article', TEMP)).toBeNull() + }) + + test('end-to-end: SnapshotStore resolves a created entity after persist via the orchestrator', () => { + const store = new SnapshotStore() + const tempId = store.createEntity('Article', { title: 'Draft' }) + expect(store.isNewEntity('Article', tempId)).toBe(true) + expect(store.getPersistedId('Article', tempId)).toBeNull() + + store.mapTempIdToPersistedId('Article', tempId, 'server-99') + + // The handle still references the temp id; lookups transparently resolve. + expect(store.getPersistedId('Article', tempId)).toBe('server-99') + expect(store.isNewEntity('Article', tempId)).toBe(false) + expect(store.existsOnServer('Article', tempId)).toBe(true) + // The snapshot moved to the persisted key (id field rewritten). + expect(store.getEntitySnapshot('Article', 'server-99')?.data).toMatchObject({ id: 'server-99', title: 'Draft' }) + }) +}) From ea009e01c071d9b0b743e095fcd11479d9da8656 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:54:49 +0200 Subject: [PATCH 08/28] feat(bindx): add pessimistic presentation primitive (inert) Introduces the read primitive that will let handles present the server baseline during a pessimistic persist WITHOUT the current mutate-store- to-server-then-restore dance. Inert for now: no consumer reads it yet (wiring + removal of mutate-restore lands in the next PR). - EntityMetaStore tracks a pessimisticInFlight set (a subset of the persisting set), set/cleared in the same setPersisting transition so the two cannot drift; it does not bump the reachability mutation counter since the flag drives presentation only. - SnapshotStore.getPresentationSnapshot returns the canonical snapshot except while pessimistically in-flight, when it returns a frozen server-baseline view (data === serverData) built without mutating the store. Non-pessimistic entities are returned verbatim, so optimistic and not-persisting share one path. - The SET_PERSISTING action carries an optional pessimistic flag; BatchPersister sets it when updateMode is pessimistic. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/core/ActionDispatcher.ts | 1 + packages/bindx/src/core/actions.ts | 9 ++++- .../bindx/src/persistence/BatchPersister.ts | 5 +-- packages/bindx/src/store/EntityMetaStore.ts | 35 ++++++++++++++++-- packages/bindx/src/store/SnapshotStore.ts | 36 +++++++++++++++++-- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index bd66eda0..67506717 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -373,6 +373,7 @@ export class ActionDispatcher { action.entityType, action.entityId, action.isPersisting, + action.pessimistic ?? false, ) break diff --git a/packages/bindx/src/core/actions.ts b/packages/bindx/src/core/actions.ts index 4042d843..f3c31a1c 100644 --- a/packages/bindx/src/core/actions.ts +++ b/packages/bindx/src/core/actions.ts @@ -202,6 +202,12 @@ export interface SetPersistingAction { readonly entityType: string readonly entityId: string readonly isPersisting: boolean + /** + * Whether this persist is pessimistic. While a pessimistic persist is + * in-flight, handles present the server baseline instead of the (still dirty) + * local data. Ignored when isPersisting is false. + */ + readonly pessimistic?: boolean } // ==================== Error Actions ==================== @@ -428,8 +434,9 @@ export function setPersisting( entityType: string, entityId: string, isPersisting: boolean, + pessimistic: boolean = false, ): SetPersistingAction { - return { type: 'SET_PERSISTING', entityType, entityId, isPersisting } + return { type: 'SET_PERSISTING', entityType, entityId, isPersisting, pessimistic } } /** diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index f0e3339c..a173a22e 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -206,9 +206,10 @@ export class BatchPersister { // Mark all as in-flight this.changeRegistry.markInFlight(sortedEntities) - // Set persisting state for all entities + // Set persisting state for all entities. In pessimistic mode the flag also + // marks the entity for server-baseline presentation while in-flight. for (const entity of sortedEntities) { - this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true)) + this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic')) this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId)) } diff --git a/packages/bindx/src/store/EntityMetaStore.ts b/packages/bindx/src/store/EntityMetaStore.ts index a4b940f7..e8e23a1f 100644 --- a/packages/bindx/src/store/EntityMetaStore.ts +++ b/packages/bindx/src/store/EntityMetaStore.ts @@ -36,6 +36,14 @@ export class EntityMetaStore implements Rekeyable { /** Persisting status keyed by "entityType:id" */ private readonly persistingEntities = new Set() + /** + * Entities whose in-flight persist is pessimistic, keyed by "entityType:id". + * A subset of {@link persistingEntities}. While present, the entity is + * presented at its server baseline (see SnapshotStore.getPresentationSnapshot) + * even though its canonical data stays dirty. + */ + private readonly pessimisticInFlight = new Set() + /** * Monotonic counter bumped when reachability-relevant metadata changes — * `existsOnServer` and `isPersisting` (which seed the reachability roots) plus @@ -104,17 +112,31 @@ export class EntityMetaStore implements Rekeyable { return this.persistingEntities.has(key) } - setPersisting(key: string, isPersisting: boolean): void { + setPersisting(key: string, isPersisting: boolean, pessimistic: boolean = false): void { if (isPersisting) { if (!this.persistingEntities.has(key)) { this.persistingEntities.add(key) this.mutationVersion++ } - } else if (this.persistingEntities.delete(key)) { - this.mutationVersion++ + // The pessimistic flag drives presentation only, not reachability, so it + // deliberately does not bump mutationVersion. + if (pessimistic) { + this.pessimisticInFlight.add(key) + } else { + this.pessimisticInFlight.delete(key) + } + } else { + if (this.persistingEntities.delete(key)) { + this.mutationVersion++ + } + this.pessimisticInFlight.delete(key) } } + isPessimisticInFlight(key: string): boolean { + return this.pessimisticInFlight.has(key) + } + /** * Removes all metadata for an entity (load state, meta, persisting). */ @@ -122,6 +144,7 @@ export class EntityMetaStore implements Rekeyable { this.loadStates.delete(key) this.entityMetas.delete(key) this.persistingEntities.delete(key) + this.pessimisticInFlight.delete(key) this.mutationVersion++ } @@ -148,6 +171,11 @@ export class EntityMetaStore implements Rekeyable { this.persistingEntities.add(ctx.newKey) } + if (this.pessimisticInFlight.has(ctx.oldKey)) { + this.pessimisticInFlight.delete(ctx.oldKey) + this.pessimisticInFlight.add(ctx.newKey) + } + this.mutationVersion++ this.setExistsOnServer(ctx.newKey, true) } @@ -178,6 +206,7 @@ export class EntityMetaStore implements Rekeyable { this.loadStates.clear() this.entityMetas.clear() this.persistingEntities.clear() + this.pessimisticInFlight.clear() this.mutationVersion++ } } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index decb0db9..e882e721 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1,4 +1,5 @@ import type { EntitySnapshot, LoadStatus } from './snapshots.js' +import { createEntitySnapshot } from './snapshots.js' import type { FieldError } from '../errors/types.js' import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js' import { ErrorStore } from './ErrorStore.js' @@ -583,12 +584,43 @@ export class SnapshotStore implements SnapshotVersionBumper { return this.meta.isPersisting(key) } - setPersisting(entityType: string, id: string, isPersisting: boolean): void { + setPersisting(entityType: string, id: string, isPersisting: boolean, pessimistic: boolean = false): void { const key = this.getEntityKey(entityType, id) - this.meta.setPersisting(key, isPersisting) + this.meta.setPersisting(key, isPersisting, pessimistic) this.notifyEntitySubscribers(key) } + /** + * Returns the snapshot a consumer should DISPLAY for an entity. + * + * This equals the canonical {@link getEntitySnapshot} except while the entity + * is pessimistically in-flight, when it returns the server baseline (data === + * serverData) WITHOUT mutating the store. The canonical snapshot stays dirty, + * so dirty tracking, mutation building, and retry are unaffected — only the + * presented value is the pre-persist server view. + * + * Inert until consumers route their display reads through it (see PR 4); a + * non-pessimistic entity is returned verbatim, so optimistic mode and the + * not-persisting case share this one path. + */ + getPresentationSnapshot(entityType: string, id: string): EntitySnapshot | undefined { + const snapshot = this.getEntitySnapshot(entityType, id) + if (!snapshot) return undefined + + const key = this.getEntityKey(entityType, id) + if (!this.meta.isPessimisticInFlight(key)) { + return snapshot + } + + return createEntitySnapshot( + snapshot.id, + snapshot.entityType, + snapshot.serverData, + snapshot.serverData, + snapshot.version, + ) + } + // ==================== Error State (delegated to ErrorStore) ==================== getFieldErrors(entityType: string, id: string, fieldName: string): readonly FieldError[] { From 1fe2a66ba2409037e3f6280db4692af0f7c8dd9c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 18:54:49 +0200 Subject: [PATCH 09/28] test(bindx): cover the pessimistic presentation primitive Asserts presentation equals canonical without the flag, equals the server baseline while pessimistically in-flight (canonical staying dirty, still reported as an update), tracks optimistic vs pessimistic, restores on clear, and that the baseline view is frozen and not aliased. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- tests/unit/store/presentationFlag.test.ts | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/unit/store/presentationFlag.test.ts diff --git a/tests/unit/store/presentationFlag.test.ts b/tests/unit/store/presentationFlag.test.ts new file mode 100644 index 00000000..8a4947bf --- /dev/null +++ b/tests/unit/store/presentationFlag.test.ts @@ -0,0 +1,76 @@ +// Pessimistic presentation primitive (PR 3 / C1 of the store/persistence debt program). +// +// getPresentationSnapshot returns the snapshot a consumer should DISPLAY: the +// canonical snapshot, except while an entity is pessimistically in-flight, when +// it returns the server baseline (data === serverData) WITHOUT mutating the +// store. The canonical snapshot stays dirty, so dirty tracking and retry are +// unaffected. This primitive is inert until PR 4 routes display reads through it. +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' + +interface Article { + id: string + title: string +} + +describe('pessimistic presentation flag', () => { + let store: SnapshotStore + + beforeEach(() => { + store = new SnapshotStore() + // Server baseline "Server", with a local dirty edit "Local edit". + store.setEntityData('Article', 'a1', { id: 'a1', title: 'Server' }, true) + store.setFieldValue('Article', 'a1', ['title'], 'Local edit') + }) + + const presentedTitle = (): string | undefined => + store.getPresentationSnapshot
('Article', 'a1')?.data.title + const canonicalTitle = (): string | undefined => + store.getEntitySnapshot
('Article', 'a1')?.data.title + + test('without the flag, presentation equals the canonical snapshot', () => { + expect(presentedTitle()).toBe('Local edit') + expect(store.getPresentationSnapshot('Article', 'a1')).toBe(store.getEntitySnapshot('Article', 'a1')) + }) + + test('while pessimistically in-flight, presentation is the server baseline', () => { + store.setPersisting('Article', 'a1', true, true) + + expect(presentedTitle()).toBe('Server') + const presented = store.getPresentationSnapshot
('Article', 'a1') + expect(presented?.data).toEqual(presented?.serverData) + }) + + test('the canonical snapshot stays dirty during pessimistic in-flight', () => { + store.setPersisting('Article', 'a1', true, true) + + // Canonical data untouched — no mutate-restore. + expect(canonicalTitle()).toBe('Local edit') + // Still reported as a dirty update. + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: 'a1', + changeType: 'update', + }) + }) + + test('optimistic in-flight presents the live data, not the baseline', () => { + store.setPersisting('Article', 'a1', true, false) + expect(presentedTitle()).toBe('Local edit') + }) + + test('clearing the persisting flag restores canonical presentation', () => { + store.setPersisting('Article', 'a1', true, true) + expect(presentedTitle()).toBe('Server') + + store.setPersisting('Article', 'a1', false) + expect(presentedTitle()).toBe('Local edit') + }) + + test('the presented baseline snapshot is frozen and does not alias the stored one', () => { + store.setPersisting('Article', 'a1', true, true) + const presented = store.getPresentationSnapshot
('Article', 'a1') + expect(Object.isFrozen(presented)).toBe(true) + expect(presented).not.toBe(store.getEntitySnapshot('Article', 'a1')) + }) +}) From 241eb4a8d7125e3ad0607135da10a5b6de134659 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 19:03:36 +0200 Subject: [PATCH 10/28] refactor(bindx): route handle display reads through presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field and entity value display now read getPresentationSnapshot, so a pessimistically in-flight entity shows the server baseline. Dirty tracking keeps reading the canonical snapshot, so a field stays correctly dirty while its display shows the server value — the crucial split that lets the next commit delete the mutate-store-to-server-then-restore dance. - BaseHandle: getEntityData stays canonical (also used by has-many materialization); new getPresentationData for display. - FieldHandle.value -> presentation; isDirty compares canonical vs server. - EntityHandle.data -> presentation; getSnapshot/isDirty/serverData stay canonical. Scope note: relation display (has-one/has-many) still presents canonical state during a pessimistic in-flight window; presenting relations at the server baseline is a follow-up, cleaner once the has-one snapshot fallback is removed (keystone PR). Final post-persist states are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/handles/BaseHandle.ts | 14 +++++++++- packages/bindx/src/handles/EntityHandle.ts | 7 +++-- packages/bindx/src/handles/FieldHandle.ts | 13 ++++++--- tests/unit/handles/fieldHandle.test.ts | 32 ++++++++++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/bindx/src/handles/BaseHandle.ts b/packages/bindx/src/handles/BaseHandle.ts index 6dbd5ae3..f62bea68 100644 --- a/packages/bindx/src/handles/BaseHandle.ts +++ b/packages/bindx/src/handles/BaseHandle.ts @@ -83,13 +83,25 @@ export abstract class EntityRelatedHandle extends BaseHandle { } /** - * Get entity data. + * Get the CANONICAL entity data — the live, possibly-dirty values. Use this + * for dirty tracking and any logic that must reflect the user's edits, even + * while a pessimistic persist is in-flight. For values to DISPLAY, use + * {@link getPresentationData}. */ protected getEntityData(): Record | undefined { const snapshot = this.store.getEntitySnapshot(this.entityType, this.entityId) return snapshot?.data as Record | undefined } + /** + * Get the entity data a consumer should DISPLAY. Equals {@link getEntityData} + * except while the entity is pessimistically in-flight, when it returns the + * server baseline (the canonical data stays dirty underneath). + */ + protected getPresentationData(): Record | undefined { + return this.store.getPresentationSnapshot>(this.entityType, this.entityId)?.data + } + /** * Get entity server data. */ diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index d5db13a5..6eff44fc 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -140,11 +140,14 @@ export class EntityHandle extends Enti } /** - * Gets the current entity data. + * Gets the current entity data to DISPLAY. * Returns selected fields subset as specified by TSelected type parameter. + * While a pessimistic persist is in-flight this is the server baseline; the + * canonical snapshot (see {@link getSnapshot}, used for dirty tracking) stays + * dirty underneath. */ get data(): TSelected | null { - const snapshot = this.getSnapshot() + const snapshot = this.store.getPresentationSnapshot(this.entityType, this.entityId) return (snapshot?.data ?? null) as TSelected | null } diff --git a/packages/bindx/src/handles/FieldHandle.ts b/packages/bindx/src/handles/FieldHandle.ts index d2cdcde9..63192e38 100644 --- a/packages/bindx/src/handles/FieldHandle.ts +++ b/packages/bindx/src/handles/FieldHandle.ts @@ -87,10 +87,11 @@ export class FieldHandle extends EntityRelatedHandle { } /** - * Gets the current field value. + * Gets the current field value to DISPLAY. While a pessimistic persist is + * in-flight this is the server baseline; otherwise it is the live value. */ get value(): T | null { - const data = this.getEntityData() + const data = this.getPresentationData() if (!data) return null return getNestedValue(data, this.fieldPath) as T | null } @@ -105,10 +106,14 @@ export class FieldHandle extends EntityRelatedHandle { } /** - * Checks if the field has been modified. + * Checks if the field has been modified. Compares the CANONICAL value against + * the server value — never the presented value, so a field stays correctly + * dirty even while its display shows the server baseline mid-pessimistic-persist. */ get isDirty(): boolean { - return !deepEqual(this.value, this.serverValue) + const data = this.getEntityData() + const value = data ? (getNestedValue(data, this.fieldPath) as T | null) : null + return !deepEqual(value, this.serverValue) } /** diff --git a/tests/unit/handles/fieldHandle.test.ts b/tests/unit/handles/fieldHandle.test.ts index 3dd68e03..284f5c3e 100644 --- a/tests/unit/handles/fieldHandle.test.ts +++ b/tests/unit/handles/fieldHandle.test.ts @@ -55,6 +55,38 @@ describe('FieldHandle', () => { }) }) + // ==================== Pessimistic Presentation ==================== + + describe('Pessimistic Presentation', () => { + test('value shows the server baseline while pessimistically in-flight, but stays dirty', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Server' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Local edit') + const handle = createFieldHandle(['title']) + + expect(handle.value).toBe('Local edit') + expect(handle.isDirty).toBe(true) + + store.setPersisting('Article', 'a-1', true, true) + // Display flips to the server baseline; dirty state (canonical) is unchanged. + expect(handle.value).toBe('Server') + expect(handle.serverValue).toBe('Server') + expect(handle.isDirty).toBe(true) + + store.setPersisting('Article', 'a-1', false) + expect(handle.value).toBe('Local edit') + expect(handle.isDirty).toBe(true) + }) + + test('optimistic in-flight keeps showing the live value', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Server' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Local edit') + const handle = createFieldHandle(['title']) + + store.setPersisting('Article', 'a-1', true, false) + expect(handle.value).toBe('Local edit') + }) + }) + // ==================== Dirty State ==================== describe('Dirty State', () => { From 311d56c189319b506a78aaa0ca5f68dbfe15f043 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 19:08:49 +0200 Subject: [PATCH 11/28] refactor(bindx): delete pessimistic mutate-restore dance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With handle display reads now routed through getPresentationSnapshot, the store no longer needs to be reset to the server view during a pessimistic persist and restored afterward. Remove the whole capture/reset/restore machinery: - drop captureEntityStates, restoreEntityState and the CapturedEntityState type, and the per-update resetEntity + resetAllRelations block; - success now commits the (still-dirty) canonical state as the new server baseline via the same path optimistic mode always used; - failure leaves the entity dirty with no action — edits and created entities survive for a retry (P2) by construction, since the store was never mutated; - remove the now-dead store methods getAllRelationsForEntity, getAllHasManyForEntity and restoreHasManyState. The full pessimistic suite (commit-on-success, P2 preserve-edits and preserve-create on failure, isPersisting timing, batch) passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../bindx/src/persistence/BatchPersister.ts | 176 +++--------------- packages/bindx/src/store/RelationStore.ts | 48 ----- packages/bindx/src/store/SnapshotStore.ts | 22 --- 3 files changed, 23 insertions(+), 223 deletions(-) diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index a173a22e..65ffb094 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -25,19 +25,6 @@ import type { EntitySnapshot } from '../store/snapshots.js' import type { StoredHasManyState, StoredRelationState } from '../store/SnapshotStore.js' import { deepEqual } from '../utils/deepEqual.js' -/** - * Captured state for pessimistic mode restoration. - * Contains all the data needed to restore an entity's dirty state after - * temporarily resetting it to server state during pessimistic persistence. - */ -interface CapturedEntityState { - entityType: string - entityId: string - snapshot: EntitySnapshot | undefined - relations: Map - hasManyStates: Map -} - /** * Options for BatchPersister */ @@ -216,17 +203,12 @@ export class BatchPersister { // Block undo during persist this.undoManager?.block() - // For pessimistic mode, capture dirty state before building mutations - // This allows us to restore and commit the state after server confirmation - let capturedStates: CapturedEntityState[] | null = null - if (updateMode === 'pessimistic') { - capturedStates = this.captureEntityStates(sortedEntities) - } - let result: PersistenceResult | undefined try { - // Build mutations BEFORE resetting state (for pessimistic mode) - // The mutations need to contain the dirty data to send to server + // Build mutations from the (dirty) canonical state. The store is never + // mutated to the server view — pessimistic mode presents the server + // baseline via getPresentationSnapshot instead — so there is nothing to + // capture or restore. const mutations = this.buildMutations(sortedEntities, scope) if (mutations.length === 0) { @@ -241,23 +223,11 @@ export class BatchPersister { return result } - // For pessimistic mode, reset entities to server state after capturing - // This makes the UI show the "old" (server) data while persisting - if (updateMode === 'pessimistic') { - for (const entity of sortedEntities) { - // Only reset updates, not creates (creates don't have server state) - if (entity.changeType === 'update') { - this.dispatcher.dispatch(resetEntity(entity.entityType, entity.entityId)) - this.store.resetAllRelations(entity.entityType, entity.entityId) - } - } - } - // Execute transaction const transactionResult = await this.executeTransaction(mutations, options?.signal) - // Process results with captured state for pessimistic mode - result = this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates) + // Assigned to `result` so the finally block can gate the sweep on full success. + result = this.processTransactionResult(sortedEntities, transactionResult, scope, options) return result } finally { @@ -273,9 +243,9 @@ export class BatchPersister { this.undoManager?.unblock() // Reclaim memory: drop snapshots of any created entities orphaned during - // editing. Only after a FULLY successful persist — on failure the captured - // creates/edits were restored for retry (see processTransactionResult), and - // sweeping then could reclaim a create the user still intends to save. + // editing. Only after a FULLY successful persist — on a failed or partial + // persist the user's creates and edits are left intact and dirty for a retry, + // and sweeping then could reclaim a create the user still intends to save. if (result?.success) { this.store.sweepUnreachableCreated() } @@ -675,13 +645,11 @@ export class BatchPersister { transactionResult: TransactionResult, scope: PersistScope, options?: BatchPersistOptions, - capturedStates?: CapturedEntityState[] | null, ): PersistenceResult { const results: EntityPersistResult[] = [] let successCount = 0 let failedCount = 0 const rollbackOnError = options?.rollbackOnError ?? false - const isPessimistic = capturedStates != null if (transactionResult.ok) { // All succeeded - commit all @@ -692,24 +660,16 @@ export class BatchPersister { ) if (entity) { - // For pessimistic mode, restore captured state and commit - if (isPessimistic && capturedStates) { - const capturedState = capturedStates.find( - s => s.entityType === entity.entityType && s.entityId === entity.entityId, - ) - if (capturedState) { - this.restoreEntityState(capturedState, true) - } + // The store was never mutated to the server view, so a successful + // persist just commits the dirty state as the new server baseline — + // the same path optimistic mode always took. + if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) { + // Partial commit for field scope + this.store.commitFields(entity.entityType, entity.entityId, [...scope.fields]) } else { - // Commit based on scope - if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) { - // Partial commit for field scope - this.store.commitFields(entity.entityType, entity.entityId, [...scope.fields]) - } else { - // Full commit - this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) - this.store.commitAllRelations(entity.entityType, entity.entityId) - } + // Full commit + this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) + this.store.commitAllRelations(entity.entityType, entity.entityId) } // Map temp ID if create @@ -759,10 +719,11 @@ export class BatchPersister { mutationResult.errorMessage, ) - // Optimistic mode rolls a FAILED entity back to server state only when - // rollbackOnError is set. Pessimistic restore is handled below for ALL - // captured entities, not just the failed ones. - if (!isPessimistic && rollbackOnError) { + // The store was never mutated to the server view, so on failure the + // entity's edits and creates are already intact and dirty — they + // simply survive for a retry (P2), no restore needed. Rollback to + // server state happens only when rollbackOnError is set. + if (rollbackOnError) { this.rollbackEntity(entity) } } @@ -786,20 +747,6 @@ export class BatchPersister { } } } - - // Pessimistic mode: the transaction failed, so from the user's point of view - // nothing was committed. Undo the pre-transaction server-view reset for EVERY - // captured entity (not only the ones whose individual mutation failed) and keep - // the restored data dirty (commit=false) so the user can retry. Restoring even - // the entities whose mutation happened to succeed in the non-atomic sequential - // fallback is what re-establishes their relation edges to inline-created - // children — keeping those creates reachable so the post-persist sweep does not - // reclaim a child the user still intends to save (P2). - if (isPessimistic && capturedStates) { - for (const capturedState of capturedStates) { - this.restoreEntityState(capturedState, false) - } - } } return { @@ -811,83 +758,6 @@ export class BatchPersister { } } - /** - * Captures the current state of entities for pessimistic mode. - * This allows us to restore the dirty state after a successful persist. - */ - private captureEntityStates(entities: DirtyEntity[]): CapturedEntityState[] { - return entities.map(entity => { - const snapshot = this.store.getEntitySnapshot(entity.entityType, entity.entityId) - const relations = this.store.getAllRelationsForEntity(entity.entityType, entity.entityId) - const hasManyStates = this.store.getAllHasManyForEntity(entity.entityType, entity.entityId) - - return { - entityType: entity.entityType, - entityId: entity.entityId, - snapshot, - relations, - hasManyStates, - } - }) - } - - /** - * Restores a captured entity state (pessimistic mode), optionally committing it. - * - * - commit=true (server confirmed): restore the dirty data and commit it as the - * new server baseline. - * - commit=false (server rejected): restore the dirty data but leave it dirty, so - * the user's in-flight edits and creates survive for a retry (P2) instead of - * being stuck at the server view the pessimistic reset left behind. - */ - private restoreEntityState(capturedState: CapturedEntityState, commit: boolean): void { - if (capturedState.snapshot) { - this.store.setEntityData( - capturedState.entityType, - capturedState.entityId, - capturedState.snapshot.data as Record, - commit, // server data only when confirmed; otherwise keep it as a dirty edit - ) - } - - for (const [relationKey, relationState] of capturedState.relations) { - const fieldName = relationKey.split(':')[2] - if (fieldName) { - this.store.setRelation(capturedState.entityType, capturedState.entityId, fieldName, { - currentId: relationState.currentId, - state: relationState.state, - // Restore placeholderData too: a has-one in the 'creating' state carries - // its inline create payload here, and getDirtyRelations treats a - // non-empty placeholderData as a dirty signal. Dropping it on a - // commit=false retry would lose the inline data and silently mark the - // relation clean. (For commit=true it is cleared by commitRelation below.) - placeholderData: relationState.placeholderData, - }) - if (commit) { - this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName) - } - } - } - - for (const [hasManyKey, hasManyState] of capturedState.hasManyStates) { - const fieldName = hasManyKey.split(':')[2] - if (fieldName) { - this.store.restoreHasManyState(capturedState.entityType, capturedState.entityId, fieldName, hasManyState) - if (commit) { - // Compute new server IDs: serverIds - removals + connections - const newServerIds = new Set(hasManyState.serverIds) - for (const removedId of hasManyState.plannedRemovals.keys()) { - newServerIds.delete(removedId) - } - for (const connectedId of hasManyState.plannedConnections) { - newServerIds.add(connectedId) - } - this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds)) - } - } - } - } - /** * Rolls back an entity's optimistic changes to server state. * Handles all mutation types: create, update, and delete. diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 6df92170..b912b55c 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -556,21 +556,6 @@ export class RelationStore implements Rekeyable { return computeDefaultOrderedIds(existing) } - /** - * Restores a has-many state from a captured snapshot. - * Used in pessimistic mode after successful server confirmation. - */ - restoreHasManyState(key: string, state: StoredHasManyState): void { - this.writeHasMany(key, { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedConnections: new Set(state.plannedConnections), - createdEntities: new Set(state.createdEntities), - version: state.version + 1, - }) - } - // ==================== Bulk Operations ==================== /** @@ -614,39 +599,6 @@ export class RelationStore implements Rekeyable { } } - /** - * Gets all relation states for an entity. - */ - getAllRelationsForEntity(keyPrefix: string): Map { - const result = new Map() - for (const [key, state] of this.relationStates) { - if (key.startsWith(keyPrefix)) { - result.set(key, { ...state }) - } - } - return result - } - - /** - * Gets all has-many states for an entity. - */ - getAllHasManyForEntity(keyPrefix: string): Map { - const result = new Map() - for (const [key, state] of this.hasManyStates) { - if (key.startsWith(keyPrefix)) { - result.set(key, { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedConnections: new Set(state.plannedConnections), - createdEntities: new Set(state.createdEntities), - version: state.version, - }) - } - } - return result - } - // ==================== Dirty Tracking ==================== /** diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index e882e721..7727aad8 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -792,28 +792,6 @@ export class SnapshotStore implements SnapshotVersionBumper { this.relations.resetAllRelations(keyPrefix) } - getAllRelationsForEntity(entityType: string, entityId: string): Map { - const keyPrefix = `${entityType}:${entityId}:` - return this.relations.getAllRelationsForEntity(keyPrefix) - } - - getAllHasManyForEntity(entityType: string, entityId: string): Map { - const keyPrefix = `${entityType}:${entityId}:` - return this.relations.getAllHasManyForEntity(keyPrefix) - } - - restoreHasManyState( - parentType: string, - parentId: string, - fieldName: string, - state: StoredHasManyState, - alias?: string, - ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - this.relations.restoreHasManyState(key, state) - this.notifyRelationSubscribers(key) - } - // ==================== Subscriptions (delegated to SubscriptionManager) ==================== subscribeToEntity(entityType: string, id: string, callback: Subscriber): () => void { From feb12f6a92b53b1cf68712e8867a83db7396f737 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 19:13:24 +0200 Subject: [PATCH 12/28] test(bindx): pin parent-notification behavior before childToParents rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscriber call-count harness covering has-one/has-many parent re-render on child change, the diamond (shared child notifies both parents), rekey preserving subscriptions, and the known append-only childToParents leak (disconnect still notifies the former parent) — the regression oracle for the upcoming reverse-index notification rework. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../store/notificationPropagation.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/unit/store/notificationPropagation.test.ts diff --git a/tests/unit/store/notificationPropagation.test.ts b/tests/unit/store/notificationPropagation.test.ts new file mode 100644 index 00000000..edd6109b --- /dev/null +++ b/tests/unit/store/notificationPropagation.test.ts @@ -0,0 +1,151 @@ +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { createTestStore, createMockSubscriber } from '../shared/unitTestHelpers.js' + +/** + * Pins the CURRENT parent re-render / subscription-notification behavior with + * subscriber call-count assertions, BEFORE the notification machinery is rewired. + * + * A later PR replaces the append-only `childToParents` registry in + * `SubscriptionManager` with a reverse index derived from relation edges. This + * harness is the regression oracle: every assertion encodes today's behavior so + * the rework can prove it introduced no re-render regression. + * + * Mechanics worth keeping in mind while reading these tests: + * - `setRelation` / `addToHasMany` notify the relation's own subscribers and the + * relation OWNER's entity subscribers — they do NOT walk `childToParents`. + * - `setFieldValue` on the child entity calls `notifyEntitySubscribers`, which + * walks `childToParents` UP the tree and bumps each ancestor's snapshot version. + * So the child-field mutation is what exercises parent propagation here. + */ +describe('Notification propagation', () => { + let store: SnapshotStore + + beforeEach(() => { + store = createTestStore() + }) + + test('has-one parent re-renders when child field changes', () => { + // Server parent A with a child B connected via has-one. + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + + store.getOrCreateRelation('Author', 'author-1', 'featuredArticle', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.setRelation('Author', 'author-1', 'featuredArticle', { + currentId: 'article-1', + state: 'connected', + }) + store.registerParentChild('Author', 'author-1', 'Article', 'article-1') + + const parent = createMockSubscriber() + store.subscribeToEntity('Author', 'author-1', parent.fn) + parent.reset() + + // Mutate the child. + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + // The parent's subscriber must fire via child→parent propagation. + expect(parent.callCount()).toBe(1) + }) + + test('has-many parent re-renders when an item changes', () => { + // Server parent A with a child item B in a has-many. + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + + store.getOrCreateHasMany('Author', 'author-1', 'articles', []) + store.addToHasMany('Author', 'author-1', 'articles', 'article-1') + store.registerParentChild('Author', 'author-1', 'Article', 'article-1') + + const parent = createMockSubscriber() + store.subscribeToEntity('Author', 'author-1', parent.fn) + parent.reset() + + // Mutate the child item. + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + expect(parent.callCount()).toBe(1) + }) + + test('disconnect still notifies the former parent (current append-only behavior)', () => { + // Server parent A with a child B connected via has-one. + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + + store.getOrCreateRelation('Author', 'author-1', 'featuredArticle', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.setRelation('Author', 'author-1', 'featuredArticle', { + currentId: 'article-1', + state: 'connected', + }) + store.registerParentChild('Author', 'author-1', 'Article', 'article-1') + + // "Disconnect" the child: clear the relation. Note that a relation disconnect + // does NOT call unregisterParentChild, so the parent link survives. + store.setRelation('Author', 'author-1', 'featuredArticle', { + currentId: null, + state: 'disconnected', + }) + + const parent = createMockSubscriber() + store.subscribeToEntity('Author', 'author-1', parent.fn) + parent.reset() + + // Mutate the now-disconnected child. + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + // LEAK: PR 7 will make a disconnected child stop notifying its former parent; + // this assertion will flip then. + expect(parent.callCount()).toBe(1) + }) + + test('diamond: a shared child notifies both parents', () => { + // Child B connected to TWO parents A1 and A2. + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) + store.setEntityData('Author', 'author-2', { id: 'author-2', name: 'Bob' }, true) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + + store.registerParentChild('Author', 'author-1', 'Article', 'article-1') + store.registerParentChild('Author', 'author-2', 'Article', 'article-1') + + const parentOne = createMockSubscriber() + const parentTwo = createMockSubscriber() + store.subscribeToEntity('Author', 'author-1', parentOne.fn) + store.subscribeToEntity('Author', 'author-2', parentTwo.fn) + parentOne.reset() + parentTwo.reset() + + // Mutate the shared child. + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + expect(parentOne.callCount()).toBe(1) + expect(parentTwo.callCount()).toBe(1) + }) + + test('rekey preserves subscriptions', () => { + // Subscribe to a created (temp-id) entity, rekey it, then mutate via the + // persisted id and confirm the original subscription still fires. + const tempId = store.createEntity('Article', { title: 'Draft' }) + + const subscriber = createMockSubscriber() + store.subscribeToEntity('Article', tempId, subscriber.fn) + + store.mapTempIdToPersistedId('Article', tempId, 'article-persisted') + subscriber.reset() + + store.setFieldValue('Article', 'article-persisted', ['title'], 'Updated') + + expect(subscriber.callCount()).toBe(1) + }) +}) From 53f23e21787dd6db0b5f27ff103f3991d66c1fe9 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 19:29:23 +0200 Subject: [PATCH 13/28] refactor(bindx): make RelationStore authoritative for has-one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Has-one relation state had two sources of truth: the RelationStore entry AND the related object embedded in the parent's snapshot data. HasOneHandle read from the store when an entry existed, else fell back to the embedded snapshot — invisible to reachability-based create detection, which reads membership exclusively from RelationStore. Mirror the has-many path (HasManyListHandle.materializeEmbeddedItems): add an idempotent ensureEntry() that materializes the relation entry from the parent's embedded data on every relation-state read (relatedId/state/isDirty), then drop the snapshot fallback so RelationStore is the single source of truth. - Initial materialization uses the non-notifying getOrCreateRelation, deriving the server baseline from the parent's serverData so a freshly loaded relation is not dirty (currentId === serverId, state === serverState). - A parent re-fetch (embedded reference changed) advances the server baseline only when the relation is not locally dirty, via hasEmbeddedDataChanged — child-snapshot propagation keeps sole ownership of the propagation slot in ensureRelatedEntitySnapshot, so the two paths never double-consume it. - Local connect/disconnect, placeholders, and creating entries are never clobbered (detected as locally-dirty / id-mismatch). A created child connected via a has-one now appears in reachability and getAllDirtyEntities() as a create with no explicit setRelation, with no analyzer change. Tests cover loaded-not-dirty materialization, the dropped fallback, and the created-child-as-create case. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/handles/HasOneHandle.ts | 140 ++++++++++++++++----- tests/unit/handles/hasOneHandle.test.ts | 123 ++++++++++++++++++ 2 files changed, 233 insertions(+), 30 deletions(-) diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index 35faaecd..a02e6583 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -1,6 +1,7 @@ import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js' import type { ActionDispatcher } from '../core/ActionDispatcher.js' import type { SnapshotStore } from '../store/SnapshotStore.js' +import type { StoredRelationState } from '../store/RelationStore.js' import type { SchemaRegistry } from '../schema/SchemaRegistry.js' import type { SelectionMeta } from '../selection/types.js' import { @@ -17,6 +18,7 @@ import { type Unsubscribe, type EntityAccessor, type HasOneAccessor, + type HasOneRelationState, } from './types.js' import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js' import type { @@ -137,51 +139,122 @@ 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. + * Materializes the RelationStore entry from embedded snapshot data first, so a + * server-loaded has-one reports 'connected' without a prior explicit entry. */ - get state(): 'connected' | 'disconnected' | 'deleted' | 'creating' { + get state(): HasOneRelationState { + this.ensureEntry() const relation = this.store.getRelation( this.entityType, this.entityId, this.fieldName, ) - if (relation) { - return relation.state - } - // No explicit relation state — check if snapshot has embedded data - if (this.relatedId !== null) { - return 'connected' - } - return 'disconnected' + return relation?.state ?? 'disconnected' } /** - * Gets the current related entity ID. - * Falls back to entity data if relation state is not initialized. + * Gets the current related entity ID — read exclusively from the RelationStore + * after materializing the entry from embedded snapshot data. */ get relatedId(): string | null { - // First check relation state (for manual changes like connect/disconnect) + this.ensureEntry() const relation = this.store.getRelation( this.entityType, this.entityId, this.fieldName, ) - if (relation) { - return relation.currentId + return relation?.currentId ?? null + } + + /** + * Materializes this has-one's RelationStore entry from the parent's embedded + * snapshot data, so the store is the single source of truth (symmetric with + * {@link HasManyListHandle.materializeEmbeddedItems}). + * + * Only the relation entry is touched here — child-snapshot propagation stays in + * {@link ensureRelatedEntitySnapshot}, which owns the per-relation propagation + * slot so the two paths never double-consume it. + * + * - No entry yet + the parent embeds a related object with an id → create a + * `connected` entry (non-notifying) whose server baseline comes from the + * parent's serverData, so a freshly loaded relation is not dirty + * (currentId === serverId, state === serverState). + * - Existing entry + parent re-fetch (embedded reference changed) that is NOT + * locally dirty → advance the server baseline to the new related id. + * - A local connect/disconnect, a placeholder, or a `creating` entry is left + * untouched — it is detected as a locally-dirty relation. + * - No embedded data and no entry → the relation stays unmaterialized (null). + */ + private ensureEntry(): void { + const existing = this.store.getRelation(this.entityType, this.entityId, this.fieldName) + const embeddedId = this.readEmbeddedRelatedId() + + if (!existing) { + if (embeddedId === null) return + const serverId = this.readServerRelatedId() + this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, { + currentId: embeddedId, + serverId, + state: 'connected', + serverState: serverId !== null ? 'connected' : 'disconnected', + placeholderData: {}, + }) + return } - // Fallback to entity snapshot data (for server-loaded data) - const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId) - if (parentSnapshot?.data) { - const relatedData = (parentSnapshot.data as Record)[this.fieldName] - if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) { - return (relatedData as { id: string }).id - } + this.advanceServerBaselineOnRefetch(existing, embeddedId) + } + + /** + * On a parent re-fetch (embedded reference changed) advances the relation's + * server baseline to the embedded related id, but only when the relation is + * not locally dirty — a local connect/disconnect/create must survive a re-fetch. + * + * Does NOT consume the propagation slot — {@link ensureRelatedEntitySnapshot} + * owns it. The same reference-change signal drives both, so both run within the + * one render that observes a new parent reference. + */ + private advanceServerBaselineOnRefetch( + existing: StoredRelationState, + embeddedId: string | null, + ): void { + if (embeddedId === null || existing.serverId === embeddedId) return + if (this.isLocallyDirty(existing)) return + + const embeddedData = this.readEmbeddedRelatedData() + if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) { + return } - return null + this.store.setRelation(this.entityType, this.entityId, this.fieldName, { + currentId: embeddedId, + serverId: embeddedId, + state: 'connected', + serverState: 'connected', + }) + } + + private isLocallyDirty(relation: StoredRelationState): boolean { + return ( + relation.currentId !== relation.serverId || + relation.state !== relation.serverState || + Object.keys(relation.placeholderData).length > 0 + ) + } + + /** Reads the embedded related object from the parent's canonical current data. */ + private readEmbeddedRelatedData(): unknown { + return this.getEntityData()?.[this.fieldName] + } + + /** Extracts the related id from the parent's embedded current data, or null. */ + private readEmbeddedRelatedId(): string | null { + return extractRelatedId(this.readEmbeddedRelatedData()) + } + + /** Extracts the related id from the parent's embedded server data, or null. */ + private readServerRelatedId(): string | null { + return extractRelatedId(this.getServerData()?.[this.fieldName]) } /** @@ -354,6 +427,7 @@ export class HasOneHandle * Checks if the relation is dirty. */ get isDirty(): boolean { + this.ensureEntry() const relation = this.store.getRelation( this.entityType, this.entityId, @@ -361,11 +435,7 @@ export class HasOneHandle ) if (!relation) return false - return ( - relation.currentId !== relation.serverId || - relation.state !== relation.serverState || - Object.keys(relation.placeholderData).length > 0 - ) + return this.isLocallyDirty(relation) } /** @@ -593,3 +663,13 @@ export class HasOneHandle } } + +/** + * Extracts the related entity id from an embedded has-one object, or null when + * the value is absent / not an object with a string id. + */ +function extractRelatedId(embedded: unknown): string | null { + if (!embedded || typeof embedded !== 'object' || !('id' in embedded)) return null + const id = embedded.id + return typeof id === 'string' ? id : null +} diff --git a/tests/unit/handles/hasOneHandle.test.ts b/tests/unit/handles/hasOneHandle.test.ts index feb0da20..686f3586 100644 --- a/tests/unit/handles/hasOneHandle.test.ts +++ b/tests/unit/handles/hasOneHandle.test.ts @@ -676,4 +676,127 @@ describe('HasOneHandle', () => { expect(handle.__entityName).toBe('Author') }) }) + + // ==================== Eager Materialization (PR 6) ==================== + // + // RelationStore is the single source of truth for has-one: reading relatedId/ + // state materializes the entry from embedded snapshot data, and there is no + // longer a snapshot fallback in the getters. + + describe('Eager materialization', () => { + test('a loaded has-one materializes a RelationStore entry and is not dirty', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + + // No explicit setRelation — the entry only lives in embedded snapshot data. + expect(store.getRelation('Article', 'a-1', 'author')).toBeUndefined() + + const handle = createHasOneHandleRaw() + + // Reading state materializes the entry. + expect(handle.state).toBe('connected') + expect(handle.relatedId).toBe('auth-1') + + const relation = store.getRelation('Article', 'a-1', 'author') + expect(relation).toBeDefined() + expect(relation!.currentId).toBe('auth-1') + expect(relation!.serverId).toBe('auth-1') + expect(relation!.state).toBe('connected') + expect(relation!.serverState).toBe('connected') + + // A freshly materialized loaded relation must NOT be dirty. + expect(handle.isDirty).toBe(false) + }) + + test('relatedId reads purely from RelationStore (no snapshot fallback)', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + + const handle = createHasOneHandleRaw() + + // First read materializes the entry. + expect(handle.relatedId).toBe('auth-1') + + // Disconnect at the relation level only — the parent's embedded data is + // left untouched (still { id: 'auth-1', ... }). A snapshot fallback would + // resurrect 'auth-1'; reading purely from the store must report null. + store.setRelation('Article', 'a-1', 'author', { currentId: null, state: 'disconnected' }) + + const embedded = (store.getEntitySnapshot('Article', 'a-1')!.data as Record)['author'] + expect(embedded).toEqual({ id: 'auth-1', name: 'John' }) + + const handle2 = createHasOneHandleRaw() + expect(handle2.relatedId).toBeNull() + expect(handle2.state).toBe('disconnected') + }) + + test('disconnected (no embedded data, no entry) leaves the relation unmaterialized', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) + + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBeNull() + expect(handle.state).toBe('disconnected') + + // No spurious entry created for an empty relation. + expect(store.getRelation('Article', 'a-1', 'author')).toBeUndefined() + }) + + test('materialization does not clobber a local connect', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + // Local connect to a different author than the embedded one. + store.setRelation('Article', 'a-1', 'author', { currentId: 'auth-2', state: 'connected' }) + + const handle = createHasOneHandleRaw() + + // The local connect survives — embedded 'auth-1' must not win. + expect(handle.relatedId).toBe('auth-2') + expect(handle.isDirty).toBe(true) + }) + }) + + // ==================== Reachability / dirty for created has-one child ==================== + + describe('Created has-one child via embedded data', () => { + test('a created child connected via has-one appears as a create without explicit setRelation', () => { + // Server parent. + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) + + // User creates a new author (auto-rooted as a top-level create). + const childId = store.createEntity('Author', { name: 'Draft Author' }) + + // The connection lives ONLY in the parent's embedded current data — + // no store.setRelation() call. + store.setFieldValue('Article', 'a-1', ['author'], { id: childId, name: 'Draft Author' }) + + // Detach the auto-root so the child is reachable ONLY through the has-one + // edge once it is materialized (proves materialization drives reachability). + store.registerParentChild('Article', 'a-1', 'Author', childId) + store.unregisterRootEntity('Author', childId) + + // Before any handle read, the relation entry does not exist yet, so the + // child is not reachable through it. + expect(store.getRelation('Article', 'a-1', 'author')).toBeUndefined() + const beforeCreates = store.getAllDirtyEntities().filter(e => e.changeType === 'create') + expect(beforeCreates.map(e => e.entityId)).not.toContain(childId) + + // A handle read materializes the relation entry. + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe(childId) + + // Now the created child is reachable via the materialized has-one edge and + // reported as a create — no explicit setRelation was ever called. + const creates = store.getAllDirtyEntities().filter(e => e.changeType === 'create') + expect(creates.map(e => e.entityId)).toContain(childId) + }) + }) }) From 6ec3260227e4d19a68fdb5d85c2cd79c37944a99 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 20:59:25 +0200 Subject: [PATCH 14/28] refactor(bindx): derive parent notification from relation edges; remove childToParents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent re-render propagation used a separate append-only childToParents registry in SubscriptionManager, populated on every render via registerParentChild and never cleaned on disconnect (a leak). RelationStore is now the single source of truth for relation membership, so derive 'which parents reference this child' from its live edges instead. - Add RelationStore.getParentKeysForChild(childId): an on-demand scan of the live has-one/has-many edges, with liveness matching getLiveChildIds exactly. Chosen over an incremental reverse index: correct by construction (reads the same maps as the forward query), so nothing can drift on disconnect/rekey/clear. - Inject it into SubscriptionManager via a small ParentKeyLookup interface, mirroring SnapshotVersionBumper. notifyEntitySubscribers derives parents from it, preserving the recursion + cycle guard and the parent snapshot bump. - Remove childToParents, registerParentChild/unregisterParentChild bodies, and their migration in rekey(). SnapshotStore.registerParentChild keeps only the load-bearing roots.unregister(childKey) side effect; unregisterParentChild is deleted. Render-time callers (handles, ActionDispatcher) are unchanged and now trigger only the root-unregister half — the relation edge they already set up is the notification source. A disconnected child no longer notifies its former parent (the leak is gone). Tests: flip the harness disconnect scenario to the no-leak behavior; update the notification harness diamond and the rekey/snapshotStore/actionDispatcher tests to establish real relation edges (the new notification source) instead of bare registerParentChild calls. Add getParentKeysForChild.test.ts with a randomized cross-check proving the reverse query always agrees with getLiveChildIds. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/RelationStore.ts | 55 +++++++ packages/bindx/src/store/SnapshotStore.ts | 25 +-- .../bindx/src/store/SubscriptionManager.ts | 90 ++++++----- tests/subscriptionRekey.test.ts | 29 +++- tests/unit/core/actionDispatcher.test.ts | 14 +- .../unit/store/getParentKeysForChild.test.ts | 142 ++++++++++++++++++ .../store/notificationPropagation.test.ts | 21 ++- tests/unit/store/snapshotStore.test.ts | 11 +- 8 files changed, 311 insertions(+), 76 deletions(-) create mode 100644 tests/unit/store/getParentKeysForChild.test.ts diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index b912b55c..870324a7 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -495,6 +495,50 @@ export class RelationStore implements Rekeyable { return Array.from(ids) } + /** + * Collects the composite keys ("parentType:parentId") of every entity that + * currently has a LIVE relation edge pointing at {@link childId}. This is the + * reverse of {@link getLiveChildIds}: instead of "which children does this + * parent reach", it answers "which parents reference this child". + * + * It is the single source of truth for parent re-render notification — when a + * child entity's own field changes, every parent returned here is notified so + * its rendered view of the child updates. + * + * Liveness matches {@link getLiveChildIds} EXACTLY so the two never disagree: + * - has-one: currentId === childId, when the relation is not 'deleted' + * (a disconnected relation has a null currentId and is skipped); + * - has-many: childId ∈ (serverIds ∪ plannedConnections ∪ createdEntities) + * and childId ∉ plannedRemovals. + * + * Implemented as an on-demand scan of the live edges rather than an + * incrementally-maintained reverse index: it is correct by construction (it + * reads the same maps the forward query reads), so there is no second + * structure that could drift out of sync on disconnect/remove/rekey/clear. + */ + getParentKeysForChild(childId: string): Set { + const parents = new Set() + + for (const [key, state] of this.relationStates) { + if (state.currentId === childId && state.state !== 'deleted') { + parents.add(parentKeyFromRelationKey(key)) + } + } + + for (const [key, state] of this.hasManyStates) { + if (state.plannedRemovals.has(childId)) continue + if ( + state.serverIds.has(childId) || + state.plannedConnections.has(childId) || + state.createdEntities.has(childId) + ) { + parents.add(parentKeyFromRelationKey(key)) + } + } + + return parents + } + /** * Removes all relation and has-many state owned by an entity (keys under the * given owner prefix). Called by removeEntity so a removed entity leaves no @@ -836,6 +880,17 @@ export class RelationStore implements Rekeyable { // ==================== Helper Functions ==================== +/** + * Derives the parent composite key ("parentType:parentId") from a relation key + * ("parentType:parentId:fieldName") by dropping the trailing field segment. + * Entity ids and field names never contain ':', so the parent key is everything + * before the last separator. + */ +function parentKeyFromRelationKey(relationKey: string): string { + const lastSeparator = relationKey.lastIndexOf(':') + return relationKey.slice(0, lastSeparator) +} + /** * Computes the default ordered IDs for a has-many relation. * Order is: serverIds (minus removals) + plannedConnections diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 7727aad8..1353201a 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -64,6 +64,10 @@ export class SnapshotStore implements SnapshotVersionBumper { constructor() { this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots) this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability) + // Parent re-render propagation is derived from the relation store's live + // edges — the single source of truth for relation membership — rather than + // a separate parent-child registry. + this.subscriptions.setParentKeyLookup(this.relations) // The participants are visited in this exact order on every rekey — see the // ordering contract in RekeyOrchestrator.rekey(). Propagation tracking lives // on this store, so it joins as a small inline adapter. @@ -823,21 +827,22 @@ export class SnapshotStore implements SnapshotVersionBumper { // ==================== Parent-Child Relationships ==================== + /** + * Anchors a child under a parent relation. Parent re-render notification is now + * DERIVED from the relation store's live edges (see + * {@link SubscriptionManager.getParentKeys}), so the only remaining effect is + * the reachability one: a child anchored by a parent relation is no longer a + * top-level root; its reachability flows through the parent. (No-op for server + * children that were never roots.) + * + * Callers (handles, the action dispatcher) keep calling this on connect so the + * root-unregister stays centralized in one place. + */ registerParentChild(parentType: string, parentId: string, childType: string, childId: string): void { - const parentKey = this.getEntityKey(parentType, parentId) const childKey = this.getEntityKey(childType, childId) - this.subscriptions.registerParentChild(parentKey, childKey) - // A child anchored by a parent relation is no longer a top-level root; its - // reachability now flows through the parent. (No-op for server children.) this.roots.unregister(childKey) } - unregisterParentChild(parentType: string, parentId: string, childType: string, childId: string): void { - const parentKey = this.getEntityKey(parentType, parentId) - const childKey = this.getEntityKey(childType, childId) - this.subscriptions.unregisterParentChild(parentKey, childKey) - } - // ==================== Partial Snapshot Export/Import ==================== exportPartialSnapshot(keys: { diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index b4ef7c1d..a44780b9 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -10,6 +10,16 @@ export interface SnapshotVersionBumper { bumpEntitySnapshotVersion(key: string): void } +/** + * Resolves the parent entity keys that currently have a LIVE relation edge to a + * child, so a child-field change can propagate up to its parents. Implemented by + * the relation store (the single source of truth for relation membership); this + * interface keeps {@link SubscriptionManager} decoupled from its concrete type. + */ +export interface ParentKeyLookup { + getParentKeysForChild(childId: string): Set +} + /** * Manages subscriptions for entity and relation changes. * @@ -17,7 +27,7 @@ export interface SnapshotVersionBumper { * - Entity-level subscriptions * - Relation-level subscriptions * - Global subscriptions (any change) - * - Parent-child change propagation + * - Parent-child change propagation (derived from live relation edges) * - Global version tracking for change detection */ export class SubscriptionManager implements Rekeyable { @@ -30,15 +40,19 @@ export class SubscriptionManager implements Rekeyable { /** Global subscribers (notified on any change) */ private readonly globalSubscribers = new Set() - /** Parent-child relationships: childKey -> Set of parentKeys */ - private readonly childToParents = new Map>() - /** Maps old keys → new keys after rekey, so unsubscribe closures can find migrated callbacks */ private readonly rekeyedKeys = new Map() /** Global version number for change detection */ private globalVersion = 0 + /** + * Resolves a child's parents from live relation edges. Injected after + * construction (the relation store and this manager are siblings under + * SnapshotStore) via {@link setParentKeyLookup}. + */ + private parentKeyLookup: ParentKeyLookup | undefined + /** * Resolves a key through the rekey redirect chain. * The rekey() method collapses chains (A→C instead of A→B→C), so this should @@ -121,29 +135,25 @@ export class SubscriptionManager implements Rekeyable { // ==================== Parent-Child Relationships ==================== /** - * Registers a parent-child relationship for change propagation. - * When the child entity changes, parent entity subscribers will be notified. + * Wires the live-edge parent lookup. Parent re-render propagation is derived + * from the relation store's edges rather than a separate registry, so there is + * one source of truth for the parent-child graph. */ - registerParentChild(parentKey: string, childKey: string): void { - let parents = this.childToParents.get(childKey) - if (!parents) { - parents = new Set() - this.childToParents.set(childKey, parents) - } - parents.add(parentKey) + setParentKeyLookup(lookup: ParentKeyLookup): void { + this.parentKeyLookup = lookup } /** - * Unregisters a parent-child relationship. + * Derives the parent entity keys for a child entity key by reading the live + * relation edges via the injected lookup. The child's bare id is the part of + * the key after the first ':' ("entityType:id"). */ - unregisterParentChild(parentKey: string, childKey: string): void { - const parents = this.childToParents.get(childKey) - if (parents) { - parents.delete(parentKey) - if (parents.size === 0) { - this.childToParents.delete(childKey) - } - } + private getParentKeys(childKey: string): Set { + if (!this.parentKeyLookup) return new Set() + const separator = childKey.indexOf(':') + if (separator === -1) return new Set() + const childId = childKey.slice(separator + 1) + return this.parentKeyLookup.getParentKeysForChild(childId) } // ==================== Notification ==================== @@ -171,14 +181,14 @@ export class SubscriptionManager implements Rekeyable { } } - // Notify parent entity subscribers (propagate change up the tree) - const parents = this.childToParents.get(key) - if (parents) { - for (const parentKey of parents) { - // Bump parent snapshot version so useSyncExternalStore detects a change - bumper.bumpEntitySnapshotVersion(parentKey) - this.notifyEntitySubscribers(parentKey, bumper, notifiedKeys) - } + // Notify parent entity subscribers (propagate change up the tree). + // Parents are derived from the relation store's LIVE edges, so a + // disconnected child no longer reaches its former parent. + const parents = this.getParentKeys(key) + for (const parentKey of parents) { + // Bump parent snapshot version so useSyncExternalStore detects a change + bumper.bumpEntitySnapshotVersion(parentKey) + this.notifyEntitySubscribers(parentKey, bumper, notifiedKeys) } // Notify global subscribers (only once, not for each parent) @@ -263,9 +273,12 @@ export class SubscriptionManager implements Rekeyable { } /** - * Moves subscriptions and parent-child relationships from oldKey to newKey. + * Moves entity and relation subscriptions from oldKey to newKey. * Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix. * Registers redirects so unsubscribe closures can find migrated callbacks. + * + * Parent-child links are NOT migrated here — they are derived from the + * relation store's live edges, which migrate their own id references on rekey. */ rekey(ctx: RekeyContext): void { const { oldKey, newKey, oldKeyPrefix, newKeyPrefix } = ctx @@ -305,20 +318,5 @@ export class SubscriptionManager implements Rekeyable { this.relationSubscribers.delete(oldRelKey) this.relationSubscribers.set(newRelKey, subs) } - - // Move parent-child: update child→parent mappings - const parents = this.childToParents.get(oldKey) - if (parents) { - this.childToParents.delete(oldKey) - this.childToParents.set(newKey, parents) - } - - // Update parent-child: replace oldKey in any parent sets that reference it - for (const parentSet of this.childToParents.values()) { - if (parentSet.has(oldKey)) { - parentSet.delete(oldKey) - parentSet.add(newKey) - } - } } } diff --git a/tests/subscriptionRekey.test.ts b/tests/subscriptionRekey.test.ts index 60baadb6..d0d747d1 100644 --- a/tests/subscriptionRekey.test.ts +++ b/tests/subscriptionRekey.test.ts @@ -213,7 +213,8 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const parentId = store.createEntity('Author', { name: 'John' }) const childId = store.createEntity('Article', { title: 'Draft' }) - // Register parent-child before mapping + // Establish a live relation edge — parent notification is derived from it. + store.setRelation('Author', parentId, 'featuredArticle', { currentId: childId, state: 'connected' }) store.registerParentChild('Author', parentId, 'Article', childId) const parentCallback = mock(() => {}) @@ -243,7 +244,11 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const roundId = store.createEntity('Round', { roundNumber: 1 }) const reviewId = store.createEntity('Review', { comment: 'initial' }) - // Register parent-child chain (simulates what handles do during render) + // Establish live relation edges down the chain — parent notification is + // derived from these. registerParentChild also un-roots each anchored child. + store.setRelation('Program', 'prog-1', 'approval', { currentId: approvalId, state: 'connected' }) + store.setRelation('Approval', approvalId, 'round', { currentId: roundId, state: 'connected' }) + store.setRelation('Round', roundId, 'review', { currentId: reviewId, state: 'connected' }) store.registerParentChild('Program', 'prog-1', 'Approval', approvalId) store.registerParentChild('Approval', approvalId, 'Round', roundId) store.registerParentChild('Round', roundId, 'Review', reviewId) @@ -275,6 +280,9 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const roundId = store.createEntity('Round', { roundNumber: 1 }) const reviewId = store.createEntity('Review', { comment: 'initial' }) + store.setRelation('Program', 'prog-1', 'approval', { currentId: approvalId, state: 'connected' }) + store.setRelation('Approval', approvalId, 'round', { currentId: roundId, state: 'connected' }) + store.setRelation('Round', roundId, 'review', { currentId: reviewId, state: 'connected' }) store.registerParentChild('Program', 'prog-1', 'Approval', approvalId) store.registerParentChild('Approval', approvalId, 'Round', roundId) store.registerParentChild('Round', roundId, 'Review', reviewId) @@ -307,7 +315,9 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const approvalId = store.createEntity('Approval', { status: 'pending' }) const roundId = store.createEntity('Round', { roundNumber: 1 }) - // Initial parent-child registration + // Initial relation edges + parent-child registration + store.setRelation('Program', 'prog-1', 'approval', { currentId: approvalId, state: 'connected' }) + store.setRelation('Approval', approvalId, 'round', { currentId: roundId, state: 'connected' }) store.registerParentChild('Program', 'prog-1', 'Approval', approvalId) store.registerParentChild('Approval', approvalId, 'Round', roundId) @@ -338,12 +348,15 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const roundId = store.createEntity('Round', { roundNumber: 1 }) const reviewId = store.createEntity('Review', { comment: 'init' }) - // Rekey WITHOUT any prior registerParentChild + // Rekey WITHOUT any prior relation edge / registerParentChild store.mapTempIdToPersistedId('Round', roundId, 'uuid-round') store.mapTempIdToPersistedId('Review', reviewId, 'uuid-review') - // Now handles register parent-child (during render AFTER persist) - // using temp IDs — getEntityKey resolves them + // Now handles establish the relation edges + register parent-child (during + // render AFTER persist) using temp IDs — getRelationKey/getEntityKey resolve + // them to the persisted keys/ids, so notification follows the live edges. + store.setRelation('Program', 'prog-1', 'round', { currentId: 'uuid-round', state: 'connected' }) + store.setRelation('Round', roundId, 'review', { currentId: 'uuid-review', state: 'connected' }) store.registerParentChild('Program', 'prog-1', 'Round', roundId) store.registerParentChild('Round', roundId, 'Review', reviewId) @@ -366,6 +379,10 @@ describe('Subscription migration after mapTempIdToPersistedId', () => { const parentId = store.createEntity('Parent', { data: 1 }) const childId = store.createEntity('Child', { data: 2 }) + // Live relation edges drive notification; replaceEntityId migrates the stored + // currentId references when each end is rekeyed, in either order. + store.setRelation('Root', 'root-1', 'child', { currentId: parentId, state: 'connected' }) + store.setRelation('Parent', parentId, 'child', { currentId: childId, state: 'connected' }) store.registerParentChild('Root', 'root-1', 'Parent', parentId) store.registerParentChild('Parent', parentId, 'Child', childId) diff --git a/tests/unit/core/actionDispatcher.test.ts b/tests/unit/core/actionDispatcher.test.ts index d1a8adb0..30fc58e5 100644 --- a/tests/unit/core/actionDispatcher.test.ts +++ b/tests/unit/core/actionDispatcher.test.ts @@ -704,20 +704,28 @@ describe('ActionDispatcher', () => { expect(parentCallback).toHaveBeenCalled() }) - test('store.addToHasMany alone does NOT register parent-child', () => { + test('store.addToHasMany alone propagates: the live edge is the notification source', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) store.getOrCreateHasMany('Article', 'a-1', 'tags') store.setEntityData('Tag', 't-1', { id: 't-1', name: 'JS' }, true) - // Use low-level store API directly (no dispatcher) + // Use low-level store API directly (no dispatcher). The has-many edge IS the + // single source of truth for parent notification — there is no separate + // parent-child registry to populate. store.addToHasMany('Article', 'a-1', 'tags', 't-1') const parentCallback = mock(() => {}) store.subscribeToEntity('Article', 'a-1', parentCallback) parentCallback.mockClear() - // Modifying the child should NOT propagate — no parent-child registered + // Modifying the child propagates to the parent via the live relation edge. store.setFieldValue('Tag', 't-1', ['name'], 'TypeScript') + expect(parentCallback).toHaveBeenCalled() + + // Removing the edge severs propagation. + store.removeFromHasMany('Article', 'a-1', 'tags', 't-1', 'disconnect') + parentCallback.mockClear() + store.setFieldValue('Tag', 't-1', ['name'], 'JavaScript') expect(parentCallback).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/store/getParentKeysForChild.test.ts b/tests/unit/store/getParentKeysForChild.test.ts new file mode 100644 index 00000000..8debbb12 --- /dev/null +++ b/tests/unit/store/getParentKeysForChild.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect } from 'bun:test' +import { RelationStore } from '../../../packages/bindx/src/store/RelationStore.js' + +/** + * Behavioral + cross-check tests for {@link RelationStore.getParentKeysForChild}, + * the reverse query that powers parent re-render notification after the + * append-only `childToParents` registry was removed. + * + * The cross-check proves the reverse query agrees with the forward query + * ({@link RelationStore.getLiveChildIds}): a child appears under a parent iff that + * parent's live children include the child. Since both read the same maps with + * the same liveness rules, they must never disagree — the randomized sequence is + * the safety net guarding that invariant. + */ +describe('RelationStore.getParentKeysForChild', () => { + test('has-one connect returns the parent; disconnect removes it', () => { + const relations = new RelationStore() + + relations.setRelation( + 'Author:a1:featured', + { currentId: 'art1', state: 'connected' }, + undefined, + 'featured', + ) + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) + + relations.setRelation( + 'Author:a1:featured', + { currentId: null, state: 'disconnected' }, + undefined, + 'featured', + ) + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('has-one in deleted state does not anchor its target', () => { + const relations = new RelationStore() + + relations.setRelation( + 'Author:a1:featured', + { currentId: 'art1', state: 'deleted' }, + undefined, + 'featured', + ) + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('has-many add returns the parent; remove (disconnect) removes it', () => { + const relations = new RelationStore() + + relations.addToHasMany('Author:a1:articles', 'art1') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) + + relations.removeFromHasMany('Author:a1:articles', 'art1', 'disconnect') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('has-many server item is a live parent until removed', () => { + const relations = new RelationStore() + + relations.setHasManyServerIds('Author:a1:articles', ['art1', 'art2']) + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) + + relations.planHasManyRemoval('Author:a1:articles', 'art1', 'delete') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('diamond: a shared child returns both parents', () => { + const relations = new RelationStore() + + relations.setRelation( + 'Author:a1:featured', + { currentId: 'art1', state: 'connected' }, + undefined, + 'featured', + ) + relations.addToHasMany('Tag:t1:articles', 'art1') + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1', 'Tag:t1'])) + }) + + test('cross-check: reverse query agrees with getLiveChildIds over a randomized sequence', () => { + const relations = new RelationStore() + const parents = ['Author:a1', 'Author:a2', 'Tag:t1', 'Tag:t2'] + const children = ['art1', 'art2', 'art3', 'art4'] + + // Deterministic pseudo-random sequence (no flakiness, full reproducibility). + let seed = 0x1234abcd + const rand = (n: number): number => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed % n + } + + const pick = (list: readonly T[]): T => list[rand(list.length)]! + + for (let step = 0; step < 400; step++) { + const parent = pick(parents) + const child = pick(children) + const hasOneKey = `${parent}:featured` + const hasManyKey = `${parent}:articles` + + switch (rand(7)) { + case 0: + relations.setRelation(hasOneKey, { currentId: child, state: 'connected' }, undefined, 'featured') + break + case 1: + relations.setRelation(hasOneKey, { currentId: null, state: 'disconnected' }, undefined, 'featured') + break + case 2: + relations.setRelation(hasOneKey, { currentId: child, state: 'deleted' }, undefined, 'featured') + break + case 3: + relations.addToHasMany(hasManyKey, child) + break + case 4: + relations.planHasManyConnection(hasManyKey, child) + break + case 5: + relations.removeFromHasMany(hasManyKey, child, 'disconnect') + break + case 6: + relations.setHasManyServerIds(hasManyKey, [child]) + break + } + + // Forward-derived expectation: a parent should appear for `child` iff + // `child` is among that parent's live children. + for (const c of children) { + const expected = new Set() + for (const p of parents) { + if (relations.getLiveChildIds(`${p}:`).includes(c)) { + expected.add(p) + } + } + expect(relations.getParentKeysForChild(c)).toEqual(expected) + } + } + }) +}) diff --git a/tests/unit/store/notificationPropagation.test.ts b/tests/unit/store/notificationPropagation.test.ts index edd6109b..e4e2b2da 100644 --- a/tests/unit/store/notificationPropagation.test.ts +++ b/tests/unit/store/notificationPropagation.test.ts @@ -73,7 +73,7 @@ describe('Notification propagation', () => { expect(parent.callCount()).toBe(1) }) - test('disconnect still notifies the former parent (current append-only behavior)', () => { + test('disconnect stops notifying the former parent', () => { // Server parent A with a child B connected via has-one. store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) @@ -91,8 +91,8 @@ describe('Notification propagation', () => { }) store.registerParentChild('Author', 'author-1', 'Article', 'article-1') - // "Disconnect" the child: clear the relation. Note that a relation disconnect - // does NOT call unregisterParentChild, so the parent link survives. + // "Disconnect" the child: clear the relation edge. Parent notification is now + // derived from the live relation edge, so clearing it severs the link. store.setRelation('Author', 'author-1', 'featuredArticle', { currentId: null, state: 'disconnected', @@ -105,17 +105,24 @@ describe('Notification propagation', () => { // Mutate the now-disconnected child. store.setFieldValue('Article', 'article-1', ['title'], 'Updated') - // LEAK: PR 7 will make a disconnected child stop notifying its former parent; - // this assertion will flip then. - expect(parent.callCount()).toBe(1) + // The former parent must NOT be notified — there is no live edge to it. + expect(parent.callCount()).toBe(0) }) test('diamond: a shared child notifies both parents', () => { - // Child B connected to TWO parents A1 and A2. + // Child B connected to TWO parents A1 and A2 via live relation edges. store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) store.setEntityData('Author', 'author-2', { id: 'author-2', name: 'Bob' }, true) store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + store.setRelation('Author', 'author-1', 'featuredArticle', { + currentId: 'article-1', + state: 'connected', + }) + store.setRelation('Author', 'author-2', 'featuredArticle', { + currentId: 'article-1', + state: 'connected', + }) store.registerParentChild('Author', 'author-1', 'Article', 'article-1') store.registerParentChild('Author', 'author-2', 'Article', 'article-1') diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index 2cd7cd4e..a334f8df 100644 --- a/tests/unit/store/snapshotStore.test.ts +++ b/tests/unit/store/snapshotStore.test.ts @@ -1109,6 +1109,8 @@ describe('SnapshotStore', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) store.setEntityData('Author', 'auth-1', { id: 'auth-1', name: 'John' }, true) + // Parent notification is derived from the live relation edge. + store.setRelation('Article', 'a-1', 'author', { currentId: 'auth-1', state: 'connected' }) store.registerParentChild('Article', 'a-1', 'Author', 'auth-1') store.subscribeToEntity('Article', 'a-1', subscriber.fn) @@ -1117,18 +1119,19 @@ describe('SnapshotStore', () => { expect(subscriber.callCount()).toBeGreaterThan(0) }) - test('should unregister parent-child relationship', () => { + test('should stop propagating after the relation edge is disconnected', () => { const subscriber = createMockSubscriber() store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) store.setEntityData('Author', 'auth-1', { id: 'auth-1', name: 'John' }, true) - store.registerParentChild('Article', 'a-1', 'Author', 'auth-1') - store.unregisterParentChild('Article', 'a-1', 'Author', 'auth-1') + store.setRelation('Article', 'a-1', 'author', { currentId: 'auth-1', state: 'connected' }) + // Disconnecting the edge severs parent notification (no separate registry). + store.setRelation('Article', 'a-1', 'author', { currentId: null, state: 'disconnected' }) store.subscribeToEntity('Article', 'a-1', subscriber.fn) store.setFieldValue('Author', 'auth-1', ['name'], 'Jane') - // Parent should not be notified after unregistering + // Parent should not be notified — there is no live edge to it. expect(subscriber.callCount()).toBe(0) }) }) From b20584113edcad5b16decbaf36fca23315f518aa Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 21:10:08 +0200 Subject: [PATCH 15/28] refactor(bindx): collapse has-many planned-addition sets into one map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace StoredHasManyState's plannedConnections: Set and createdEntities: Set with a single plannedAdditions: Map. The map keys are exactly the old plannedConnections; keys whose value is 'created' are exactly the old createdEntities, making the "createdEntities ⊆ plannedConnections" invariant structural and removing one mutable field (5→4). add() records 'created', connect()/embedded-connect record 'connected' without downgrading an existing 'created' entry. removeFromHasMany branches on get(id) === 'created'. getLiveChildIds, getParentKeysForChild, dirty tracking, commit, rekey, export/import and computeDefaultOrderedIds updated to the new field. SnapshotStore accessors keep their Set/boolean return types so external callers are unaffected; MutationCollector emits create vs connect from the kind. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../bindx/src/handles/HasManyListHandle.ts | 3 +- .../src/persistence/MutationCollector.ts | 25 ++- packages/bindx/src/store/RelationStore.ts | 154 ++++++++---------- packages/bindx/src/store/SnapshotStore.ts | 14 +- tests/unit/core/actionDispatcher.test.ts | 2 +- tests/unit/handles/hasManyAlias.test.ts | 6 +- tests/unit/handles/hasManyHandle.test.ts | 20 +-- tests/unit/persistence/rollback.test.ts | 4 +- tests/unit/store/snapshotStore.test.ts | 28 ++-- 9 files changed, 121 insertions(+), 135 deletions(-) diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 6ab29ce6..ae03a7fc 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -322,8 +322,7 @@ export class HasManyListHandle 0 || - state.plannedConnections.size > 0 || - state.createdEntities.size > 0 || + state.plannedAdditions.size > 0 || state.orderedIds !== null ) } diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index 5d04bc5a..bac4b471 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -472,22 +472,19 @@ export class MutationCollector implements MutationDataCollector { } } - // Created entities -> create (using collectCreateData for full recursive collection) - if (targetType) { - for (const tempId of hasManyState.createdEntities) { - this._nestedEntityIds.add(tempId) - this._nestedEntityTypes.set(tempId, targetType) - const createData = this.collectCreateData(targetType, tempId) - operations.push({ create: createData ?? {}, alias: tempId }) + // Planned additions -> create (newly created) or connect (existing persisted) + for (const [additionId, kind] of hasManyState.plannedAdditions) { + if (kind === 'created') { + if (!targetType) continue + this._nestedEntityIds.add(additionId) + this._nestedEntityTypes.set(additionId, targetType) + const createData = this.collectCreateData(targetType, additionId) + operations.push({ create: createData ?? {}, alias: additionId }) + } else { + operations.push({ connect: { id: additionId }, alias: additionId }) } } - // Planned connections (minus created entities) -> connect - for (const connectedId of hasManyState.plannedConnections) { - if (hasManyState.createdEntities.has(connectedId)) continue - operations.push({ connect: { id: connectedId }, alias: connectedId }) - } - // Server items that aren't removed -> check for updates via entity snapshots if (targetType) { for (const itemId of hasManyState.serverIds) { @@ -723,7 +720,7 @@ export class MutationCollector implements MutationDataCollector { // Skip if store already has managed entities for this relation const existing = this.store.getHasMany(entityType, entityId, fieldName) - if (existing && (existing.createdEntities.size > 0 || existing.plannedConnections.size > 0)) { + if (existing && existing.plannedAdditions.size > 0) { return } diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 870324a7..a03de250 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -15,20 +15,31 @@ function setsEqual(a: Set, b: Set): boolean { */ export type HasManyRemovalType = 'disconnect' | 'delete' +/** + * Kind of a planned has-many addition: + * - 'created': a newly created entity (via add()) + * - 'connected': an existing persisted entity being connected (via connect()) + */ +export type HasManyAdditionKind = 'created' | 'connected' + /** * Has-many list state stored in SnapshotStore */ export interface StoredHasManyState { /** IDs of items from server */ serverIds: Set - /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedConnections) */ + /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedAdditions) */ orderedIds: string[] | null /** Planned removals (disconnect or delete) keyed by entity ID */ plannedRemovals: Map - /** Planned connections (IDs to add to the list) */ - plannedConnections: Set - /** Entity IDs created via add() - tracked for proper remove() semantics and mutation generation */ - createdEntities: Set + /** + * Planned additions (IDs to add to the list) keyed by entity ID, with the + * value distinguishing newly CREATED entities (add()) from existing PERSISTED + * entities being CONNECTED (connect()). The keys are exactly the connections; + * the keys whose value is 'created' are exactly the created entities, so the + * "created ⊆ connections" invariant is structural. + */ + plannedAdditions: Map version: number } @@ -188,8 +199,7 @@ export class RelationStore implements Rekeyable { serverIds: new Set(serverIds ?? []), orderedIds: null, plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), + plannedAdditions: new Map(), version: 0, }) } else if (serverIds !== undefined) { @@ -225,8 +235,7 @@ export class RelationStore implements Rekeyable { serverIds: new Set(serverIds), orderedIds: null, plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), + plannedAdditions: new Map(), version: 0, }) } else { @@ -250,27 +259,23 @@ export class RelationStore implements Rekeyable { serverIds: new Set(), orderedIds: null, plannedRemovals: new Map([[itemId, type]]), - plannedConnections: new Set(), - createdEntities: new Set(), + plannedAdditions: new Map(), version: 0, }) } else { const newPlannedRemovals = new Map(existing.plannedRemovals) newPlannedRemovals.set(itemId, type) - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.delete(itemId) + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.delete(itemId) let newOrderedIds = existing.orderedIds if (newOrderedIds !== null) { newOrderedIds = newOrderedIds.filter(id => id !== itemId) } - const newCreatedEntities = new Set(existing.createdEntities) - newCreatedEntities.delete(itemId) this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, plannedRemovals: newPlannedRemovals, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, + plannedAdditions: newPlannedAdditions, version: existing.version + 1, }) } @@ -287,13 +292,15 @@ export class RelationStore implements Rekeyable { serverIds: new Set(), orderedIds: null, plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set(), + plannedAdditions: new Map([[itemId, 'connected']]), version: 0, }) } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) + const newPlannedAdditions = new Map(existing.plannedAdditions) + // Do not downgrade an existing 'created' addition to 'connected'. + if (newPlannedAdditions.get(itemId) !== 'created') { + newPlannedAdditions.set(itemId, 'connected') + } const newPlannedRemovals = new Map(existing.plannedRemovals) newPlannedRemovals.delete(itemId) let newOrderedIds = existing.orderedIds @@ -303,7 +310,7 @@ export class RelationStore implements Rekeyable { this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, + plannedAdditions: newPlannedAdditions, plannedRemovals: newPlannedRemovals, version: existing.version + 1, }) @@ -320,8 +327,7 @@ export class RelationStore implements Rekeyable { serverIds: new Set(newServerIds), orderedIds: null, plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), + plannedAdditions: new Map(), version: (existing?.version ?? 0) + 1, }) } @@ -337,8 +343,7 @@ export class RelationStore implements Rekeyable { serverIds: existing.serverIds, orderedIds: null, plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), + plannedAdditions: new Map(), version: existing.version + 1, }) } @@ -355,22 +360,18 @@ export class RelationStore implements Rekeyable { serverIds: new Set(), orderedIds: [itemId], plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set([itemId]), + plannedAdditions: new Map([[itemId, 'created']]), version: 0, }) } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) - const newCreatedEntities = new Set(existing.createdEntities) - newCreatedEntities.add(itemId) + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.set(itemId, 'created') const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) const newOrderedIds = [...currentOrderedIds, itemId] this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, + plannedAdditions: newPlannedAdditions, version: existing.version + 1, }) } @@ -378,8 +379,8 @@ export class RelationStore implements Rekeyable { /** * Connects an existing (persisted) entity to a has-many relation. - * Unlike addToHasMany, does NOT add to createdEntities — used for - * materializing embedded connect references to existing entities. + * Unlike addToHasMany, records the addition as 'connected' (not 'created') — + * used for materializing embedded connect references to existing entities. */ connectExistingToHasMany(key: string, itemId: string): void { const existing = this.hasManyStates.get(key) @@ -389,19 +390,21 @@ export class RelationStore implements Rekeyable { serverIds: new Set(), orderedIds: [itemId], plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set(), + plannedAdditions: new Map([[itemId, 'connected']]), version: 0, }) } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) + const newPlannedAdditions = new Map(existing.plannedAdditions) + // Do not downgrade an existing 'created' addition to 'connected'. + if (newPlannedAdditions.get(itemId) !== 'created') { + newPlannedAdditions.set(itemId, 'connected') + } const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) const newOrderedIds = [...currentOrderedIds, itemId] this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, + plannedAdditions: newPlannedAdditions, version: existing.version + 1, }) } @@ -417,13 +420,11 @@ export class RelationStore implements Rekeyable { const existing = this.hasManyStates.get(key) if (!existing) return false - const isCreatedEntity = existing.createdEntities.has(itemId) + const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created' if (isCreatedEntity) { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.delete(itemId) - const newCreatedEntities = new Set(existing.createdEntities) - newCreatedEntities.delete(itemId) + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.delete(itemId) let newOrderedIds = existing.orderedIds if (newOrderedIds !== null) { newOrderedIds = newOrderedIds.filter(id => id !== itemId) @@ -432,14 +433,12 @@ export class RelationStore implements Rekeyable { const newState: StoredHasManyState = { ...existing, orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, + plannedAdditions: newPlannedAdditions, version: existing.version + 1, } if ( - newPlannedConnections.size === 0 && - newCreatedEntities.size === 0 && + newPlannedAdditions.size === 0 && existing.plannedRemovals.size === 0 && newOrderedIds !== null ) { @@ -466,8 +465,8 @@ export class RelationStore implements Rekeyable { * - has-one: currentId, when the relation is not disconnected/deleted * (a disconnected relation has a null currentId; a 'deleted' relation is * removing its target, so the target is not anchored by it). - * - has-many: effective members = (serverIds ∪ plannedConnections ∪ - * createdEntities) minus plannedRemovals. + * - has-many: effective members = (serverIds ∪ plannedAdditions.keys()) + * minus plannedRemovals. */ getLiveChildIds(keyPrefix: string): string[] { const ids = new Set() @@ -484,10 +483,7 @@ export class RelationStore implements Rekeyable { for (const id of state.serverIds) { if (!state.plannedRemovals.has(id)) ids.add(id) } - for (const id of state.plannedConnections) { - if (!state.plannedRemovals.has(id)) ids.add(id) - } - for (const id of state.createdEntities) { + for (const id of state.plannedAdditions.keys()) { if (!state.plannedRemovals.has(id)) ids.add(id) } } @@ -508,7 +504,7 @@ export class RelationStore implements Rekeyable { * Liveness matches {@link getLiveChildIds} EXACTLY so the two never disagree: * - has-one: currentId === childId, when the relation is not 'deleted' * (a disconnected relation has a null currentId and is skipped); - * - has-many: childId ∈ (serverIds ∪ plannedConnections ∪ createdEntities) + * - has-many: childId ∈ (serverIds ∪ plannedAdditions.keys()) * and childId ∉ plannedRemovals. * * Implemented as an on-demand scan of the live edges rather than an @@ -527,11 +523,7 @@ export class RelationStore implements Rekeyable { for (const [key, state] of this.hasManyStates) { if (state.plannedRemovals.has(childId)) continue - if ( - state.serverIds.has(childId) || - state.plannedConnections.has(childId) || - state.createdEntities.has(childId) - ) { + if (state.serverIds.has(childId) || state.plannedAdditions.has(childId)) { parents.add(parentKeyFromRelationKey(key)) } } @@ -618,7 +610,7 @@ export class RelationStore implements Rekeyable { for (const removedId of state.plannedRemovals.keys()) { newServerIds.delete(removedId) } - for (const connectedId of state.plannedConnections) { + for (const connectedId of state.plannedAdditions.keys()) { newServerIds.add(connectedId) } this.commitHasMany(key, Array.from(newServerIds)) @@ -668,7 +660,7 @@ export class RelationStore implements Rekeyable { if (!key.startsWith(keyPrefix)) continue const fieldName = key.slice(keyPrefix.length) - if (state.plannedRemovals.size > 0 || state.plannedConnections.size > 0) { + if (state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0) { dirtyRelations.push(fieldName) } } @@ -707,8 +699,7 @@ export class RelationStore implements Rekeyable { serverIds: new Set(state.serverIds), orderedIds: state.orderedIds ? [...state.orderedIds] : null, plannedRemovals: new Map(state.plannedRemovals), - plannedConnections: new Set(state.plannedConnections), - createdEntities: new Set(state.createdEntities), + plannedAdditions: new Map(state.plannedAdditions), version: state.version, }) } @@ -743,8 +734,7 @@ export class RelationStore implements Rekeyable { serverIds: new Set(state.serverIds), orderedIds: state.orderedIds ? [...state.orderedIds] : null, plannedRemovals: new Map(state.plannedRemovals), - plannedConnections: new Set(state.plannedConnections), - createdEntities: new Set(state.createdEntities), + plannedAdditions: new Map(state.plannedAdditions), version: state.version + 1, }) keys.push(key) @@ -771,7 +761,7 @@ export class RelationStore implements Rekeyable { } } - // Replace in has-many states: serverIds, orderedIds, plannedConnections, createdEntities, plannedRemovals + // Replace in has-many states: serverIds, orderedIds, plannedAdditions, plannedRemovals for (const [key, state] of this.hasManyStates) { let changed = false @@ -793,19 +783,12 @@ export class RelationStore implements Rekeyable { } } - let plannedConnections = state.plannedConnections - if (plannedConnections.has(oldId)) { - plannedConnections = new Set(plannedConnections) - plannedConnections.delete(oldId) - plannedConnections.add(newId) - changed = true - } - - let createdEntities = state.createdEntities - if (createdEntities.has(oldId)) { - createdEntities = new Set(createdEntities) - createdEntities.delete(oldId) - createdEntities.add(newId) + let plannedAdditions = state.plannedAdditions + const additionKind = plannedAdditions.get(oldId) + if (additionKind !== undefined) { + plannedAdditions = new Map(plannedAdditions) + plannedAdditions.delete(oldId) + plannedAdditions.set(newId, additionKind) changed = true } @@ -823,8 +806,7 @@ export class RelationStore implements Rekeyable { serverIds, orderedIds, plannedRemovals, - plannedConnections, - createdEntities, + plannedAdditions, version: state.version + 1, }) } @@ -893,7 +875,7 @@ function parentKeyFromRelationKey(relationKey: string): string { /** * Computes the default ordered IDs for a has-many relation. - * Order is: serverIds (minus removals) + plannedConnections + * Order is: serverIds (minus removals) + plannedAdditions */ export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { const result: string[] = [] @@ -904,7 +886,7 @@ export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { } } - for (const id of state.plannedConnections) { + for (const id of state.plannedAdditions.keys()) { if (!result.includes(id)) { result.push(id) } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 1353201a..5c157ddb 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -470,7 +470,9 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): Set | undefined { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasMany(key)?.plannedConnections + const state = this.relations.getHasMany(key) + if (!state) return undefined + return new Set(state.plannedAdditions.keys()) } commitHasMany( @@ -568,7 +570,7 @@ export class SnapshotStore implements SnapshotVersionBumper { ): boolean { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) const state = this.relations.getHasMany(key) - return state?.createdEntities.has(itemId) ?? false + return state?.plannedAdditions.get(itemId) === 'created' } getHasManyCreatedEntities( @@ -578,7 +580,13 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): Set | undefined { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasMany(key)?.createdEntities + const state = this.relations.getHasMany(key) + if (!state) return undefined + const created = new Set() + for (const [id, kind] of state.plannedAdditions) { + if (kind === 'created') created.add(id) + } + return created } // ==================== Persisting State (delegated to EntityMetaStore) ==================== diff --git a/tests/unit/core/actionDispatcher.test.ts b/tests/unit/core/actionDispatcher.test.ts index 30fc58e5..9bd9b0eb 100644 --- a/tests/unit/core/actionDispatcher.test.ts +++ b/tests/unit/core/actionDispatcher.test.ts @@ -237,7 +237,7 @@ describe('ActionDispatcher', () => { }) const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-1')).toBe(true) + expect(state?.plannedAdditions.has('t-1')).toBe(true) }) test('ADD_TO_LIST should create entity and add to has-many', () => { diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index 625f6461..36c4b50d 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -262,8 +262,8 @@ describe('HasMany with Alias Support', () => { const state1 = store.getHasMany('Article', 'a-1', 'tags', alias1) const state2 = store.getHasMany('Article', 'a-1', 'tags', alias2) - expect(state1?.plannedConnections.size).toBe(0) - expect(state2?.plannedConnections.size).toBe(1) + expect(state1?.plannedAdditions.size).toBe(0) + expect(state2?.plannedAdditions.size).toBe(1) }) }) @@ -309,7 +309,7 @@ describe('HasMany with Alias Support', () => { // Should be tracked under the alias const state = store.getHasMany('Article', 'a-1', 'tags', alias) - expect(state?.createdEntities.has(tempId)).toBe(true) + expect(state?.plannedAdditions.get(tempId) === 'created').toBe(true) }) test('should remove items from the correct alias', () => { diff --git a/tests/unit/handles/hasManyHandle.test.ts b/tests/unit/handles/hasManyHandle.test.ts index 565df1e8..2c18e927 100644 --- a/tests/unit/handles/hasManyHandle.test.ts +++ b/tests/unit/handles/hasManyHandle.test.ts @@ -359,8 +359,8 @@ describe('HasManyListHandle', () => { handle.remove(tempId) const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.createdEntities.has(tempId)).toBe(false) - expect(state?.plannedConnections.has(tempId)).toBe(false) + expect(state?.plannedAdditions.get(tempId) === 'created').toBe(false) + expect(state?.plannedAdditions.has(tempId)).toBe(false) }) test('should remove server entity — manyHasMany uses disconnect', () => { @@ -551,8 +551,8 @@ describe('HasManyListHandle', () => { handle.remove(tempId) const state = store.getHasMany('Article', 'a-1', 'comments') - expect(state?.createdEntities.has(tempId)).toBe(false) - expect(state?.plannedConnections.has(tempId)).toBe(false) + expect(state?.plannedAdditions.get(tempId) === 'created').toBe(false) + expect(state?.plannedAdditions.has(tempId)).toBe(false) // No planned removal — the add was cancelled, not a delete expect(state?.plannedRemovals.has(tempId)).toBe(false) }) @@ -592,8 +592,8 @@ describe('HasManyListHandle', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.has(tempId)).toBe(false) - expect(state?.plannedConnections.has(tempId)).toBe(false) - expect(state?.createdEntities.has(tempId)).toBe(false) + expect(state?.plannedAdditions.has(tempId)).toBe(false) + expect(state?.plannedAdditions.get(tempId) === 'created').toBe(false) expect(handle.items.length).toBe(0) }) @@ -612,8 +612,8 @@ describe('HasManyListHandle', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.has(tempId)).toBe(false) - expect(state?.plannedConnections.has(tempId)).toBe(false) - expect(state?.createdEntities.has(tempId)).toBe(false) + expect(state?.plannedAdditions.has(tempId)).toBe(false) + expect(state?.plannedAdditions.get(tempId) === 'created').toBe(false) expect(handle.items.length).toBe(0) }) }) @@ -812,7 +812,7 @@ describe('HasManyListHandle', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.size).toBe(0) - expect(state?.plannedConnections.size).toBe(0) + expect(state?.plannedAdditions.size).toBe(0) }) }) @@ -1016,7 +1016,7 @@ describe('HasManyListHandle', () => { handle.connect('t-1') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-1')).toBe(false) + expect(state?.plannedAdditions.has('t-1')).toBe(false) }) test('interceptor can cancel disconnect()', () => { diff --git a/tests/unit/persistence/rollback.test.ts b/tests/unit/persistence/rollback.test.ts index a5e3798b..9ecc6140 100644 --- a/tests/unit/persistence/rollback.test.ts +++ b/tests/unit/persistence/rollback.test.ts @@ -225,7 +225,7 @@ describe('BatchPersister rollback', () => { // Verify local changes const beforeHasMany = store.getHasMany('Article', 'a-1', 'tags') - expect(beforeHasMany?.plannedConnections.has('tag-3')).toBe(true) + expect(beforeHasMany?.plannedAdditions.has('tag-3')).toBe(true) expect(beforeHasMany?.plannedRemovals.has('tag-1')).toBe(true) // Persist with rollback @@ -239,7 +239,7 @@ describe('BatchPersister rollback', () => { // Has-many should be back to server state const afterHasMany = store.getHasMany('Article', 'a-1', 'tags') - expect(afterHasMany?.plannedConnections.size).toBe(0) + expect(afterHasMany?.plannedAdditions.size).toBe(0) expect(afterHasMany?.plannedRemovals.size).toBe(0) }) }) diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index a334f8df..283e9e90 100644 --- a/tests/unit/store/snapshotStore.test.ts +++ b/tests/unit/store/snapshotStore.test.ts @@ -353,7 +353,7 @@ describe('SnapshotStore', () => { expect(state.serverIds).toEqual(new Set(['t-1', 't-2'])) expect(state.plannedRemovals.size).toBe(0) - expect(state.plannedConnections.size).toBe(0) + expect(state.plannedAdditions.size).toBe(0) }) test('should update serverIds when called with new values', () => { @@ -412,18 +412,18 @@ describe('SnapshotStore', () => { store.planHasManyRemoval('Article', 'a-1', 'tags', 't-1', 'disconnect') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-1')).toBe(false) + expect(state?.plannedAdditions.has('t-1')).toBe(false) }) test('should clear createdEntities when planning removal', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags') store.addToHasMany('Article', 'a-1', 'tags', 't-new') - expect(store.getHasMany('Article', 'a-1', 'tags')?.createdEntities.has('t-new')).toBe(true) + expect(store.getHasMany('Article', 'a-1', 'tags')?.plannedAdditions.get('t-new') === 'created').toBe(true) store.planHasManyRemoval('Article', 'a-1', 'tags', 't-new', 'delete') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.createdEntities.has('t-new')).toBe(false) + expect(state?.plannedAdditions.get('t-new') === 'created').toBe(false) }) test('should remove item from orderedIds when planning removal', () => { @@ -445,7 +445,7 @@ describe('SnapshotStore', () => { store.planHasManyConnection('Article', 'a-1', 'tags', 't-new') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-new')).toBe(true) + expect(state?.plannedAdditions.has('t-new')).toBe(true) }) test('should cancel planned disconnect when planning connection', () => { @@ -464,7 +464,7 @@ describe('SnapshotStore', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.has('t-1')).toBe(false) - expect(state?.plannedConnections.has('t-1')).toBe(true) + expect(state?.plannedAdditions.has('t-1')).toBe(true) }) }) @@ -474,7 +474,7 @@ describe('SnapshotStore', () => { store.addToHasMany('Article', 'a-1', 'tags', 't-new') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-new')).toBe(true) + expect(state?.plannedAdditions.has('t-new')).toBe(true) }) test('should track item in createdEntities', () => { @@ -482,7 +482,7 @@ describe('SnapshotStore', () => { store.addToHasMany('Article', 'a-1', 'tags', 't-new') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.createdEntities.has('t-new')).toBe(true) + expect(state?.plannedAdditions.get('t-new') === 'created').toBe(true) }) test('should add to orderedIds', () => { @@ -501,8 +501,8 @@ describe('SnapshotStore', () => { store.removeFromHasMany('Article', 'a-1', 'tags', 't-new', 'disconnect') const state = store.getHasMany('Article', 'a-1', 'tags') - expect(state?.plannedConnections.has('t-new')).toBe(false) - expect(state?.createdEntities.has('t-new')).toBe(false) + expect(state?.plannedAdditions.has('t-new')).toBe(false) + expect(state?.plannedAdditions.get('t-new') === 'created').toBe(false) }) test('should plan disconnect for server entity', () => { @@ -528,8 +528,8 @@ describe('SnapshotStore', () => { const state = store.getHasMany('Article', 'a-1', 'tags') // Created entities should be cancelled, not planned for deletion - expect(state?.plannedConnections.has('t-new')).toBe(false) - expect(state?.createdEntities.has('t-new')).toBe(false) + expect(state?.plannedAdditions.has('t-new')).toBe(false) + expect(state?.plannedAdditions.get('t-new') === 'created').toBe(false) expect(state?.plannedRemovals.has('t-new')).toBe(false) }) }) @@ -570,7 +570,7 @@ describe('SnapshotStore', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.serverIds).toEqual(new Set(['t-1', 't-2'])) - expect(state?.plannedConnections.size).toBe(0) + expect(state?.plannedAdditions.size).toBe(0) }) }) @@ -583,7 +583,7 @@ describe('SnapshotStore', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.size).toBe(0) - expect(state?.plannedConnections.size).toBe(0) + expect(state?.plannedAdditions.size).toBe(0) expect(state?.orderedIds).toBeNull() }) }) From b667a187bcef364239f5423c2c822cf9ac5f778c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 22 Jun 2026 21:18:19 +0200 Subject: [PATCH 16/28] refactor(bindx): decompose RelationStore into composed HasOneStore + HasManyStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the ~900-line RelationStore into two focused collaborator classes behind an unchanged public API: - HasOneStore owns has-one relation state and its own mutation-version counter. - HasManyStore owns has-many list state, computeDefaultOrderedIds, and its own mutation-version counter. - relationKey.ts holds the shared parentKeyFromRelationKey helper. - RelationStore is now a thin facade composing both: it sums the two mutation versions, unions getLiveChildIds/getParentKeysForChild/getDirtyRelations, and fans rekey/replaceEntityId/removeOwnedRelations/commit/reset/clear out to both sub-stores. Public API and all import paths (StoredRelationState, StoredHasManyState, HasManyRemovalType, HasManyAdditionKind, computeDefaultOrderedIds re-exported from RelationStore.js) are preserved; consumers see no change. Behavior is identical — pure structural move. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/HasManyStore.ts | 609 ++++++++++++++++ packages/bindx/src/store/HasOneStore.ts | 289 ++++++++ packages/bindx/src/store/RelationStore.ts | 825 +++------------------- packages/bindx/src/store/relationKey.ts | 10 + 4 files changed, 990 insertions(+), 743 deletions(-) create mode 100644 packages/bindx/src/store/HasManyStore.ts create mode 100644 packages/bindx/src/store/HasOneStore.ts create mode 100644 packages/bindx/src/store/relationKey.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts new file mode 100644 index 00000000..d5570b7e --- /dev/null +++ b/packages/bindx/src/store/HasManyStore.ts @@ -0,0 +1,609 @@ +import { parentKeyFromRelationKey } from './relationKey.js' + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const item of a) { + if (!b.has(item)) return false + } + return true +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +/** + * Removal type for has-many items + */ +export type HasManyRemovalType = 'disconnect' | 'delete' + +/** + * Kind of a planned has-many addition: + * - 'created': a newly created entity (via add()) + * - 'connected': an existing persisted entity being connected (via connect()) + */ +export type HasManyAdditionKind = 'created' | 'connected' + +/** + * Has-many list state stored in SnapshotStore + */ +export interface StoredHasManyState { + /** IDs of items from server */ + serverIds: Set + /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedAdditions) */ + orderedIds: string[] | null + /** Planned removals (disconnect or delete) keyed by entity ID */ + plannedRemovals: Map + /** + * Planned additions (IDs to add to the list) keyed by entity ID, with the + * value distinguishing newly CREATED entities (add()) from existing PERSISTED + * entities being CONNECTED (connect()). The keys are exactly the connections; + * the keys whose value is 'created' are exactly the created entities, so the + * "created ⊆ connections" invariant is structural. + */ + plannedAdditions: Map + version: number +} + +/** + * Computes the default ordered IDs for a has-many relation. + * Order is: serverIds (minus removals) + plannedAdditions + */ +export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { + const result: string[] = [] + + for (const id of state.serverIds) { + if (!state.plannedRemovals.has(id)) { + result.push(id) + } + } + + for (const id of state.plannedAdditions.keys()) { + if (!result.includes(id)) { + result.push(id) + } + } + + return result +} + +/** + * Owns has-many list state ("parentType:parentId:fieldName" → {@link StoredHasManyState}). + * + * Has its own monotonic {@link mutationVersion} bumped on every actual write + * (funnelled through {@link writeHasMany} plus the delete/clear paths); the + * facade sums this with the has-one counter for {@link ReachabilityAnalyzer}. + */ +export class HasManyStore { + /** Has-many list states keyed by "parentType:parentId:fieldName" */ + private readonly hasManyStates = new Map() + + private mutationVersion = 0 + + getMutationVersion(): number { + return this.mutationVersion + } + + private writeHasMany(key: string, state: StoredHasManyState): void { + this.hasManyStates.set(key, state) + this.mutationVersion++ + } + + /** + * Gets or creates has-many list state. + */ + getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { + const existing = this.hasManyStates.get(key) + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(serverIds ?? []), + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map(), + version: 0, + }) + } else if (serverIds !== undefined) { + const newServerIds = new Set(serverIds) + if (!setsEqual(existing.serverIds, newServerIds)) { + this.writeHasMany(key, { + ...existing, + serverIds: newServerIds, + orderedIds: null, + version: existing.version + 1, + }) + } + } + + return this.hasManyStates.get(key)! + } + + /** + * Gets has-many list state. + */ + getHasMany(key: string): StoredHasManyState | undefined { + return this.hasManyStates.get(key) + } + + /** + * Sets server IDs for a has-many relation. + */ + setHasManyServerIds(key: string, serverIds: string[]): void { + const existing = this.hasManyStates.get(key) + + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(serverIds), + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map(), + version: 0, + }) + } else { + this.writeHasMany(key, { + ...existing, + serverIds: new Set(serverIds), + orderedIds: null, + version: existing.version + 1, + }) + } + } + + /** + * Plans a removal for a has-many item. + */ + planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { + const existing = this.hasManyStates.get(key) + + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(), + orderedIds: null, + plannedRemovals: new Map([[itemId, type]]), + plannedAdditions: new Map(), + version: 0, + }) + } else { + const newPlannedRemovals = new Map(existing.plannedRemovals) + newPlannedRemovals.set(itemId, type) + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.delete(itemId) + let newOrderedIds = existing.orderedIds + if (newOrderedIds !== null) { + newOrderedIds = newOrderedIds.filter(id => id !== itemId) + } + this.writeHasMany(key, { + ...existing, + orderedIds: newOrderedIds, + plannedRemovals: newPlannedRemovals, + plannedAdditions: newPlannedAdditions, + version: existing.version + 1, + }) + } + } + + /** + * Plans a connection for a has-many item. + */ + planHasManyConnection(key: string, itemId: string): void { + const existing = this.hasManyStates.get(key) + + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(), + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map([[itemId, 'connected']]), + version: 0, + }) + } else { + const newPlannedAdditions = new Map(existing.plannedAdditions) + // Do not downgrade an existing 'created' addition to 'connected'. + if (newPlannedAdditions.get(itemId) !== 'created') { + newPlannedAdditions.set(itemId, 'connected') + } + const newPlannedRemovals = new Map(existing.plannedRemovals) + newPlannedRemovals.delete(itemId) + let newOrderedIds = existing.orderedIds + if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { + newOrderedIds = [...newOrderedIds, itemId] + } + this.writeHasMany(key, { + ...existing, + orderedIds: newOrderedIds, + plannedAdditions: newPlannedAdditions, + plannedRemovals: newPlannedRemovals, + version: existing.version + 1, + }) + } + } + + /** + * Commits has-many state after successful persist. + */ + commitHasMany(key: string, newServerIds: string[]): void { + const existing = this.hasManyStates.get(key) + + this.writeHasMany(key, { + serverIds: new Set(newServerIds), + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map(), + version: (existing?.version ?? 0) + 1, + }) + } + + /** + * Resets has-many state to server state (clears planned operations). + */ + resetHasMany(key: string): void { + const existing = this.hasManyStates.get(key) + if (!existing) return + + this.writeHasMany(key, { + serverIds: existing.serverIds, + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map(), + version: existing.version + 1, + }) + } + + /** + * Adds a newly created entity to a has-many relation. + * Used by HasManyListHandle.add() for inline entity creation. + */ + addToHasMany(key: string, itemId: string): void { + const existing = this.hasManyStates.get(key) + + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(), + orderedIds: [itemId], + plannedRemovals: new Map(), + plannedAdditions: new Map([[itemId, 'created']]), + version: 0, + }) + } else { + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.set(itemId, 'created') + const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) + const newOrderedIds = [...currentOrderedIds, itemId] + this.writeHasMany(key, { + ...existing, + orderedIds: newOrderedIds, + plannedAdditions: newPlannedAdditions, + version: existing.version + 1, + }) + } + } + + /** + * Connects an existing (persisted) entity to a has-many relation. + * Unlike addToHasMany, records the addition as 'connected' (not 'created') — + * used for materializing embedded connect references to existing entities. + */ + connectExistingToHasMany(key: string, itemId: string): void { + const existing = this.hasManyStates.get(key) + + if (!existing) { + this.writeHasMany(key, { + serverIds: new Set(), + orderedIds: [itemId], + plannedRemovals: new Map(), + plannedAdditions: new Map([[itemId, 'connected']]), + version: 0, + }) + } else { + const newPlannedAdditions = new Map(existing.plannedAdditions) + // Do not downgrade an existing 'created' addition to 'connected'. + if (newPlannedAdditions.get(itemId) !== 'created') { + newPlannedAdditions.set(itemId, 'connected') + } + const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) + const newOrderedIds = [...currentOrderedIds, itemId] + this.writeHasMany(key, { + ...existing, + orderedIds: newOrderedIds, + plannedAdditions: newPlannedAdditions, + version: existing.version + 1, + }) + } + } + + /** + * Removes an entity from a has-many relation. + * For newly created entities (via add()), cancels the connection. + * For existing server entities, plans the specified removal type. + * Returns true if the state changed (caller should notify), false if it was a no-op. + */ + removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean { + const existing = this.hasManyStates.get(key) + if (!existing) return false + + const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created' + + if (isCreatedEntity) { + const newPlannedAdditions = new Map(existing.plannedAdditions) + newPlannedAdditions.delete(itemId) + let newOrderedIds = existing.orderedIds + if (newOrderedIds !== null) { + newOrderedIds = newOrderedIds.filter(id => id !== itemId) + } + + const newState: StoredHasManyState = { + ...existing, + orderedIds: newOrderedIds, + plannedAdditions: newPlannedAdditions, + version: existing.version + 1, + } + + if ( + newPlannedAdditions.size === 0 && + existing.plannedRemovals.size === 0 && + newOrderedIds !== null + ) { + const defaultOrder = computeDefaultOrderedIds(newState) + if (arraysEqual(newOrderedIds, defaultOrder)) { + newState.orderedIds = null + } + } + + this.writeHasMany(key, newState) + return true + } else { + this.planHasManyRemoval(key, itemId, removalType) + return true + } + } + + /** + * Moves an item within a has-many relation from one index to another. + */ + moveInHasMany(key: string, fromIndex: number, toIndex: number): void { + const existing = this.hasManyStates.get(key) + if (!existing) return + + const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) + + if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return + if (toIndex < 0 || toIndex >= currentOrderedIds.length) return + if (fromIndex === toIndex) return + + const newOrderedIds = [...currentOrderedIds] + const movedItem = newOrderedIds.splice(fromIndex, 1)[0] + if (movedItem === undefined) return + newOrderedIds.splice(toIndex, 0, movedItem) + + this.writeHasMany(key, { + ...existing, + orderedIds: newOrderedIds, + version: existing.version + 1, + }) + } + + /** + * Gets the ordered list of item IDs for a has-many relation. + */ + getHasManyOrderedIds(key: string): string[] { + const existing = this.hasManyStates.get(key) + if (!existing) return [] + + if (existing.orderedIds !== null) { + return existing.orderedIds + } + + return computeDefaultOrderedIds(existing) + } + + /** + * Collects the ids of child entities reachable through LIVE has-many edges + * (key prefix "parentType:parentId:"): + * effective members = (serverIds ∪ plannedAdditions.keys()) minus plannedRemovals. + */ + collectLiveChildIds(keyPrefix: string, ids: Set): void { + for (const [key, state] of this.hasManyStates) { + if (!key.startsWith(keyPrefix)) continue + for (const id of state.serverIds) { + if (!state.plannedRemovals.has(id)) ids.add(id) + } + for (const id of state.plannedAdditions.keys()) { + if (!state.plannedRemovals.has(id)) ids.add(id) + } + } + } + + /** + * Adds the composite parent keys of every LIVE has-many edge containing + * {@link childId}: childId ∈ (serverIds ∪ plannedAdditions.keys()) and + * childId ∉ plannedRemovals. + */ + collectParentKeysForChild(childId: string, parents: Set): void { + for (const [key, state] of this.hasManyStates) { + if (state.plannedRemovals.has(childId)) continue + if (state.serverIds.has(childId) || state.plannedAdditions.has(childId)) { + parents.add(parentKeyFromRelationKey(key)) + } + } + } + + /** + * Removes all has-many state owned by an entity (keys under the given owner + * prefix). Bumps the mutation version once if anything was removed. + */ + removeOwnedRelations(keyPrefix: string): void { + let changed = false + for (const key of [...this.hasManyStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.hasManyStates.delete(key) + changed = true + } + } + if (changed) this.mutationVersion++ + } + + /** + * Commits all has-many relations for an entity. + */ + commitAllRelations(keyPrefix: string): void { + for (const [key, state] of this.hasManyStates) { + if (key.startsWith(keyPrefix)) { + const newServerIds = new Set(state.serverIds) + for (const removedId of state.plannedRemovals.keys()) { + newServerIds.delete(removedId) + } + for (const connectedId of state.plannedAdditions.keys()) { + newServerIds.add(connectedId) + } + this.commitHasMany(key, Array.from(newServerIds)) + } + } + } + + /** + * Resets all has-many relations for an entity to server state. + */ + resetAllRelations(keyPrefix: string): void { + for (const key of this.hasManyStates.keys()) { + if (key.startsWith(keyPrefix)) { + this.resetHasMany(key) + } + } + } + + /** + * Collects the field names of dirty has-many relations for an entity. + */ + collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void { + for (const [key, state] of this.hasManyStates) { + if (!key.startsWith(keyPrefix)) continue + const fieldName = key.slice(keyPrefix.length) + + if (state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0) { + dirtyRelations.push(fieldName) + } + } + } + + /** + * Exports has-many states for given keys. + */ + exportHasManyStates(keys: string[]): Map { + const result = new Map() + for (const key of keys) { + const state = this.hasManyStates.get(key) + if (state) { + result.set(key, { + serverIds: new Set(state.serverIds), + orderedIds: state.orderedIds ? [...state.orderedIds] : null, + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions: new Map(state.plannedAdditions), + version: state.version, + }) + } + } + return result + } + + /** + * Imports has-many states from a snapshot. + * Returns the keys that were imported for notification. + */ + importHasManyStates(states: Map): string[] { + const keys: string[] = [] + for (const [key, state] of states) { + this.writeHasMany(key, { + serverIds: new Set(state.serverIds), + orderedIds: state.orderedIds ? [...state.orderedIds] : null, + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions: new Map(state.plannedAdditions), + version: state.version + 1, + }) + keys.push(key) + } + return keys + } + + /** + * Replaces all occurrences of oldId with newId across has-many states + * (serverIds, orderedIds, plannedAdditions, plannedRemovals). + */ + replaceEntityId(oldId: string, newId: string): void { + for (const [key, state] of this.hasManyStates) { + let changed = false + + let serverIds = state.serverIds + if (serverIds.has(oldId)) { + serverIds = new Set(serverIds) + serverIds.delete(oldId) + serverIds.add(newId) + changed = true + } + + let orderedIds = state.orderedIds + if (orderedIds) { + const idx = orderedIds.indexOf(oldId) + if (idx !== -1) { + orderedIds = [...orderedIds] + orderedIds[idx] = newId + changed = true + } + } + + let plannedAdditions = state.plannedAdditions + const additionKind = plannedAdditions.get(oldId) + if (additionKind !== undefined) { + plannedAdditions = new Map(plannedAdditions) + plannedAdditions.delete(oldId) + plannedAdditions.set(newId, additionKind) + changed = true + } + + let plannedRemovals = state.plannedRemovals + if (plannedRemovals.has(oldId)) { + const removalType = plannedRemovals.get(oldId)! + plannedRemovals = new Map(plannedRemovals) + plannedRemovals.delete(oldId) + plannedRemovals.set(newId, removalType) + changed = true + } + + if (changed) { + this.writeHasMany(key, { + serverIds, + orderedIds, + plannedRemovals, + plannedAdditions, + version: state.version + 1, + }) + } + } + } + + /** + * Rekeys has-many entries owned by an entity (changes the parent ID in the key). + */ + rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void { + const toMove: [string, StoredHasManyState][] = [] + for (const [key, value] of this.hasManyStates) { + if (key.startsWith(oldKeyPrefix)) { + toMove.push([key, value]) + } + } + for (const [oldKey, value] of toMove) { + this.hasManyStates.delete(oldKey) + this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + } + } + + /** + * Clears all has-many relation data. + */ + clear(): void { + this.hasManyStates.clear() + this.mutationVersion++ + } +} diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts new file mode 100644 index 00000000..97d5649d --- /dev/null +++ b/packages/bindx/src/store/HasOneStore.ts @@ -0,0 +1,289 @@ +import type { HasOneRelationState } from '../handles/types.js' +import type { EntitySnapshot } from './snapshots.js' +import { parentKeyFromRelationKey } from './relationKey.js' + +/** + * Relation state stored in SnapshotStore + */ +export interface StoredRelationState { + currentId: string | null + serverId: string | null + state: HasOneRelationState + serverState: HasOneRelationState + placeholderData: Record + version: number +} + +/** + * Owns has-one relation state ("parentType:parentId:fieldName" → {@link StoredRelationState}). + * + * Has its own monotonic {@link mutationVersion} bumped on every actual write + * (funnelled through {@link writeRelation} plus the delete/clear paths); the + * facade sums this with the has-many counter for {@link ReachabilityAnalyzer}. + */ +export class HasOneStore { + /** Relation states keyed by "parentType:parentId:fieldName" */ + private readonly relationStates = new Map() + + private mutationVersion = 0 + + getMutationVersion(): number { + return this.mutationVersion + } + + private writeRelation(key: string, state: StoredRelationState): void { + this.relationStates.set(key, state) + this.mutationVersion++ + } + + /** + * Gets or creates relation state. + */ + getOrCreateRelation( + key: string, + initial: Omit, + ): StoredRelationState { + if (!this.relationStates.has(key)) { + this.writeRelation(key, { ...initial, version: 0 }) + } + + return this.relationStates.get(key)! + } + + /** + * Gets relation state. + */ + getRelation(key: string): StoredRelationState | undefined { + return this.relationStates.get(key) + } + + /** + * Updates relation state. + * If the relation state doesn't exist, creates it using entity snapshot for server data. + */ + setRelation( + key: string, + updates: Partial>, + entitySnapshot: EntitySnapshot | undefined, + fieldName: string, + ): void { + const existing = this.relationStates.get(key) + + if (!existing) { + let serverId: string | null = null + let serverState: HasOneRelationState = 'disconnected' + + if (entitySnapshot?.serverData) { + const relatedData = (entitySnapshot.serverData as Record)[fieldName] + if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) { + serverId = (relatedData as { id: string }).id + serverState = 'connected' + } + } + + this.writeRelation(key, { + currentId: 'currentId' in updates ? updates.currentId! : serverId, + serverId, + state: 'state' in updates ? updates.state! : serverState, + serverState, + placeholderData: updates.placeholderData ?? {}, + version: 0, + }) + } else { + this.writeRelation(key, { + ...existing, + ...updates, + version: existing.version + 1, + }) + } + } + + /** + * Commits relation state (server = current). + */ + commitRelation(key: string): void { + const existing = this.relationStates.get(key) + if (!existing) return + + this.writeRelation(key, { + ...existing, + serverId: existing.currentId, + serverState: existing.state === 'creating' ? 'connected' : existing.state, + placeholderData: {}, + version: existing.version + 1, + }) + } + + /** + * Resets relation to server state. + */ + resetRelation(key: string): void { + const existing = this.relationStates.get(key) + if (!existing) return + + this.writeRelation(key, { + ...existing, + currentId: existing.serverId, + state: existing.serverState, + placeholderData: {}, + version: existing.version + 1, + }) + } + + /** + * Collects the ids of child entities reachable through LIVE has-one edges + * (key prefix "parentType:parentId:"): currentId, when the relation is not + * disconnected/deleted. + */ + collectLiveChildIds(keyPrefix: string, ids: Set): void { + for (const [key, state] of this.relationStates) { + if (!key.startsWith(keyPrefix)) continue + if (state.currentId !== null && state.state !== 'deleted') { + ids.add(state.currentId) + } + } + } + + /** + * Adds the composite parent keys of every LIVE has-one edge pointing at + * {@link childId} (currentId === childId, when the relation is not 'deleted'). + */ + collectParentKeysForChild(childId: string, parents: Set): void { + for (const [key, state] of this.relationStates) { + if (state.currentId === childId && state.state !== 'deleted') { + parents.add(parentKeyFromRelationKey(key)) + } + } + } + + /** + * Removes all has-one state owned by an entity (keys under the given owner + * prefix). Bumps the mutation version once if anything was removed. + */ + removeOwnedRelations(keyPrefix: string): void { + let changed = false + for (const key of [...this.relationStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.relationStates.delete(key) + changed = true + } + } + if (changed) this.mutationVersion++ + } + + /** + * Commits all has-one relations for an entity. + */ + commitAllRelations(keyPrefix: string): void { + for (const key of this.relationStates.keys()) { + if (key.startsWith(keyPrefix)) { + this.commitRelation(key) + } + } + } + + /** + * Resets all has-one relations for an entity to server state. + */ + resetAllRelations(keyPrefix: string): void { + for (const key of this.relationStates.keys()) { + if (key.startsWith(keyPrefix)) { + this.resetRelation(key) + } + } + } + + /** + * Collects the field names of dirty has-one relations for an entity. + */ + collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void { + for (const [key, state] of this.relationStates) { + if (!key.startsWith(keyPrefix)) continue + const fieldName = key.slice(keyPrefix.length) + + if ( + state.currentId !== state.serverId || + state.state !== state.serverState || + Object.keys(state.placeholderData).length > 0 + ) { + dirtyRelations.push(fieldName) + } + } + } + + /** + * Exports relation states for given keys. + */ + exportRelationStates(keys: string[]): Map { + const result = new Map() + for (const key of keys) { + const state = this.relationStates.get(key) + if (state) { + result.set(key, { + ...state, + placeholderData: { ...state.placeholderData }, + }) + } + } + return result + } + + /** + * Imports relation states from a snapshot. + * Returns the keys that were imported for notification. + */ + importRelationStates(states: Map): string[] { + const keys: string[] = [] + for (const [key, state] of states) { + this.writeRelation(key, { + ...state, + placeholderData: { ...state.placeholderData }, + }) + keys.push(key) + } + return keys + } + + /** + * Replaces all occurrences of oldId with newId across has-one relation states + * (currentId, serverId). + */ + replaceEntityId(oldId: string, newId: string): void { + for (const [key, state] of this.relationStates) { + let changed = false + let currentId = state.currentId + let serverId = state.serverId + + if (currentId === oldId) { currentId = newId; changed = true } + if (serverId === oldId) { serverId = newId; changed = true } + + if (changed) { + this.writeRelation(key, { ...state, currentId, serverId, version: state.version + 1 }) + } + } + } + + /** + * Rekeys has-one entries owned by an entity (changes the parent ID in the key). + */ + rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void { + const toMove: [string, StoredRelationState][] = [] + for (const [key, value] of this.relationStates) { + if (key.startsWith(oldKeyPrefix)) { + toMove.push([key, value]) + } + } + for (const [oldKey, value] of toMove) { + this.relationStates.delete(oldKey) + this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + } + } + + /** + * Clears all has-one relation data. + */ + clear(): void { + this.relationStates.clear() + this.mutationVersion++ + } +} diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index a03de250..43e10b0a 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -1,816 +1,218 @@ -import type { HasOneRelationState } from '../handles/types.js' import type { EntitySnapshot } from './snapshots.js' import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' - -function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false - for (const item of a) { - if (!b.has(item)) return false - } - return true -} - -/** - * Removal type for has-many items - */ -export type HasManyRemovalType = 'disconnect' | 'delete' - -/** - * Kind of a planned has-many addition: - * - 'created': a newly created entity (via add()) - * - 'connected': an existing persisted entity being connected (via connect()) - */ -export type HasManyAdditionKind = 'created' | 'connected' - -/** - * Has-many list state stored in SnapshotStore - */ -export interface StoredHasManyState { - /** IDs of items from server */ - serverIds: Set - /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedAdditions) */ - orderedIds: string[] | null - /** Planned removals (disconnect or delete) keyed by entity ID */ - plannedRemovals: Map - /** - * Planned additions (IDs to add to the list) keyed by entity ID, with the - * value distinguishing newly CREATED entities (add()) from existing PERSISTED - * entities being CONNECTED (connect()). The keys are exactly the connections; - * the keys whose value is 'created' are exactly the created entities, so the - * "created ⊆ connections" invariant is structural. - */ - plannedAdditions: Map - version: number -} - -/** - * Relation state stored in SnapshotStore - */ -export interface StoredRelationState { - currentId: string | null - serverId: string | null - state: HasOneRelationState - serverState: HasOneRelationState - placeholderData: Record - version: number -} +import { HasOneStore, type StoredRelationState } from './HasOneStore.js' +import { + HasManyStore, + computeDefaultOrderedIds, + type HasManyAdditionKind, + type HasManyRemovalType, + type StoredHasManyState, +} from './HasManyStore.js' + +// Re-exported so existing imports from './RelationStore.js' keep resolving. +export type { StoredRelationState } from './HasOneStore.js' +export type { HasManyAdditionKind, HasManyRemovalType, StoredHasManyState } from './HasManyStore.js' +export { computeDefaultOrderedIds } from './HasManyStore.js' /** * Manages has-one and has-many relation state. * * Relation keys use the format "parentType:parentId:fieldName". * Notification is handled by callers via callback returns. + * + * Thin facade composing a {@link HasOneStore} and a {@link HasManyStore}: each + * sub-store owns its own state map and write helper, and the facade merges the + * cross-cutting queries (mutation version sum; live-child/parent-key unions; + * dirty union) and fans out the cross-cutting mutations (rekey, removeOwned, + * clear) to both. */ export class RelationStore implements Rekeyable { - /** Relation states keyed by "parentType:parentId:fieldName" */ - private readonly relationStates = new Map() - - /** Has-many list states keyed by "parentType:parentId:fieldName" */ - private readonly hasManyStates = new Map() + private readonly hasOne = new HasOneStore() + private readonly hasMany = new HasManyStore() /** - * Monotonic counter bumped on every actual write to relation/has-many state. - * Used by {@link ReachabilityAnalyzer} to memoize its walk. All writes funnel - * through {@link writeRelation}/{@link writeHasMany} (plus the delete/clear - * paths), so a read-only `getOrCreate*` call on the per-render materialize - * path — which does not write when the entry already matches — never bumps it. + * Increases whenever EITHER sub-store mutates. Used by {@link ReachabilityAnalyzer} + * to memoize its walk; only monotonic-increase-on-mutation matters, so the sum + * of the two per-store counters is sufficient. */ - private mutationVersion = 0 - getMutationVersion(): number { - return this.mutationVersion - } - - private writeRelation(key: string, state: StoredRelationState): void { - this.relationStates.set(key, state) - this.mutationVersion++ - } - - private writeHasMany(key: string, state: StoredHasManyState): void { - this.hasManyStates.set(key, state) - this.mutationVersion++ + return this.hasOne.getMutationVersion() + this.hasMany.getMutationVersion() } // ==================== Has-One Relations ==================== - /** - * Gets or creates relation state. - */ getOrCreateRelation( key: string, initial: Omit, ): StoredRelationState { - if (!this.relationStates.has(key)) { - this.writeRelation(key, { ...initial, version: 0 }) - } - - return this.relationStates.get(key)! + return this.hasOne.getOrCreateRelation(key, initial) } - /** - * Gets relation state. - */ getRelation(key: string): StoredRelationState | undefined { - return this.relationStates.get(key) + return this.hasOne.getRelation(key) } - /** - * Updates relation state. - * If the relation state doesn't exist, creates it using entity snapshot for server data. - */ setRelation( key: string, updates: Partial>, entitySnapshot: EntitySnapshot | undefined, fieldName: string, ): void { - const existing = this.relationStates.get(key) - - if (!existing) { - let serverId: string | null = null - let serverState: HasOneRelationState = 'disconnected' - - if (entitySnapshot?.serverData) { - const relatedData = (entitySnapshot.serverData as Record)[fieldName] - if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) { - serverId = (relatedData as { id: string }).id - serverState = 'connected' - } - } - - this.writeRelation(key, { - currentId: 'currentId' in updates ? updates.currentId! : serverId, - serverId, - state: 'state' in updates ? updates.state! : serverState, - serverState, - placeholderData: updates.placeholderData ?? {}, - version: 0, - }) - } else { - this.writeRelation(key, { - ...existing, - ...updates, - version: existing.version + 1, - }) - } + this.hasOne.setRelation(key, updates, entitySnapshot, fieldName) } - /** - * Commits relation state (server = current). - */ commitRelation(key: string): void { - const existing = this.relationStates.get(key) - if (!existing) return - - this.writeRelation(key, { - ...existing, - serverId: existing.currentId, - serverState: existing.state === 'creating' ? 'connected' : existing.state, - placeholderData: {}, - version: existing.version + 1, - }) + this.hasOne.commitRelation(key) } - /** - * Resets relation to server state. - */ resetRelation(key: string): void { - const existing = this.relationStates.get(key) - if (!existing) return - - this.writeRelation(key, { - ...existing, - currentId: existing.serverId, - state: existing.serverState, - placeholderData: {}, - version: existing.version + 1, - }) + this.hasOne.resetRelation(key) } // ==================== Has-Many Relations ==================== - /** - * Gets or creates has-many list state. - */ getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { - const existing = this.hasManyStates.get(key) - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(serverIds ?? []), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: 0, - }) - } else if (serverIds !== undefined) { - const newServerIds = new Set(serverIds) - if (!setsEqual(existing.serverIds, newServerIds)) { - this.writeHasMany(key, { - ...existing, - serverIds: newServerIds, - orderedIds: null, - version: existing.version + 1, - }) - } - } - - return this.hasManyStates.get(key)! + return this.hasMany.getOrCreateHasMany(key, serverIds) } - /** - * Gets has-many list state. - */ getHasMany(key: string): StoredHasManyState | undefined { - return this.hasManyStates.get(key) + return this.hasMany.getHasMany(key) } - /** - * Sets server IDs for a has-many relation. - */ setHasManyServerIds(key: string, serverIds: string[]): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(serverIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: 0, - }) - } else { - this.writeHasMany(key, { - ...existing, - serverIds: new Set(serverIds), - orderedIds: null, - version: existing.version + 1, - }) - } + this.hasMany.setHasManyServerIds(key, serverIds) } - /** - * Plans a removal for a has-many item. - */ planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map([[itemId, type]]), - plannedAdditions: new Map(), - version: 0, - }) - } else { - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.set(itemId, type) - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedRemovals: newPlannedRemovals, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - }) - } + this.hasMany.planHasManyRemoval(key, itemId, type) } - /** - * Plans a connection for a has-many item. - */ planHasManyConnection(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map([[itemId, 'connected']]), - version: 0, - }) - } else { - const newPlannedAdditions = new Map(existing.plannedAdditions) - // Do not downgrade an existing 'created' addition to 'connected'. - if (newPlannedAdditions.get(itemId) !== 'created') { - newPlannedAdditions.set(itemId, 'connected') - } - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { - newOrderedIds = [...newOrderedIds, itemId] - } - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) - } + this.hasMany.planHasManyConnection(key, itemId) } - /** - * Commits has-many state after successful persist. - */ commitHasMany(key: string, newServerIds: string[]): void { - const existing = this.hasManyStates.get(key) - - this.writeHasMany(key, { - serverIds: new Set(newServerIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: (existing?.version ?? 0) + 1, - }) + this.hasMany.commitHasMany(key, newServerIds) } - /** - * Resets has-many state to server state (clears planned operations). - */ resetHasMany(key: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return - - this.writeHasMany(key, { - serverIds: existing.serverIds, - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: existing.version + 1, - }) + this.hasMany.resetHasMany(key) } - /** - * Adds a newly created entity to a has-many relation. - * Used by HasManyListHandle.add() for inline entity creation. - */ addToHasMany(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: [itemId], - plannedRemovals: new Map(), - plannedAdditions: new Map([[itemId, 'created']]), - version: 0, - }) - } else { - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.set(itemId, 'created') - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - const newOrderedIds = [...currentOrderedIds, itemId] - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - }) - } + this.hasMany.addToHasMany(key, itemId) } - /** - * Connects an existing (persisted) entity to a has-many relation. - * Unlike addToHasMany, records the addition as 'connected' (not 'created') — - * used for materializing embedded connect references to existing entities. - */ connectExistingToHasMany(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: [itemId], - plannedRemovals: new Map(), - plannedAdditions: new Map([[itemId, 'connected']]), - version: 0, - }) - } else { - const newPlannedAdditions = new Map(existing.plannedAdditions) - // Do not downgrade an existing 'created' addition to 'connected'. - if (newPlannedAdditions.get(itemId) !== 'created') { - newPlannedAdditions.set(itemId, 'connected') - } - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - const newOrderedIds = [...currentOrderedIds, itemId] - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - }) - } + this.hasMany.connectExistingToHasMany(key, itemId) } - /** - * Removes an entity from a has-many relation. - * For newly created entities (via add()), cancels the connection. - * For existing server entities, plans the specified removal type. - * Returns true if the state changed (caller should notify), false if it was a no-op. - */ removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean { - const existing = this.hasManyStates.get(key) - if (!existing) return false - - const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created' - - if (isCreatedEntity) { - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } - - const newState: StoredHasManyState = { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - } - - if ( - newPlannedAdditions.size === 0 && - existing.plannedRemovals.size === 0 && - newOrderedIds !== null - ) { - const defaultOrder = computeDefaultOrderedIds(newState) - if (arraysEqual(newOrderedIds, defaultOrder)) { - newState.orderedIds = null - } - } - - this.writeHasMany(key, newState) - return true - } else { - this.planHasManyRemoval(key, itemId, removalType) - return true - } + return this.hasMany.removeFromHasMany(key, itemId, removalType) } + moveInHasMany(key: string, fromIndex: number, toIndex: number): void { + this.hasMany.moveInHasMany(key, fromIndex, toIndex) + } + + getHasManyOrderedIds(key: string): string[] { + return this.hasMany.getHasManyOrderedIds(key) + } + + // ==================== Reachability / Reverse Lookup ==================== + /** * Collects the ids of child entities currently reachable through an entity's - * LIVE relations (key prefix "parentType:parentId:"). Used by reachability-based - * create detection to walk the relation graph from roots. - * - * Live edges are: - * - has-one: currentId, when the relation is not disconnected/deleted - * (a disconnected relation has a null currentId; a 'deleted' relation is - * removing its target, so the target is not anchored by it). - * - has-many: effective members = (serverIds ∪ plannedAdditions.keys()) - * minus plannedRemovals. + * LIVE relations (key prefix "parentType:parentId:"). Unions the live has-one + * edges with the live has-many members. */ getLiveChildIds(keyPrefix: string): string[] { const ids = new Set() - - for (const [key, state] of this.relationStates) { - if (!key.startsWith(keyPrefix)) continue - if (state.currentId !== null && state.state !== 'deleted') { - ids.add(state.currentId) - } - } - - for (const [key, state] of this.hasManyStates) { - if (!key.startsWith(keyPrefix)) continue - for (const id of state.serverIds) { - if (!state.plannedRemovals.has(id)) ids.add(id) - } - for (const id of state.plannedAdditions.keys()) { - if (!state.plannedRemovals.has(id)) ids.add(id) - } - } - + this.hasOne.collectLiveChildIds(keyPrefix, ids) + this.hasMany.collectLiveChildIds(keyPrefix, ids) return Array.from(ids) } /** * Collects the composite keys ("parentType:parentId") of every entity that - * currently has a LIVE relation edge pointing at {@link childId}. This is the - * reverse of {@link getLiveChildIds}: instead of "which children does this - * parent reach", it answers "which parents reference this child". - * - * It is the single source of truth for parent re-render notification — when a - * child entity's own field changes, every parent returned here is notified so - * its rendered view of the child updates. - * - * Liveness matches {@link getLiveChildIds} EXACTLY so the two never disagree: - * - has-one: currentId === childId, when the relation is not 'deleted' - * (a disconnected relation has a null currentId and is skipped); - * - has-many: childId ∈ (serverIds ∪ plannedAdditions.keys()) - * and childId ∉ plannedRemovals. - * - * Implemented as an on-demand scan of the live edges rather than an - * incrementally-maintained reverse index: it is correct by construction (it - * reads the same maps the forward query reads), so there is no second - * structure that could drift out of sync on disconnect/remove/rekey/clear. + * currently has a LIVE relation edge pointing at {@link childId} — the reverse + * of {@link getLiveChildIds}. Unions both sub-stores' results, scanning the + * same live edges the forward query reads so the two never disagree. */ getParentKeysForChild(childId: string): Set { const parents = new Set() - - for (const [key, state] of this.relationStates) { - if (state.currentId === childId && state.state !== 'deleted') { - parents.add(parentKeyFromRelationKey(key)) - } - } - - for (const [key, state] of this.hasManyStates) { - if (state.plannedRemovals.has(childId)) continue - if (state.serverIds.has(childId) || state.plannedAdditions.has(childId)) { - parents.add(parentKeyFromRelationKey(key)) - } - } - + this.hasOne.collectParentKeysForChild(childId, parents) + this.hasMany.collectParentKeysForChild(childId, parents) return parents } + // ==================== Bulk Operations ==================== + /** * Removes all relation and has-many state owned by an entity (keys under the * given owner prefix). Called by removeEntity so a removed entity leaves no * stale relation state behind. */ removeOwnedRelations(keyPrefix: string): void { - let changed = false - for (const key of [...this.relationStates.keys()]) { - if (key.startsWith(keyPrefix)) { - this.relationStates.delete(key) - changed = true - } - } - for (const key of [...this.hasManyStates.keys()]) { - if (key.startsWith(keyPrefix)) { - this.hasManyStates.delete(key) - changed = true - } - } - if (changed) this.mutationVersion++ + this.hasOne.removeOwnedRelations(keyPrefix) + this.hasMany.removeOwnedRelations(keyPrefix) } - /** - * Moves an item within a has-many relation from one index to another. - */ - moveInHasMany(key: string, fromIndex: number, toIndex: number): void { - const existing = this.hasManyStates.get(key) - if (!existing) return - - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - - if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return - if (toIndex < 0 || toIndex >= currentOrderedIds.length) return - if (fromIndex === toIndex) return - - const newOrderedIds = [...currentOrderedIds] - const movedItem = newOrderedIds.splice(fromIndex, 1)[0] - if (movedItem === undefined) return - newOrderedIds.splice(toIndex, 0, movedItem) - - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - version: existing.version + 1, - }) - } - - /** - * Gets the ordered list of item IDs for a has-many relation. - */ - getHasManyOrderedIds(key: string): string[] { - const existing = this.hasManyStates.get(key) - if (!existing) return [] - - if (existing.orderedIds !== null) { - return existing.orderedIds - } - - return computeDefaultOrderedIds(existing) - } - - // ==================== Bulk Operations ==================== - /** * Commits all relations (hasOne and hasMany) for an entity. */ commitAllRelations(keyPrefix: string): void { - for (const key of this.relationStates.keys()) { - if (key.startsWith(keyPrefix)) { - this.commitRelation(key) - } - } - - for (const [key, state] of this.hasManyStates) { - if (key.startsWith(keyPrefix)) { - const newServerIds = new Set(state.serverIds) - for (const removedId of state.plannedRemovals.keys()) { - newServerIds.delete(removedId) - } - for (const connectedId of state.plannedAdditions.keys()) { - newServerIds.add(connectedId) - } - this.commitHasMany(key, Array.from(newServerIds)) - } - } + this.hasOne.commitAllRelations(keyPrefix) + this.hasMany.commitAllRelations(keyPrefix) } /** * Resets all relations (hasOne and hasMany) for an entity to server state. */ resetAllRelations(keyPrefix: string): void { - for (const key of this.relationStates.keys()) { - if (key.startsWith(keyPrefix)) { - this.resetRelation(key) - } - } - - for (const key of this.hasManyStates.keys()) { - if (key.startsWith(keyPrefix)) { - this.resetHasMany(key) - } - } + this.hasOne.resetAllRelations(keyPrefix) + this.hasMany.resetAllRelations(keyPrefix) } // ==================== Dirty Tracking ==================== /** - * Gets the list of dirty relations for an entity. + * Gets the list of dirty relations (has-one and has-many) for an entity. */ getDirtyRelations(keyPrefix: string): string[] { const dirtyRelations: string[] = [] - - for (const [key, state] of this.relationStates) { - if (!key.startsWith(keyPrefix)) continue - const fieldName = key.slice(keyPrefix.length) - - if ( - state.currentId !== state.serverId || - state.state !== state.serverState || - Object.keys(state.placeholderData).length > 0 - ) { - dirtyRelations.push(fieldName) - } - } - - for (const [key, state] of this.hasManyStates) { - if (!key.startsWith(keyPrefix)) continue - const fieldName = key.slice(keyPrefix.length) - - if (state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0) { - dirtyRelations.push(fieldName) - } - } - + this.hasOne.collectDirtyRelations(keyPrefix, dirtyRelations) + this.hasMany.collectDirtyRelations(keyPrefix, dirtyRelations) return dirtyRelations } // ==================== Export/Import ==================== - /** - * Exports relation states for given keys. - */ exportRelationStates(keys: string[]): Map { - const result = new Map() - for (const key of keys) { - const state = this.relationStates.get(key) - if (state) { - result.set(key, { - ...state, - placeholderData: { ...state.placeholderData }, - }) - } - } - return result + return this.hasOne.exportRelationStates(keys) } - /** - * Exports has-many states for given keys. - */ exportHasManyStates(keys: string[]): Map { - const result = new Map() - for (const key of keys) { - const state = this.hasManyStates.get(key) - if (state) { - result.set(key, { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedAdditions: new Map(state.plannedAdditions), - version: state.version, - }) - } - } - return result + return this.hasMany.exportHasManyStates(keys) } - /** - * Imports relation states from a snapshot. - * Returns the keys that were imported for notification. - */ importRelationStates(states: Map): string[] { - const keys: string[] = [] - for (const [key, state] of states) { - this.writeRelation(key, { - ...state, - placeholderData: { ...state.placeholderData }, - }) - keys.push(key) - } - return keys + return this.hasOne.importRelationStates(states) } - /** - * Imports has-many states from a snapshot. - * Returns the keys that were imported for notification. - */ importHasManyStates(states: Map): string[] { - const keys: string[] = [] - for (const [key, state] of states) { - this.writeHasMany(key, { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedAdditions: new Map(state.plannedAdditions), - version: state.version + 1, - }) - keys.push(key) - } - return keys + return this.hasMany.importHasManyStates(states) } + // ==================== Rekey ==================== + /** * Replaces all occurrences of oldId with newId across relation and hasMany states. * Used after persist to rekey temp IDs to server-assigned IDs. */ replaceEntityId(oldId: string, newId: string): void { - // Replace in has-one relation states: currentId, serverId - for (const [key, state] of this.relationStates) { - let changed = false - let currentId = state.currentId - let serverId = state.serverId - - if (currentId === oldId) { currentId = newId; changed = true } - if (serverId === oldId) { serverId = newId; changed = true } - - if (changed) { - this.writeRelation(key, { ...state, currentId, serverId, version: state.version + 1 }) - } - } - - // Replace in has-many states: serverIds, orderedIds, plannedAdditions, plannedRemovals - for (const [key, state] of this.hasManyStates) { - let changed = false - - let serverIds = state.serverIds - if (serverIds.has(oldId)) { - serverIds = new Set(serverIds) - serverIds.delete(oldId) - serverIds.add(newId) - changed = true - } - - let orderedIds = state.orderedIds - if (orderedIds) { - const idx = orderedIds.indexOf(oldId) - if (idx !== -1) { - orderedIds = [...orderedIds] - orderedIds[idx] = newId - changed = true - } - } - - let plannedAdditions = state.plannedAdditions - const additionKind = plannedAdditions.get(oldId) - if (additionKind !== undefined) { - plannedAdditions = new Map(plannedAdditions) - plannedAdditions.delete(oldId) - plannedAdditions.set(newId, additionKind) - changed = true - } - - let plannedRemovals = state.plannedRemovals - if (plannedRemovals.has(oldId)) { - const removalType = plannedRemovals.get(oldId)! - plannedRemovals = new Map(plannedRemovals) - plannedRemovals.delete(oldId) - plannedRemovals.set(newId, removalType) - changed = true - } - - if (changed) { - this.writeHasMany(key, { - serverIds, - orderedIds, - plannedRemovals, - plannedAdditions, - version: state.version + 1, - }) - } - } + this.hasOne.replaceEntityId(oldId, newId) + this.hasMany.replaceEntityId(oldId, newId) } /** @@ -827,78 +229,15 @@ export class RelationStore implements Rekeyable { * Rekeys relation/hasMany entries owned by an entity (changes the parent ID in the key). */ rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void { - const toMoveRelations: [string, StoredRelationState][] = [] - for (const [key, value] of this.relationStates) { - if (key.startsWith(oldKeyPrefix)) { - toMoveRelations.push([key, value]) - } - } - for (const [oldKey, value] of toMoveRelations) { - this.relationStates.delete(oldKey) - this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) - } - - const toMoveHasMany: [string, StoredHasManyState][] = [] - for (const [key, value] of this.hasManyStates) { - if (key.startsWith(oldKeyPrefix)) { - toMoveHasMany.push([key, value]) - } - } - for (const [oldKey, value] of toMoveHasMany) { - this.hasManyStates.delete(oldKey) - this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) - } + this.hasOne.rekeyOwner(oldKeyPrefix, newKeyPrefix) + this.hasMany.rekeyOwner(oldKeyPrefix, newKeyPrefix) } /** * Clears all relation data. */ clear(): void { - this.relationStates.clear() - this.hasManyStates.clear() - this.mutationVersion++ - } -} - -// ==================== Helper Functions ==================== - -/** - * Derives the parent composite key ("parentType:parentId") from a relation key - * ("parentType:parentId:fieldName") by dropping the trailing field segment. - * Entity ids and field names never contain ':', so the parent key is everything - * before the last separator. - */ -function parentKeyFromRelationKey(relationKey: string): string { - const lastSeparator = relationKey.lastIndexOf(':') - return relationKey.slice(0, lastSeparator) -} - -/** - * Computes the default ordered IDs for a has-many relation. - * Order is: serverIds (minus removals) + plannedAdditions - */ -export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { - const result: string[] = [] - - for (const id of state.serverIds) { - if (!state.plannedRemovals.has(id)) { - result.push(id) - } - } - - for (const id of state.plannedAdditions.keys()) { - if (!result.includes(id)) { - result.push(id) - } - } - - return result -} - -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false + this.hasOne.clear() + this.hasMany.clear() } - return true } diff --git a/packages/bindx/src/store/relationKey.ts b/packages/bindx/src/store/relationKey.ts new file mode 100644 index 00000000..6b6973fe --- /dev/null +++ b/packages/bindx/src/store/relationKey.ts @@ -0,0 +1,10 @@ +/** + * Derives the parent composite key ("parentType:parentId") from a relation key + * ("parentType:parentId:fieldName") by dropping the trailing field segment. + * Entity ids and field names never contain ':', so the parent key is everything + * before the last separator. + */ +export function parentKeyFromRelationKey(relationKey: string): string { + const lastSeparator = relationKey.lastIndexOf(':') + return relationKey.slice(0, lastSeparator) +} From d1b837a7d0ab0388d4a89d772f59137c1af74347 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 13:39:17 +0200 Subject: [PATCH 17/28] fix(bindx): make has-one refetch baseline advance non-notifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureEntry()/advanceServerBaselineOnRefetch runs on every has-one read, including during React render. Its refetch branch wrote the new server baseline via the notifying setRelation, calling subscribers mid-render and violating the external-store contract. Add a skipNotify path to SnapshotStore.setRelation and use it here — the parent re-fetch that produced the new embedded reference already notified subscribers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/handles/HasOneHandle.ts | 9 ++- packages/bindx/src/store/SnapshotStore.ts | 8 +- tests/unit/handles/hasOneHandle.test.ts | 93 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index a02e6583..a3798559 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -213,6 +213,13 @@ export class HasOneHandle * Does NOT consume the propagation slot — {@link ensureRelatedEntitySnapshot} * owns it. The same reference-change signal drives both, so both run within the * one render that observes a new parent reference. + * + * The baseline write is NON-NOTIFYING: it runs during a render-phase read, and + * the parent re-fetch that produced the new embedded reference already notified + * subscribers. Notifying again here would mutate the store and synchronously call + * subscribers mid-render, violating the external-store contract (cf. + * {@link ensureRelatedEntitySnapshot}, which refreshes server data with skipNotify + * for the same reason). */ private advanceServerBaselineOnRefetch( existing: StoredRelationState, @@ -231,7 +238,7 @@ export class HasOneHandle serverId: embeddedId, state: 'connected', serverState: 'connected', - }) + }, true) } private isLocallyDirty(relation: StoredRelationState): boolean { diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 5c157ddb..72ea4a97 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -774,12 +774,18 @@ export class SnapshotStore implements SnapshotVersionBumper { parentId: string, fieldName: string, updates: Partial>, + skipNotify: boolean = false, ): void { const key = this.getRelationKey(parentType, parentId, fieldName) const entityKey = this.getEntityKey(parentType, parentId) const entitySnapshot = this.entitySnapshots.get(entityKey) this.relations.setRelation(key, updates, entitySnapshot, fieldName) - this.notifyRelationSubscribers(key) + // skipNotify is for writes that happen during a render-phase read (e.g. + // HasOneHandle materialization) where the change that triggered them already + // notified subscribers — notifying again would call subscribers mid-render. + if (!skipNotify) { + this.notifyRelationSubscribers(key) + } } commitRelation(parentType: string, parentId: string, fieldName: string): void { diff --git a/tests/unit/handles/hasOneHandle.test.ts b/tests/unit/handles/hasOneHandle.test.ts index 686f3586..012e5502 100644 --- a/tests/unit/handles/hasOneHandle.test.ts +++ b/tests/unit/handles/hasOneHandle.test.ts @@ -764,6 +764,99 @@ describe('HasOneHandle', () => { }) }) + // ==================== Server-baseline advance on re-fetch ==================== + // + // On a parent re-fetch whose embedded related id changed, a non-dirty has-one + // must advance its server baseline to the new id (and stay clean), while a + // locally-dirty relation must survive the re-fetch. The advance runs during a + // render-phase read and must NOT notify subscribers (the re-fetch already did). + + describe('Server-baseline advance on re-fetch', () => { + test('a re-fetch that changes the related id advances the baseline and stays clean', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + expect(handle.isDirty).toBe(false) + + // Parent re-fetched: a NEW embedded author reference with a different id. + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-2', name: 'Jane' }, + }, true) + + // Reading advances the server baseline to the new related id; still clean. + expect(handle.relatedId).toBe('auth-2') + expect(handle.isDirty).toBe(false) + + const relation = store.getRelation('Article', 'a-1', 'author') + expect(relation?.currentId).toBe('auth-2') + expect(relation?.serverId).toBe('auth-2') + expect(relation?.state).toBe('connected') + expect(relation?.serverState).toBe('connected') + }) + + test('a locally-connected relation survives a re-fetch that changes the embedded id', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + + // Local connect to a different author → dirty. + store.setRelation('Article', 'a-1', 'author', { currentId: 'auth-2', state: 'connected' }) + expect(handle.relatedId).toBe('auth-2') + expect(handle.isDirty).toBe(true) + + // Parent re-fetched at yet another author — the local dirty connect wins. + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-3', name: 'Bob' }, + }, true) + + expect(handle.relatedId).toBe('auth-2') + expect(handle.isDirty).toBe(true) + }) + + test('the baseline advance does not notify subscribers during a render-phase read', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + + // Re-fetch with a new related id, setting up the advance on the next read. + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-2', name: 'Jane' }, + }, true) + + // Subscribe AFTER the re-fetch notification: any callback fired now would be + // the illegal mid-read notification from the baseline advance (CORR-6). + const entityCb = mock(() => {}) + const relationCb = mock(() => {}) + store.subscribeToEntity('Article', 'a-1', entityCb) + store.subscribeToRelation('Article', 'a-1', 'author', relationCb) + + // This read triggers advanceServerBaselineOnRefetch. + expect(handle.relatedId).toBe('auth-2') + + expect(entityCb).not.toHaveBeenCalled() + expect(relationCb).not.toHaveBeenCalled() + }) + }) + // ==================== Reachability / dirty for created has-one child ==================== describe('Created has-one child via embedded data', () => { From 3827de08da8932fefa329bb35996414475307c2d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 13:39:29 +0200 Subject: [PATCH 18/28] fix(bindx): guard connectExistingToHasMany against duplicate ordered id connectExistingToHasMany appended to orderedIds unconditionally, so re-materializing the same embedded connect reference surfaced the list item twice. Mirror planHasManyConnection: only touch an explicit order and guard with !includes. Also pin the load-bearing no-downgrade invariant (a connect after a create on the same id must keep emitting a create) on both the planHasManyConnection and connectExistingToHasMany paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/HasManyStore.ts | 11 +++- tests/unit/store/hasManyAdditionKind.test.ts | 68 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/unit/store/hasManyAdditionKind.test.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index d5570b7e..224c9b8a 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -303,8 +303,15 @@ export class HasManyStore { if (newPlannedAdditions.get(itemId) !== 'created') { newPlannedAdditions.set(itemId, 'connected') } - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - const newOrderedIds = [...currentOrderedIds, itemId] + // Only touch an explicit order — the default order already derives from + // plannedAdditions (see computeDefaultOrderedIds). Guard against + // re-appending an id that is already listed: this path re-runs whenever an + // embedded connect reference is re-materialized, and an unconditional + // append would surface the same item twice (mirrors planHasManyConnection). + let newOrderedIds = existing.orderedIds + if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { + newOrderedIds = [...newOrderedIds, itemId] + } this.writeHasMany(key, { ...existing, orderedIds: newOrderedIds, diff --git a/tests/unit/store/hasManyAdditionKind.test.ts b/tests/unit/store/hasManyAdditionKind.test.ts new file mode 100644 index 00000000..319c29d1 --- /dev/null +++ b/tests/unit/store/hasManyAdditionKind.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from 'bun:test' +import { RelationStore } from '../../../packages/bindx/src/store/RelationStore.js' + +/** + * Pins the has-many planned-addition KIND invariant after the two-set model + * (plannedConnections + createdEntities) collapsed into a single + * Map in HasManyStore. + * + * The load-bearing rule is "no downgrade": once an id is recorded as 'created' + * (a newly created, never-persisted entity), a later connect MUST NOT rewrite it + * to 'connected'. MutationCollector reads the kind verbatim — 'created' → emit a + * create, 'connected' → emit a connect — so a downgrade would emit a connect to a + * temp id the server never creates: a silently dropped write (data loss). Both the + * planHasManyConnection and connectExistingToHasMany paths carry the guard, so + * both are pinned here. + */ +describe('HasMany planned-addition kind', () => { + const KEY = 'Article:a1:tags' + + describe('no downgrade: created stays created', () => { + test('planHasManyConnection does not downgrade a created addition', () => { + const relations = new RelationStore() + relations.addToHasMany(KEY, 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')).toBe('created') + + // connect() after add() on the SAME id must keep it a create. + relations.planHasManyConnection(KEY, 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')).toBe('created') + }) + + test('connectExistingToHasMany does not downgrade a created addition', () => { + const relations = new RelationStore() + relations.addToHasMany(KEY, 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')).toBe('created') + + // The embedded-connect materialization path must not downgrade either. + relations.connectExistingToHasMany(KEY, 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')).toBe('created') + }) + + test('a genuine connect of a never-added id is recorded as connected', () => { + const relations = new RelationStore() + relations.connectExistingToHasMany(KEY, 'persisted-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')).toBe('connected') + }) + }) + + describe('connectExistingToHasMany ordered-id dedup', () => { + test('re-connecting the same id does not duplicate it in the ordered list', () => { + const relations = new RelationStore() + // The same embedded connect reference can be materialized more than once. + relations.connectExistingToHasMany(KEY, 'persisted-1') + relations.connectExistingToHasMany(KEY, 'persisted-1') + + expect(relations.getHasManyOrderedIds(KEY)).toEqual(['persisted-1']) + expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')).toBe('connected') + }) + + test('connecting an id already present as a server member does not duplicate it', () => { + const relations = new RelationStore() + relations.setHasManyServerIds(KEY, ['persisted-1', 'persisted-2']) + + relations.connectExistingToHasMany(KEY, 'persisted-1') + + expect(relations.getHasManyOrderedIds(KEY)).toEqual(['persisted-1', 'persisted-2']) + }) + }) +}) From 74c501b0b52dfb463134e9ef94dcf45f849e1ab1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 13:39:34 +0200 Subject: [PATCH 19/28] test(bindx): pin reachability cache invalidation for has-one edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memoization suite covered only has-many edge changes. Add has-one setRelation connect/disconnect cases and a direct getMutationVersion test proving the facade counter sums both sub-stores — so dropping the has-one term (a stale-cache create-detection bug) would be caught. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- .../store/reachabilityMemoization.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/store/reachabilityMemoization.test.ts b/tests/unit/store/reachabilityMemoization.test.ts index e5d42dff..2d7bec12 100644 --- a/tests/unit/store/reachabilityMemoization.test.ts +++ b/tests/unit/store/reachabilityMemoization.test.ts @@ -115,6 +115,56 @@ describe('reachability memoization', () => { expect(sortedKeys(result)).toEqual([]) }) + // Has-one edge variants of the add/remove tests above. getMutationVersion() + // SUMS both sub-store counters, so a has-one setRelation (connect/disconnect) + // must invalidate the cache just like a has-many edge change. If the has-one + // term were dropped from the sum, a created child connected through a has-one + // would be served stale — a dropped or phantom create. + test('connecting a created child via has-one invalidates the cache and is reflected', () => { + const h = createHarness() + h.entitySnapshots.setData('Article:a1', 'a1', 'Article', { id: 'a1' }, true) + h.meta.setExistsOnServer('Article:a1', true) + // A created (never-persisted) author exists but is not yet reachable. + h.entitySnapshots.setData('Author:au1', 'au1', 'Author', { id: 'au1' }, false) + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual([]) + const calls = h.walkCount() + + h.relations.setRelation('Article:a1:author', { currentId: 'au1', state: 'connected' }, undefined, 'author') + + const result = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBeGreaterThan(calls) // recomputed, not served stale + expect(sortedKeys(result)).toEqual(['Author:au1']) + }) + + test('disconnecting a created child via has-one invalidates the cache and drops it', () => { + const h = createHarness() + h.entitySnapshots.setData('Article:a1', 'a1', 'Article', { id: 'a1' }, true) + h.meta.setExistsOnServer('Article:a1', true) + h.entitySnapshots.setData('Author:au1', 'au1', 'Author', { id: 'au1' }, false) + h.relations.setRelation('Article:a1:author', { currentId: 'au1', state: 'connected' }, undefined, 'author') + expect(sortedKeys(h.analyzer.computeReachableCreated())).toEqual(['Author:au1']) + const calls = h.walkCount() + + h.relations.setRelation('Article:a1:author', { currentId: null, state: 'disconnected' }, undefined, 'author') + + const result = h.analyzer.computeReachableCreated() + expect(h.walkCount()).toBeGreaterThan(calls) + expect(sortedKeys(result)).toEqual([]) + }) + + test('RelationStore.getMutationVersion sums has-one and has-many writes', () => { + const relations = new RelationStore() + const v0 = relations.getMutationVersion() + + relations.setRelation('Article:a1:author', { currentId: 'au1', state: 'connected' }, undefined, 'author') + const v1 = relations.getMutationVersion() + expect(v1).toBeGreaterThan(v0) // has-one write counted + + relations.addToHasMany('Article:a1:comments', 'c1') + const v2 = relations.getMutationVersion() + expect(v2).toBeGreaterThan(v1) // has-many write also counted (the sum) + }) + test('flipping existsOnServer invalidates the cache', () => { const h = createHarness() // A top-level created entity (root) — reported as a create until it exists. From 544ba207c3671d2c44a3c77bea6d0592dfa60ea8 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 13:39:43 +0200 Subject: [PATCH 20/28] docs(bindx): document bare-id contract of getParentKeysForChild The reverse parent lookup matches on a bare child id, deliberately relying on the store-wide global-id-uniqueness invariant (the same one behind EntitySnapshotStore idIndex/keyForId and the forward reachability walk). Document this on getParentKeysForChild and getParentKeys, and pin it with a test so a future type-aware change trips deliberately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/RelationStore.ts | 8 ++++++++ packages/bindx/src/store/SubscriptionManager.ts | 4 ++++ tests/unit/store/getParentKeysForChild.test.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 43e10b0a..de56d454 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -138,6 +138,14 @@ export class RelationStore implements Rekeyable { * currently has a LIVE relation edge pointing at {@link childId} — the reverse * of {@link getLiveChildIds}. Unions both sub-stores' results, scanning the * same live edges the forward query reads so the two never disagree. + * + * {@link childId} is a BARE entity id (no type): relation state records child ids + * without their type, so the match is by id alone. This deliberately relies on + * the store-wide invariant that entity ids are globally unique across types + * (server UUIDs / minted temp ids — the same invariant behind + * EntitySnapshotStore's idIndex / keyForId), so a bare id resolves to exactly one + * entity. The forward reachability walk matches on the same bare ids, so making + * this type-aware in isolation would only diverge the two halves of one query. */ getParentKeysForChild(childId: string): Set { const parents = new Set() diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index a44780b9..1e937b15 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -147,6 +147,10 @@ export class SubscriptionManager implements Rekeyable { * Derives the parent entity keys for a child entity key by reading the live * relation edges via the injected lookup. The child's bare id is the part of * the key after the first ':' ("entityType:id"). + * + * The child's TYPE is intentionally dropped — {@link ParentKeyLookup} matches on + * the bare id, relying on the store-wide global-id-uniqueness invariant (see + * {@link RelationStore.getParentKeysForChild}). */ private getParentKeys(childKey: string): Set { if (!this.parentKeyLookup) return new Set() diff --git a/tests/unit/store/getParentKeysForChild.test.ts b/tests/unit/store/getParentKeysForChild.test.ts index 8debbb12..968fa1ff 100644 --- a/tests/unit/store/getParentKeysForChild.test.ts +++ b/tests/unit/store/getParentKeysForChild.test.ts @@ -82,6 +82,22 @@ describe('RelationStore.getParentKeysForChild', () => { expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1', 'Tag:t1'])) }) + test('matches purely on the bare child id (global-id-uniqueness invariant)', () => { + const relations = new RelationStore() + + // The reverse lookup receives a BARE id (no entity type) and matches by id + // alone, deliberately relying on the store-wide invariant that ids are unique + // across types. A single bare id therefore resolves to every parent that + // anchors it, regardless of those parents' types. This pins the bare-id + // contract: making the lookup type-aware in isolation would change this and + // must be a deliberate decision (it would also have to move in lockstep with + // the forward reachability walk, which matches on the same bare ids). + relations.setRelation('Author:a1:featured', { currentId: 'shared', state: 'connected' }, undefined, 'featured') + relations.addToHasMany('Tag:t1:articles', 'shared') + + expect(relations.getParentKeysForChild('shared')).toEqual(new Set(['Author:a1', 'Tag:t1'])) + }) + test('cross-check: reverse query agrees with getLiveChildIds over a randomized sequence', () => { const relations = new RelationStore() const parents = ['Author:a1', 'Author:a2', 'Tag:t1', 'Tag:t2'] From 8ec75827efa32dc74c32211b395f95b135ec7a93 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 14:34:33 +0200 Subject: [PATCH 21/28] perf(bindx): back relation queries with a bidirectional edge index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getParentKeysForChild and getLiveChildIds scanned every relation entry, so parent-notification and the reachability walk were O(total relations) per call — a per-edit cost that scaled with store size (benchmarked ~550 µs/edit at 4000 rows vs ~3 µs before the childToParents map was removed). Introduce RelationEdgeIndex: a bidirectional, reference-counted index of live parent<->child edges that each sub-store maintains through its single write/delete chokepoint by diffing the previous against the next live set. Both queries become O(degree) and the two directions stay consistent by construction (the failure mode of the old append-only childToParents map). Liveness is now a single predicate per sub-store, consumed by the chokepoint, instead of being duplicated across the forward and reverse scans. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- packages/bindx/src/store/HasManyStore.ts | 87 +++++++++++++------ packages/bindx/src/store/HasOneStore.ts | 77 +++++++++++----- packages/bindx/src/store/RelationEdgeIndex.ts | 79 +++++++++++++++++ packages/bindx/src/store/relationKey.ts | 10 +++ 4 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 packages/bindx/src/store/RelationEdgeIndex.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 224c9b8a..aea0b369 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -1,4 +1,5 @@ -import { parentKeyFromRelationKey } from './relationKey.js' +import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' +import { RelationEdgeIndex } from './RelationEdgeIndex.js' function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false @@ -82,14 +83,45 @@ export class HasManyStore { /** Has-many list states keyed by "parentType:parentId:fieldName" */ private readonly hasManyStates = new Map() + /** + * Bidirectional live-edge index, maintained by {@link writeHasMany} / + * {@link deleteHasMany} so the parent↔child queries are O(degree) and the two + * directions stay consistent by construction. + */ + private readonly edges = new RelationEdgeIndex() + private mutationVersion = 0 getMutationVersion(): number { return this.mutationVersion } + /** + * The single write chokepoint. Reconciles the edge index by diffing the live + * members of the previous state against the next, so every state-changing path + * (server ids / planned add/remove / move / import / replaceEntityId / ...) + * 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 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.mutationVersion++ + } + + /** + * The single delete chokepoint — removes an entry and its live edges. Used by + * the bulk remove and rekey-owner paths so they don't leak edges. + */ + private deleteHasMany(key: string): void { + const existing = this.hasManyStates.get(key) + if (!existing) return + const parentKey = parentKeyFromRelationKey(key) + for (const id of liveHasManyChildIds(existing)) this.edges.removeEdge(parentKey, id) + this.hasManyStates.delete(key) this.mutationVersion++ } @@ -408,48 +440,31 @@ export class HasManyStore { /** * Collects the ids of child entities reachable through LIVE has-many edges - * (key prefix "parentType:parentId:"): - * effective members = (serverIds ∪ plannedAdditions.keys()) minus plannedRemovals. + * (key prefix "parentType:parentId:") — an O(degree) read of the edge index. */ collectLiveChildIds(keyPrefix: string, ids: Set): void { - for (const [key, state] of this.hasManyStates) { - if (!key.startsWith(keyPrefix)) continue - for (const id of state.serverIds) { - if (!state.plannedRemovals.has(id)) ids.add(id) - } - for (const id of state.plannedAdditions.keys()) { - if (!state.plannedRemovals.has(id)) ids.add(id) - } - } + this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids) } /** * Adds the composite parent keys of every LIVE has-many edge containing - * {@link childId}: childId ∈ (serverIds ∪ plannedAdditions.keys()) and - * childId ∉ plannedRemovals. + * {@link childId} — an O(degree) read of the edge index, the exact reverse of + * {@link collectLiveChildIds}. */ collectParentKeysForChild(childId: string, parents: Set): void { - for (const [key, state] of this.hasManyStates) { - if (state.plannedRemovals.has(childId)) continue - if (state.serverIds.has(childId) || state.plannedAdditions.has(childId)) { - parents.add(parentKeyFromRelationKey(key)) - } - } + this.edges.collectParents(childId, parents) } /** * Removes all has-many state owned by an entity (keys under the given owner - * prefix). Bumps the mutation version once if anything was removed. + * prefix), dropping each entry's edges through {@link deleteHasMany}. */ removeOwnedRelations(keyPrefix: string): void { - let changed = false for (const key of [...this.hasManyStates.keys()]) { if (key.startsWith(keyPrefix)) { - this.hasManyStates.delete(key) - changed = true + this.deleteHasMany(key) } } - if (changed) this.mutationVersion++ } /** @@ -592,6 +607,8 @@ export class HasManyStore { /** * Rekeys has-many entries owned by an entity (changes the parent ID in the key). + * Routing through {@link deleteHasMany} + {@link writeHasMany} migrates the edge + * index from the old parent key to the new one for free. */ rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void { const toMove: [string, StoredHasManyState][] = [] @@ -601,7 +618,7 @@ export class HasManyStore { } } for (const [oldKey, value] of toMove) { - this.hasManyStates.delete(oldKey) + this.deleteHasMany(oldKey) this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) } } @@ -611,6 +628,24 @@ export class HasManyStore { */ clear(): void { this.hasManyStates.clear() + this.edges.clear() this.mutationVersion++ } } + +/** + * The single liveness predicate for has-many membership: effective members are + * (serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and consumed + * by the write chokepoint so the forward/reverse index can never drift from it. + */ +function liveHasManyChildIds(state: StoredHasManyState | undefined): Set { + const live = new Set() + if (!state) return live + for (const id of state.serverIds) { + if (!state.plannedRemovals.has(id)) live.add(id) + } + for (const id of state.plannedAdditions.keys()) { + if (!state.plannedRemovals.has(id)) live.add(id) + } + return live +} diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index 97d5649d..574cab11 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -1,6 +1,7 @@ import type { HasOneRelationState } from '../handles/types.js' import type { EntitySnapshot } from './snapshots.js' -import { parentKeyFromRelationKey } from './relationKey.js' +import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' +import { RelationEdgeIndex } from './RelationEdgeIndex.js' /** * Relation state stored in SnapshotStore @@ -25,14 +26,47 @@ export class HasOneStore { /** Relation states keyed by "parentType:parentId:fieldName" */ private readonly relationStates = new Map() + /** + * Bidirectional live-edge index, maintained by {@link writeRelation} / + * {@link deleteRelation} so the parent↔child queries are O(degree) and the two + * directions stay consistent by construction. + */ + private readonly edges = new RelationEdgeIndex() + private mutationVersion = 0 getMutationVersion(): number { return this.mutationVersion } + /** + * The single write chokepoint. Reconciles the edge index by diffing the live + * child of the previous state against the next, so every state-changing path + * (set/commit/reset/import/replaceEntityId/...) keeps the index correct without + * tracking the reverse direction itself. + */ private writeRelation(key: string, state: StoredRelationState): void { + const oldChild = liveHasOneChildId(this.relationStates.get(key)) + 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.mutationVersion++ + } + + /** + * The single delete chokepoint — removes an entry and its live edge. Used by + * the bulk remove and rekey-owner paths so they don't leak edges. + */ + private deleteRelation(key: string): void { + const existing = this.relationStates.get(key) + if (!existing) return + const child = liveHasOneChildId(existing) + if (child !== null) this.edges.removeEdge(parentKeyFromRelationKey(key), child) + this.relationStates.delete(key) this.mutationVersion++ } @@ -132,43 +166,31 @@ export class HasOneStore { /** * Collects the ids of child entities reachable through LIVE has-one edges - * (key prefix "parentType:parentId:"): currentId, when the relation is not - * disconnected/deleted. + * (key prefix "parentType:parentId:") — an O(degree) read of the edge index. */ collectLiveChildIds(keyPrefix: string, ids: Set): void { - for (const [key, state] of this.relationStates) { - if (!key.startsWith(keyPrefix)) continue - if (state.currentId !== null && state.state !== 'deleted') { - ids.add(state.currentId) - } - } + this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids) } /** * Adds the composite parent keys of every LIVE has-one edge pointing at - * {@link childId} (currentId === childId, when the relation is not 'deleted'). + * {@link childId} — an O(degree) read of the edge index, the exact reverse of + * {@link collectLiveChildIds}. */ collectParentKeysForChild(childId: string, parents: Set): void { - for (const [key, state] of this.relationStates) { - if (state.currentId === childId && state.state !== 'deleted') { - parents.add(parentKeyFromRelationKey(key)) - } - } + this.edges.collectParents(childId, parents) } /** * Removes all has-one state owned by an entity (keys under the given owner - * prefix). Bumps the mutation version once if anything was removed. + * prefix), dropping each entry's edge through {@link deleteRelation}. */ removeOwnedRelations(keyPrefix: string): void { - let changed = false for (const key of [...this.relationStates.keys()]) { if (key.startsWith(keyPrefix)) { - this.relationStates.delete(key) - changed = true + this.deleteRelation(key) } } - if (changed) this.mutationVersion++ } /** @@ -265,6 +287,8 @@ export class HasOneStore { /** * Rekeys has-one entries owned by an entity (changes the parent ID in the key). + * Routing through {@link deleteRelation} + {@link writeRelation} migrates the + * edge index from the old parent key to the new one for free. */ rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void { const toMove: [string, StoredRelationState][] = [] @@ -274,7 +298,7 @@ export class HasOneStore { } } for (const [oldKey, value] of toMove) { - this.relationStates.delete(oldKey) + this.deleteRelation(oldKey) this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) } } @@ -284,6 +308,17 @@ export class HasOneStore { */ clear(): void { this.relationStates.clear() + this.edges.clear() this.mutationVersion++ } } + +/** + * The single liveness predicate for a has-one edge: the related id is live when + * the relation is connected to it and not deleted. Defined once and consumed by + * the write chokepoint so the forward/reverse index can never drift from it. + */ +function liveHasOneChildId(state: StoredRelationState | undefined): string | null { + if (!state) return null + return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null +} diff --git a/packages/bindx/src/store/RelationEdgeIndex.ts b/packages/bindx/src/store/RelationEdgeIndex.ts new file mode 100644 index 00000000..43ddf733 --- /dev/null +++ b/packages/bindx/src/store/RelationEdgeIndex.ts @@ -0,0 +1,79 @@ +/** + * Bidirectional index of LIVE relation edges between a parent entity + * ("parentType:parentId") and a child entity id (a bare id). + * + * Both directions are updated together by {@link addEdge}/{@link removeEdge}, so + * the forward query ({@link collectChildren}) and the reverse query + * ({@link collectParents}) can never disagree — that was the failure mode of the + * old append-only `childToParents` map, which was updated on connect but not on + * disconnect. Both queries are O(degree), not O(total edges). + * + * Edges are reference-counted: one parent entity may reach the same child through + * more than one relation field (e.g. `author` and `coauthor`, or two has-many + * fields), so an edge survives until the last field that contributes it drops it. + * + * The index stores only the LIVE edge set; turning relation state (server ids, + * planned additions/removals, has-one state) into that set is the owning + * sub-store's job, done once per write by diffing the previous against the next + * live set (see {@link HasOneStore}/{@link HasManyStore}). The index itself knows + * nothing about liveness rules. + */ +export class RelationEdgeIndex { + /** parentKey ("parentType:parentId") → childId → refcount */ + private readonly forward = new Map>() + /** childId → parentKey → refcount */ + private readonly reverse = new Map>() + + addEdge(parentKey: string, childId: string): void { + increment(this.forward, parentKey, childId) + increment(this.reverse, childId, parentKey) + } + + removeEdge(parentKey: string, childId: string): void { + decrement(this.forward, parentKey, childId) + decrement(this.reverse, childId, parentKey) + } + + /** Adds the live child ids of {@link parentKey} to {@link out}. */ + collectChildren(parentKey: string, out: Set): void { + const children = this.forward.get(parentKey) + if (children) { + for (const childId of children.keys()) out.add(childId) + } + } + + /** Adds the parent keys that hold a live edge to {@link childId} to {@link out}. */ + collectParents(childId: string, out: Set): void { + const parents = this.reverse.get(childId) + if (parents) { + for (const parentKey of parents.keys()) out.add(parentKey) + } + } + + clear(): void { + this.forward.clear() + this.reverse.clear() + } +} + +function increment(map: Map>, outer: string, inner: string): void { + let counts = map.get(outer) + if (!counts) { + counts = new Map() + map.set(outer, counts) + } + counts.set(inner, (counts.get(inner) ?? 0) + 1) +} + +function decrement(map: Map>, outer: string, inner: string): void { + const counts = map.get(outer) + if (!counts) return + const count = counts.get(inner) + if (count === undefined) return + if (count > 1) { + counts.set(inner, count - 1) + return + } + counts.delete(inner) + if (counts.size === 0) map.delete(outer) +} diff --git a/packages/bindx/src/store/relationKey.ts b/packages/bindx/src/store/relationKey.ts index 6b6973fe..f3c2cd51 100644 --- a/packages/bindx/src/store/relationKey.ts +++ b/packages/bindx/src/store/relationKey.ts @@ -8,3 +8,13 @@ export function parentKeyFromRelationKey(relationKey: string): string { const lastSeparator = relationKey.lastIndexOf(':') return relationKey.slice(0, lastSeparator) } + +/** + * Derives the parent composite key ("parentType:parentId") from an owner key + * prefix ("parentType:parentId:") by dropping the trailing separator. Callers + * pass the owner prefix used for relation-key lookups; this maps it to the key + * the {@link RelationEdgeIndex} stores edges under. + */ +export function parentKeyFromOwnerPrefix(ownerPrefix: string): string { + return ownerPrefix.endsWith(':') ? ownerPrefix.slice(0, -1) : ownerPrefix +} From 0c662bbe84a1a3751f073ddca5efd95f426c4faa Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 23 Jun 2026 14:34:38 +0200 Subject: [PATCH 22/28] test(bindx): cover the relation edge index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the reference-counting and migration cases the bidirectional index introduces — same parent reaching a child through several fields, id replacement, owner rekey, bulk removal, commit/reset — alongside the existing randomized forward/reverse cross-check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj --- tests/unit/store/relationEdgeIndex.test.ts | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/unit/store/relationEdgeIndex.test.ts diff --git a/tests/unit/store/relationEdgeIndex.test.ts b/tests/unit/store/relationEdgeIndex.test.ts new file mode 100644 index 00000000..37c4c8e2 --- /dev/null +++ b/tests/unit/store/relationEdgeIndex.test.ts @@ -0,0 +1,138 @@ +import { describe, test, expect } from 'bun:test' +import { RelationStore } from '../../../packages/bindx/src/store/RelationStore.js' +import { RelationEdgeIndex } from '../../../packages/bindx/src/store/RelationEdgeIndex.js' + +/** + * Edge cases introduced by the bidirectional {@link RelationEdgeIndex} that backs + * getLiveChildIds / getParentKeysForChild. The randomized cross-check in + * getParentKeysForChild.test.ts proves forward/reverse agreement over long + * sequences; these tests pin the specific reference-counting and migration cases + * (the same parent reaching a child through several fields, id replacement, owner + * rekey, bulk removal) where a naive reverse map would drift. + */ +describe('RelationEdgeIndex (unit)', () => { + test('reference-counts an edge contributed by multiple fields', () => { + const index = new RelationEdgeIndex() + index.addEdge('Author:a1', 'art1') + index.addEdge('Author:a1', 'art1') // second field on the same parent + + const parents1 = new Set() + index.collectParents('art1', parents1) + expect(parents1).toEqual(new Set(['Author:a1'])) + + index.removeEdge('Author:a1', 'art1') // one field drops it — still live + const parents2 = new Set() + index.collectParents('art1', parents2) + expect(parents2).toEqual(new Set(['Author:a1'])) + + index.removeEdge('Author:a1', 'art1') // last field drops it — gone + const parents3 = new Set() + index.collectParents('art1', parents3) + expect(parents3).toEqual(new Set()) + + // Forward direction is symmetric. + const children = new Set() + index.collectChildren('Author:a1', children) + expect(children).toEqual(new Set()) + }) + + test('over-decrement is a safe no-op', () => { + const index = new RelationEdgeIndex() + index.addEdge('P', 'c') + index.removeEdge('P', 'c') + index.removeEdge('P', 'c') // already gone — must not throw or go negative + const parents = new Set() + index.collectParents('c', parents) + expect(parents).toEqual(new Set()) + }) +}) + +describe('RelationStore edge index integration', () => { + test('two has-one fields to the same child keep the parent until both drop', () => { + const relations = new RelationStore() + relations.setRelation('Author:a1:featured', { currentId: 'art1', state: 'connected' }, undefined, 'featured') + relations.setRelation('Author:a1:secondary', { currentId: 'art1', state: 'connected' }, undefined, 'secondary') + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) + + relations.setRelation('Author:a1:featured', { currentId: null, state: 'disconnected' }, undefined, 'featured') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) // secondary still holds it + + relations.setRelation('Author:a1:secondary', { currentId: null, state: 'disconnected' }, undefined, 'secondary') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('a child reached via has-one AND has-many of one parent survives dropping either', () => { + const relations = new RelationStore() + relations.setRelation('Author:a1:featured', { currentId: 'art1', state: 'connected' }, undefined, 'featured') + relations.addToHasMany('Author:a1:articles', 'art1') + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) + + relations.removeFromHasMany('Author:a1:articles', 'art1', 'disconnect') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) // has-one still holds + + relations.setRelation('Author:a1:featured', { currentId: null, state: 'disconnected' }, undefined, 'featured') + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + }) + + test('replaceEntityId migrates the child id in both directions', () => { + const relations = new RelationStore() + relations.addToHasMany('Author:a1:articles', 'old1') + relations.setRelation('Author:a1:featured', { currentId: 'old1', state: 'connected' }, undefined, 'featured') + + expect(relations.getParentKeysForChild('old1')).toEqual(new Set(['Author:a1'])) + expect(relations.getLiveChildIds('Author:a1:')).toContain('old1') + + relations.replaceEntityId('old1', 'new1') + + expect(relations.getParentKeysForChild('old1')).toEqual(new Set()) + expect(relations.getParentKeysForChild('new1')).toEqual(new Set(['Author:a1'])) + const live = relations.getLiveChildIds('Author:a1:') + expect(live).toContain('new1') + expect(live).not.toContain('old1') + }) + + test('rekeyOwner migrates the parent key in both directions', () => { + const relations = new RelationStore() + relations.addToHasMany('Author:a1:articles', 'art1') + relations.setRelation('Author:a1:featured', { currentId: 'art2', state: 'connected' }, undefined, 'featured') + + relations.rekeyOwner('Author:a1:', 'Author:p1:') + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:p1'])) + expect(relations.getParentKeysForChild('art2')).toEqual(new Set(['Author:p1'])) + expect(relations.getLiveChildIds('Author:a1:')).toEqual([]) + expect(new Set(relations.getLiveChildIds('Author:p1:'))).toEqual(new Set(['art1', 'art2'])) + }) + + test('removeOwnedRelations drops all of an owner edges', () => { + const relations = new RelationStore() + relations.addToHasMany('Author:a1:articles', 'art1') + relations.setRelation('Author:a1:featured', { currentId: 'art2', state: 'connected' }, undefined, 'featured') + + relations.removeOwnedRelations('Author:a1:') + + expect(relations.getParentKeysForChild('art1')).toEqual(new Set()) + expect(relations.getParentKeysForChild('art2')).toEqual(new Set()) + expect(relations.getLiveChildIds('Author:a1:')).toEqual([]) + }) + + test('commit and reset keep the index consistent with membership', () => { + const relations = new RelationStore() + relations.setHasManyServerIds('Author:a1:articles', ['s1', 's2']) + relations.addToHasMany('Author:a1:articles', 'c1') + expect(new Set(relations.getLiveChildIds('Author:a1:'))).toEqual(new Set(['s1', 's2', 'c1'])) + + // commit folds plannedAdditions into serverIds — live membership unchanged. + relations.commitHasMany('Author:a1:articles', ['s1', 's2', 'c1']) + expect(new Set(relations.getLiveChildIds('Author:a1:'))).toEqual(new Set(['s1', 's2', 'c1'])) + expect(relations.getParentKeysForChild('c1')).toEqual(new Set(['Author:a1'])) + + // plan a removal then reset — reset restores the full server membership. + relations.planHasManyRemoval('Author:a1:articles', 's1', 'disconnect') + expect(relations.getParentKeysForChild('s1')).toEqual(new Set()) // removed → not live + relations.resetHasMany('Author:a1:articles') + expect(relations.getParentKeysForChild('s1')).toEqual(new Set(['Author:a1'])) + }) +}) From 98b0e5e46443e4bab89350156fb480815fdb2326 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 1 Jul 2026 16:23:42 +0200 Subject: [PATCH 23/28] fix(bindx): handle null has-one refetch presentation --- packages/bindx/src/handles/HasOneHandle.ts | 63 ++++++++++++----- packages/bindx/src/store/SnapshotStore.ts | 4 ++ tests/unit/handles/hasOneHandle.test.ts | 82 ++++++++++++++++++++++ tests/unit/store/presentationFlag.test.ts | 21 ++++++ 4 files changed, 152 insertions(+), 18 deletions(-) diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index a3798559..b7a87412 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -187,13 +187,14 @@ export class HasOneHandle */ private ensureEntry(): void { const existing = this.store.getRelation(this.entityType, this.entityId, this.fieldName) - const embeddedId = this.readEmbeddedRelatedId() + const embeddedData = this.readEmbeddedRelatedData() + const embeddedReference = readEmbeddedRelatedReference(embeddedData) if (!existing) { - if (embeddedId === null) return + if (embeddedReference.kind !== 'connected') return const serverId = this.readServerRelatedId() this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, { - currentId: embeddedId, + currentId: embeddedReference.id, serverId, state: 'connected', serverState: serverId !== null ? 'connected' : 'disconnected', @@ -202,7 +203,7 @@ export class HasOneHandle return } - this.advanceServerBaselineOnRefetch(existing, embeddedId) + this.advanceServerBaselineOnRefetch(existing, embeddedReference, embeddedData) } /** @@ -223,19 +224,35 @@ export class HasOneHandle */ private advanceServerBaselineOnRefetch( existing: StoredRelationState, - embeddedId: string | null, + embeddedReference: EmbeddedRelatedReference, + embeddedData: unknown, ): void { - if (embeddedId === null || existing.serverId === embeddedId) return + if (embeddedReference.kind === 'absent') return if (this.isLocallyDirty(existing)) return - const embeddedData = this.readEmbeddedRelatedData() + if (embeddedReference.kind === 'null' && existing.serverId === null && existing.serverState === 'disconnected') { + return + } + if (embeddedReference.kind === 'connected' && existing.serverId === embeddedReference.id && existing.serverState === 'connected') { + return + } if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) { return } + if (embeddedReference.kind === 'null') { + this.store.setRelation(this.entityType, this.entityId, this.fieldName, { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + }, true) + return + } + this.store.setRelation(this.entityType, this.entityId, this.fieldName, { - currentId: embeddedId, - serverId: embeddedId, + currentId: embeddedReference.id, + serverId: embeddedReference.id, state: 'connected', serverState: 'connected', }, true) @@ -254,11 +271,6 @@ export class HasOneHandle return this.getEntityData()?.[this.fieldName] } - /** Extracts the related id from the parent's embedded current data, or null. */ - private readEmbeddedRelatedId(): string | null { - return extractRelatedId(this.readEmbeddedRelatedData()) - } - /** Extracts the related id from the parent's embedded server data, or null. */ private readServerRelatedId(): string | null { return extractRelatedId(this.getServerData()?.[this.fieldName]) @@ -671,12 +683,27 @@ export class HasOneHandle } +type EmbeddedRelatedReference = + | { kind: 'absent' } + | { kind: 'null' } + | { kind: 'connected'; id: string } + +/** + * Reads an embedded has-one value while preserving explicit null. + */ +function readEmbeddedRelatedReference(embedded: unknown): EmbeddedRelatedReference { + if (embedded === undefined) return { kind: 'absent' } + if (embedded === null) return { kind: 'null' } + if (typeof embedded !== 'object' || !('id' in embedded)) return { kind: 'absent' } + const id = embedded.id + return typeof id === 'string' ? { kind: 'connected', id } : { kind: 'absent' } +} + /** * Extracts the related entity id from an embedded has-one object, or null when - * the value is absent / not an object with a string id. + * the value is not an object with a string id. */ function extractRelatedId(embedded: unknown): string | null { - if (!embedded || typeof embedded !== 'object' || !('id' in embedded)) return null - const id = embedded.id - return typeof id === 'string' ? id : null + const reference = readEmbeddedRelatedReference(embedded) + return reference.kind === 'connected' ? reference.id : null } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 72ea4a97..c90be866 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -598,7 +598,11 @@ export class SnapshotStore implements SnapshotVersionBumper { setPersisting(entityType: string, id: string, isPersisting: boolean, pessimistic: boolean = false): void { const key = this.getEntityKey(entityType, id) + const wasPessimistic = this.meta.isPessimisticInFlight(key) this.meta.setPersisting(key, isPersisting, pessimistic) + if (wasPessimistic !== this.meta.isPessimisticInFlight(key)) { + this.bumpEntitySnapshotVersion(key) + } this.notifyEntitySubscribers(key) } diff --git a/tests/unit/handles/hasOneHandle.test.ts b/tests/unit/handles/hasOneHandle.test.ts index 012e5502..447f62eb 100644 --- a/tests/unit/handles/hasOneHandle.test.ts +++ b/tests/unit/handles/hasOneHandle.test.ts @@ -801,6 +801,34 @@ describe('HasOneHandle', () => { expect(relation?.serverState).toBe('connected') }) + test('a re-fetch with explicit null advances a clean relation to disconnected', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + expect(handle.isDirty).toBe(false) + + store.refreshServerData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: null, + }) + + expect(handle.relatedId).toBeNull() + expect(handle.state).toBe('disconnected') + expect(handle.isDirty).toBe(false) + + const relation = store.getRelation('Article', 'a-1', 'author') + expect(relation?.currentId).toBeNull() + expect(relation?.serverId).toBeNull() + expect(relation?.state).toBe('disconnected') + expect(relation?.serverState).toBe('disconnected') + }) + test('a locally-connected relation survives a re-fetch that changes the embedded id', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', @@ -826,6 +854,60 @@ describe('HasOneHandle', () => { expect(handle.isDirty).toBe(true) }) + test('a locally-connected relation survives an explicit null re-fetch', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + + store.setRelation('Article', 'a-1', 'author', { currentId: 'auth-2', state: 'connected' }) + expect(handle.relatedId).toBe('auth-2') + expect(handle.isDirty).toBe(true) + + store.refreshServerData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: null, + }) + + const relation = store.getRelation('Article', 'a-1', 'author') + expect(handle.relatedId).toBe('auth-2') + expect(handle.state).toBe('connected') + expect(handle.isDirty).toBe(true) + expect(relation?.serverId).toBe('auth-1') + expect(relation?.serverState).toBe('connected') + }) + + test('a locally-disconnected relation survives an explicit null re-fetch', () => { + store.setEntityData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: { id: 'auth-1', name: 'John' }, + }, true) + const handle = createHasOneHandleRaw() + expect(handle.relatedId).toBe('auth-1') + + store.setRelation('Article', 'a-1', 'author', { currentId: null, state: 'disconnected' }) + expect(handle.relatedId).toBeNull() + expect(handle.isDirty).toBe(true) + + store.refreshServerData('Article', 'a-1', { + id: 'a-1', + title: 'Test', + author: null, + }) + + const relation = store.getRelation('Article', 'a-1', 'author') + expect(handle.relatedId).toBeNull() + expect(handle.state).toBe('disconnected') + expect(handle.isDirty).toBe(true) + expect(relation?.serverId).toBe('auth-1') + expect(relation?.serverState).toBe('connected') + }) + test('the baseline advance does not notify subscribers during a render-phase read', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', diff --git a/tests/unit/store/presentationFlag.test.ts b/tests/unit/store/presentationFlag.test.ts index 8a4947bf..5c1a6be8 100644 --- a/tests/unit/store/presentationFlag.test.ts +++ b/tests/unit/store/presentationFlag.test.ts @@ -67,6 +67,27 @@ describe('pessimistic presentation flag', () => { expect(presentedTitle()).toBe('Local edit') }) + test('pessimistic presentation toggles bump the entity snapshot version', () => { + const currentVersion = (): number => + store.getEntitySnapshot('Article', 'a1')?.version ?? -1 + const observedVersions: number[] = [] + const initialVersion = currentVersion() + + store.subscribeToEntity('Article', 'a1', () => { + observedVersions.push(currentVersion()) + }) + + store.setPersisting('Article', 'a1', true, true) + const inFlightVersion = currentVersion() + expect(inFlightVersion).toBeGreaterThan(initialVersion) + expect(observedVersions).toEqual([inFlightVersion]) + + store.setPersisting('Article', 'a1', false) + const restoredVersion = currentVersion() + expect(restoredVersion).toBeGreaterThan(inFlightVersion) + expect(observedVersions).toEqual([inFlightVersion, restoredVersion]) + }) + test('the presented baseline snapshot is frozen and does not alias the stored one', () => { store.setPersisting('Article', 'a1', true, true) const presented = store.getPresentationSnapshot
('Article', 'a1') From 98dfd5aa5342b536706d6435a8a10474531194be Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 24 Jun 2026 13:31:15 +0200 Subject: [PATCH 24/28] refactor(bindx): rebuild undo/redo as a write-journal over the decomposed store Replaces the static-projection snapshot-restore undo (getAffectedKeys computed before execution) with a write-journal keyed by what each gesture actually writes. Fixes three root-cause defects: created entities in a list weren't captured (lost on undo->sweep->redo), root registration wasn't captured (phantom/lost creates), and undo didn't survive a temp->persisted rekey (stale-key corruption). Core: - UndoJournal records each gesture (one dispatch / one handle transaction) as a JournalEntry of editable-layer pre-images, first-writer-wins per cell. - SnapshotStore implements JournalTarget (exportEntityCell/exportRelationCell/ exportHasManyCell + applyJournalImages) and gains beginTransaction/ commitTransaction/transaction(); mutating methods record before writing. - ActionDispatcher.dispatch and the pre-create handle gestures (HasManyListHandle.add, HasOneHandle.create) open transactions so one gesture = one undo unit. - Restore splices only the editable layer onto the LIVE server baseline, so undoing a persisted edit re-dirties against the current baseline. - Persist survival: the journal rekeys stacked entries (keys + embedded ids), seals now-persisted creates, and rebases has-many membership so a persisted child stays in the list under both default and explicit ordering. - UndoManager becomes a thin policy layer (debounce/manual grouping, block during persist, rekey-of-stacks); the createMiddleware() API is preserved. - Removes the dead actionClassification static projection. Tests: 36 green. Original undo.test.ts unchanged, plus undo-stabilization.test.ts (3 characterization bugs + scale + handle gesture) and undo-journal.test.ts (seal incl. the create-across-save falsification, rekey embedded-id, has-many move/disconnect/delete, multi-cell atomic, redo-after-persist, absent-relation restore, edge cases). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PbKRVbhqE2N3uTi9mbaWb6 --- packages/bindx-react/src/index.ts | 1 - packages/bindx/src/core/ActionDispatcher.ts | 122 +++--- .../bindx/src/core/actionClassification.ts | 199 --------- .../bindx/src/handles/HasManyListHandle.ts | 20 +- packages/bindx/src/handles/HasOneHandle.ts | 14 +- packages/bindx/src/index.ts | 3 - packages/bindx/src/store/HasManyStore.ts | 8 + packages/bindx/src/store/HasOneStore.ts | 8 + packages/bindx/src/store/RelationStore.ts | 16 + packages/bindx/src/store/RootRegistry.ts | 4 + packages/bindx/src/store/SnapshotStore.ts | 263 +++++++++++- packages/bindx/src/undo/UndoJournal.ts | 148 +++++++ packages/bindx/src/undo/UndoManager.ts | 389 +++++------------- packages/bindx/src/undo/index.ts | 14 +- packages/bindx/src/undo/rekeyJournalEntry.ts | 145 +++++++ packages/bindx/src/undo/types.ts | 58 --- tests/undo-journal.test.ts | 343 +++++++++++++++ tests/undo-stabilization.test.ts | 193 +++++++++ 18 files changed, 1334 insertions(+), 614 deletions(-) delete mode 100644 packages/bindx/src/core/actionClassification.ts create mode 100644 packages/bindx/src/undo/UndoJournal.ts create mode 100644 packages/bindx/src/undo/rekeyJournalEntry.ts create mode 100644 tests/undo-journal.test.ts create mode 100644 tests/undo-stabilization.test.ts diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 951b8501..59804e1f 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -99,7 +99,6 @@ export type { // Undo types UndoManagerConfig, UndoState, - UndoEntry, // Event types BindxEvent, FieldScopedEvent, diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 67506717..7b8e51df 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -42,39 +42,46 @@ export class ActionDispatcher { * Async interceptors are skipped with a warning — use dispatchAsync() for those. */ dispatch(action: Action): void { - // Run through middlewares first - for (const middleware of this.middlewares) { - const result = middleware(action, this.store) - if (result === false) { - // Middleware can cancel the action - return + // One dispatch = one undoable gesture. The store records every primary write + // made between begin/commit into a single journal entry (no-op without undo). + this.store.beginTransaction() + try { + // Run through middlewares first + for (const middleware of this.middlewares) { + const result = middleware(action, this.store) + if (result === false) { + // Middleware can cancel the action + return + } } - } - // Create and run before event through interceptors (synchronously) - const beforeEvent = createBeforeEvent(action, this.store) - if (beforeEvent) { - const result = this.eventEmitter.runInterceptorsSync(beforeEvent) - if (result === null) { - // Interceptor cancelled the action - return - } - // If event was modified, update action accordingly - if (result !== beforeEvent) { - action = this.updateActionFromEvent(action, result) + // Create and run before event through interceptors (synchronously) + const beforeEvent = createBeforeEvent(action, this.store) + if (beforeEvent) { + const result = this.eventEmitter.runInterceptorsSync(beforeEvent) + if (result === null) { + // Interceptor cancelled the action + return + } + // If event was modified, update action accordingly + if (result !== beforeEvent) { + action = this.updateActionFromEvent(action, result) + } } - } - // Capture state before execution (for after events) - const stateBefore = captureStateBeforeAction(action, this.store) + // Capture state before execution (for after events) + const stateBefore = captureStateBeforeAction(action, this.store) - // Execute the action - this.execute(action) + // Execute the action + this.execute(action) - // Emit after event - const afterEvent = createAfterEvent(action, stateBefore, this.store) - if (afterEvent) { - this.eventEmitter.emit(afterEvent) + // Emit after event + const afterEvent = createAfterEvent(action, stateBefore, this.store) + if (afterEvent) { + this.eventEmitter.emit(afterEvent) + } + } finally { + this.store.commitTransaction() } } @@ -83,41 +90,46 @@ export class ActionDispatcher { * Returns true if the action was executed, false if it was cancelled by an interceptor. */ async dispatchAsync(action: Action): Promise { - // Run through middlewares first - for (const middleware of this.middlewares) { - const result = middleware(action, this.store) - if (result === false) { - return false + this.store.beginTransaction() + try { + // Run through middlewares first + for (const middleware of this.middlewares) { + const result = middleware(action, this.store) + if (result === false) { + return false + } } - } - // Create and run before event through interceptors - const beforeEvent = createBeforeEvent(action, this.store) - if (beforeEvent) { - const result = await this.eventEmitter.runInterceptors(beforeEvent) - if (result === null) { - // Interceptor cancelled the action - return false - } - // If event was modified, update action accordingly - if (result !== beforeEvent) { - action = this.updateActionFromEvent(action, result) + // Create and run before event through interceptors + const beforeEvent = createBeforeEvent(action, this.store) + if (beforeEvent) { + const result = await this.eventEmitter.runInterceptors(beforeEvent) + if (result === null) { + // Interceptor cancelled the action + return false + } + // If event was modified, update action accordingly + if (result !== beforeEvent) { + action = this.updateActionFromEvent(action, result) + } } - } - // Capture state before execution (for after events) - const stateBefore = captureStateBeforeAction(action, this.store) + // Capture state before execution (for after events) + const stateBefore = captureStateBeforeAction(action, this.store) - // Execute the action - this.execute(action) + // Execute the action + this.execute(action) - // Emit after event - const afterEvent = createAfterEvent(action, stateBefore, this.store) - if (afterEvent) { - this.eventEmitter.emit(afterEvent) - } + // Emit after event + const afterEvent = createAfterEvent(action, stateBefore, this.store) + if (afterEvent) { + this.eventEmitter.emit(afterEvent) + } - return true + return true + } finally { + this.store.commitTransaction() + } } /** diff --git a/packages/bindx/src/core/actionClassification.ts b/packages/bindx/src/core/actionClassification.ts deleted file mode 100644 index 7ea06f2f..00000000 --- a/packages/bindx/src/core/actionClassification.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Action } from './actions.js' - -/** - * Keys in the store that are affected by an action. - * Used for partial snapshot capture/restore. - */ -export interface StoreAffectedKeys { - /** Entity keys in format "entityType:id" */ - entityKeys: string[] - /** Relation keys in format "entityType:id:fieldName" */ - relationKeys: string[] - /** Has-many keys in format "entityType:id:fieldName" */ - hasManyKeys: string[] -} - -/** - * Action types that should be tracked for undo/redo. - * These are user-initiated data mutations. - */ -const TRACKABLE_ACTION_TYPES = new Set([ - 'SET_FIELD', - 'SET_ENTITY_DATA', - 'SET_PLACEHOLDER_DATA', - 'CONNECT_RELATION', - 'DISCONNECT_RELATION', - 'DELETE_RELATION', - 'RESET_ENTITY', - 'RESET_RELATION', - 'CONNECT_TO_LIST', - 'ADD_TO_LIST', - 'REMOVE_FROM_LIST', - 'MOVE_IN_LIST', -]) - -/** - * Action types that should be skipped for undo/redo. - * These are operational, commit, or error actions. - */ -const SKIP_ACTION_TYPES = new Set([ - 'SET_LOAD_STATE', - 'SET_PERSISTING', - 'COMMIT_ENTITY', - 'COMMIT_RELATION', - // Error actions are skipped as errors are ephemeral state - 'ADD_FIELD_ERROR', - 'CLEAR_FIELD_ERRORS', - 'ADD_ENTITY_ERROR', - 'CLEAR_ENTITY_ERRORS', - 'ADD_RELATION_ERROR', - 'CLEAR_RELATION_ERRORS', - 'CLEAR_ALL_SERVER_ERRORS', - 'CLEAR_ALL_ERRORS', -]) - -/** - * Checks if an action should be tracked for undo/redo. - * Server data loads (SET_ENTITY_DATA with isServerData=true) are not tracked. - */ -export function isTrackableAction(action: Action): boolean { - if (!TRACKABLE_ACTION_TYPES.has(action.type)) { - return false - } - // Server data loads are not user-initiated mutations - if (action.type === 'SET_ENTITY_DATA' && action.isServerData) { - return false - } - return true -} - -/** - * Checks if an action should be skipped for undo/redo. - */ -export function isSkippedAction(action: Action): boolean { - return SKIP_ACTION_TYPES.has(action.type) -} - -/** - * Creates an entity key from type and id. - */ -export function createEntityKey(entityType: string, entityId: string): string { - return `${entityType}:${entityId}` -} - -/** - * Creates a relation key from parent type, id and field name. - */ -export function createRelationKey( - entityType: string, - entityId: string, - fieldName: string, -): string { - return `${entityType}:${entityId}:${fieldName}` -} - -/** - * Extracts the store keys affected by an action. - * Returns entity keys, relation keys, and has-many keys. - */ -export function getAffectedKeys(action: Action): StoreAffectedKeys { - const entityKeys: string[] = [] - const relationKeys: string[] = [] - const hasManyKeys: string[] = [] - - switch (action.type) { - case 'SET_FIELD': - case 'SET_ENTITY_DATA': - case 'REFRESH_SERVER_DATA': - case 'RESET_ENTITY': - case 'COMMIT_ENTITY': - entityKeys.push(createEntityKey(action.entityType, action.entityId)) - break - - case 'CONNECT_RELATION': - case 'DISCONNECT_RELATION': - case 'DELETE_RELATION': - case 'RESET_RELATION': - case 'COMMIT_RELATION': - case 'SET_PLACEHOLDER_DATA': - relationKeys.push( - createRelationKey(action.entityType, action.entityId, action.fieldName), - ) - break - - case 'CONNECT_TO_LIST': - case 'ADD_TO_LIST': - case 'REMOVE_FROM_LIST': - case 'MOVE_IN_LIST': - hasManyKeys.push( - createRelationKey(action.entityType, action.entityId, action.fieldName), - ) - break - - case 'SET_LOAD_STATE': - case 'SET_PERSISTING': - case 'ADD_FIELD_ERROR': - case 'CLEAR_FIELD_ERRORS': - case 'ADD_ENTITY_ERROR': - case 'CLEAR_ENTITY_ERRORS': - case 'ADD_RELATION_ERROR': - case 'CLEAR_RELATION_ERRORS': - case 'CLEAR_ALL_SERVER_ERRORS': - case 'CLEAR_ALL_ERRORS': - // These don't affect undo-able state - break - - default: { - const _exhaustive: never = action - throw new Error(`Unknown action type: ${(_exhaustive as Action).type}`) - } - } - - return { entityKeys, relationKeys, hasManyKeys } -} - -/** - * Merges two StoreAffectedKeys objects, deduplicating keys. - */ -export function mergeAffectedKeys( - target: StoreAffectedKeys, - source: StoreAffectedKeys, -): void { - for (const key of source.entityKeys) { - if (!target.entityKeys.includes(key)) { - target.entityKeys.push(key) - } - } - for (const key of source.relationKeys) { - if (!target.relationKeys.includes(key)) { - target.relationKeys.push(key) - } - } - for (const key of source.hasManyKeys) { - if (!target.hasManyKeys.includes(key)) { - target.hasManyKeys.push(key) - } - } -} - -/** - * Creates an empty StoreAffectedKeys object. - */ -export function createEmptyAffectedKeys(): StoreAffectedKeys { - return { - entityKeys: [], - relationKeys: [], - hasManyKeys: [], - } -} - -/** - * Checks if StoreAffectedKeys is empty. - */ -export function isEmptyAffectedKeys(keys: StoreAffectedKeys): boolean { - return ( - keys.entityKeys.length === 0 && - keys.relationKeys.length === 0 && - keys.hasManyKeys.length === 0 - ) -} diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index ae03a7fc..483ff73c 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -377,15 +377,19 @@ export class HasManyListHandle): string { this.assertNotDisposed() - // Pre-create entity so we can return tempId - const tempId = this.store.createEntity(this.itemType, data as Record) - - // Add to has-many relation through dispatcher (enables events/interceptors) - this.dispatcher.dispatch( - addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias), - ) + // One gesture = one undo entry: the pre-create and the list-add are journaled + // together so undo removes (and redo re-creates) the whole row. + return this.store.transaction(() => { + // Pre-create entity so we can return tempId + const tempId = this.store.createEntity(this.itemType, data as Record) + + // Add to has-many relation through dispatcher (enables events/interceptors) + this.dispatcher.dispatch( + addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias), + ) - return tempId + return tempId + }) } /** diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index b7a87412..4c727ea7 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -464,11 +464,15 @@ export class HasOneHandle */ create(data?: Partial): string { this.assertNotDisposed() - const tempId = this.store.createEntity(this.targetType, data as Record) - this.dispatcher.dispatch( - connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType), - ) - return tempId + // One gesture = one undo entry: the pre-create and the connect are journaled + // together so undo removes (and redo re-creates) the created target. + return this.store.transaction(() => { + const tempId = this.store.createEntity(this.targetType, data as Record) + this.dispatcher.dispatch( + connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType), + ) + return tempId + }) } /** diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 304bacd9..81c62734 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -241,9 +241,6 @@ export { UndoManager } from './undo/index.js' export type { UndoManagerConfig, UndoState, - UndoEntry, - PartialStoreSnapshot, - StoreAffectedKeys, } from './undo/index.js' // Store types (for undo) diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index aea0b369..80c07bd3 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -455,6 +455,14 @@ export class HasManyStore { this.edges.collectParents(childId, parents) } + /** + * Removes a single has-many list state (and its live edges). Used by undo + * restore to drop a list that did not exist before the gesture. + */ + removeHasMany(key: string): void { + this.deleteHasMany(key) + } + /** * Removes all has-many state owned by an entity (keys under the given owner * prefix), dropping each entry's edges through {@link deleteHasMany}. diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index 574cab11..0d20f646 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -181,6 +181,14 @@ export class HasOneStore { this.edges.collectParents(childId, parents) } + /** + * Removes a single has-one relation state (and its live edge). Used by undo + * restore to drop a relation that did not exist before the gesture. + */ + removeRelation(key: string): void { + this.deleteRelation(key) + } + /** * Removes all has-one state owned by an entity (keys under the given owner * prefix), dropping each entry's edge through {@link deleteRelation}. diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index de56d454..d16ad9f1 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -156,6 +156,22 @@ export class RelationStore implements Rekeyable { // ==================== Bulk Operations ==================== + /** + * Removes a single has-one relation state by key (undo restore of an + * absent-before-the-gesture relation). + */ + removeRelationState(key: string): void { + this.hasOne.removeRelation(key) + } + + /** + * Removes a single has-many list state by key (undo restore of an + * absent-before-the-gesture list). + */ + removeHasManyState(key: string): void { + this.hasMany.removeHasMany(key) + } + /** * Removes all relation and has-many state owned by an entity (keys under the * given owner prefix). Called by removeEntity so a removed entity leaves no diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts index 5aee96ed..e6790592 100644 --- a/packages/bindx/src/store/RootRegistry.ts +++ b/packages/bindx/src/store/RootRegistry.ts @@ -44,6 +44,10 @@ export class RootRegistry implements Rekeyable { return this.roots.keys() } + has(key: string): boolean { + return this.roots.has(key) + } + /** * Moves a root entry from the temp key to the persisted key (used after * persist rekeys a temp id to a server-assigned id). diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index c90be866..fc407894 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -17,6 +17,15 @@ import { EntitySnapshotStore } from './EntitySnapshotStore.js' import { RootRegistry } from './RootRegistry.js' import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' import { RekeyOrchestrator } from './RekeyOrchestrator.js' +import type { RekeyContext } from './RekeyOrchestrator.js' +import type { + UndoJournal, + JournalTarget, + JournalCellImage, + EntityCellImage, + RelationCellImage, + HasManyCellImage, +} from '../undo/UndoJournal.js' export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' export type { EntityMeta } from './EntityMetaStore.js' @@ -41,7 +50,7 @@ type Subscriber = () => void * - EntityMetaStore — entity metadata, load state, persisting, temp ID mapping * - TouchedStore — field touched state tracking */ -export class SnapshotStore implements SnapshotVersionBumper { +export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { private readonly entitySnapshots = new EntitySnapshotStore() private readonly subscriptions = new SubscriptionManager() private readonly errors = new ErrorStore() @@ -61,6 +70,13 @@ export class SnapshotStore implements SnapshotVersionBumper { */ private readonly lastPropagatedData = new Map() + /** + * Optional write-journal. When set (by an attached UndoManager), mutating + * methods record editable-layer pre-images of the cells they touch so a gesture + * can be undone. Null ⇒ no undo tracking and the transaction helpers are no-ops. + */ + private journal: UndoJournal | null = null + constructor() { this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots) this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability) @@ -83,6 +99,42 @@ export class SnapshotStore implements SnapshotVersionBumper { ]) } + // ==================== Undo Journal ==================== + + /** + * Attaches (or detaches) the write-journal. Called by UndoManager when it is + * wired to a dispatcher. + */ + setJournal(journal: UndoJournal | null): void { + this.journal = journal + } + + /** + * Opens a journal transaction (re-entrant). All primary-store writes until the + * matching {@link commitTransaction} are captured as ONE undoable gesture. + */ + beginTransaction(): void { + this.journal?.begin() + } + + commitTransaction(): void { + this.journal?.commit() + } + + /** + * Runs a gesture as a single undoable transaction. Used by handle operations + * (e.g. list add / has-one create) that write to the store outside the + * dispatcher, so their pre-create writes join the same undo entry. + */ + transaction(fn: () => T): T { + this.beginTransaction() + try { + return fn() + } finally { + this.commitTransaction() + } + } + // ==================== Key Generation ==================== private getEntityKey(entityType: string, id: string): string { @@ -184,6 +236,9 @@ export class SnapshotStore implements SnapshotVersionBumper { skipNotify: boolean = false, ): EntitySnapshot { const key = this.getEntityKey(entityType, id) + // Server loads are not user gestures; only journal local data sets (incl. the + // initial write of a freshly created entity, which captures an absent pre-image). + if (!isServerData) this.journal?.recordEntity(key) const newSnapshot = this.entitySnapshots.setData(key, id, entityType, data, isServerData) if (isServerData) { @@ -222,6 +277,7 @@ export class SnapshotStore implements SnapshotVersionBumper { updates: Partial, ): EntitySnapshot | undefined { const key = this.getEntityKey(entityType, id) + this.journal?.recordEntity(key) const result = this.entitySnapshots.updateFields(key, updates) if (result) { this.notifyEntitySubscribers(key) @@ -236,6 +292,7 @@ export class SnapshotStore implements SnapshotVersionBumper { value: unknown, ): void { const key = this.getEntityKey(entityType, id) + this.journal?.recordEntity(key) if (this.entitySnapshots.setFieldValue(key, fieldPath, value)) { this.notifyEntitySubscribers(key) } @@ -249,6 +306,7 @@ export class SnapshotStore implements SnapshotVersionBumper { resetEntity(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.journal?.recordEntity(key) this.entitySnapshots.reset(key) this.notifyEntitySubscribers(key) } @@ -327,12 +385,14 @@ export class SnapshotStore implements SnapshotVersionBumper { scheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.journal?.recordEntity(key) this.meta.scheduleForDeletion(key) this.notifyEntitySubscribers(key) } unscheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.journal?.recordEntity(key) this.meta.unscheduleForDeletion(key) this.notifyEntitySubscribers(key) } @@ -381,6 +441,18 @@ export class SnapshotStore implements SnapshotVersionBumper { // fan-out across every sub-store in its documented order. this.rekeyOrchestrator.rekey(entityType, tempId, persistedId) + // Keep the undo journal aligned: rewrite stored keys + embedded id references + // in every entry, and seal the now-persisted create so undo can't delete it. + const ctx: RekeyContext = { + oldKey: `${entityType}:${tempId}`, + newKey: `${entityType}:${persistedId}`, + oldKeyPrefix: `${entityType}:${tempId}:`, + newKeyPrefix: `${entityType}:${persistedId}:`, + oldId: tempId, + newId: persistedId, + } + this.journal?.rekey(ctx) + // Notify on the NEW key so React picks up the change. this.notifyEntitySubscribers(`${entityType}:${persistedId}`) } @@ -437,6 +509,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) this.relations.planHasManyRemoval(key, itemId, type) this.notifyRelationSubscribers(key) } @@ -459,6 +532,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) this.relations.planHasManyConnection(key, itemId) this.notifyRelationSubscribers(key) } @@ -494,6 +568,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) this.relations.resetHasMany(key) this.notifyRelationSubscribers(key) } @@ -506,6 +581,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) this.relations.addToHasMany(key, itemId) this.notifyRelationSubscribers(key) } @@ -517,6 +593,7 @@ export class SnapshotStore implements SnapshotVersionBumper { itemId: string, ): void { const key = this.getRelationKey(parentType, parentId, fieldName) + this.journal?.recordHasMany(key) this.relations.connectExistingToHasMany(key, itemId) this.notifyRelationSubscribers(key) } @@ -530,6 +607,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) // Cancelling the add of a never-persisted child just removes it from the // list; its now-unreachable snapshot is no longer reported as a `create` and // is collected by the lazy memory sweep. @@ -547,6 +625,7 @@ export class SnapshotStore implements SnapshotVersionBumper { alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + this.journal?.recordHasMany(key) this.relations.moveInHasMany(key, fromIndex, toIndex) this.notifyRelationSubscribers(key) } @@ -781,6 +860,7 @@ export class SnapshotStore implements SnapshotVersionBumper { skipNotify: boolean = false, ): void { const key = this.getRelationKey(parentType, parentId, fieldName) + this.journal?.recordRelation(key) const entityKey = this.getEntityKey(parentType, parentId) const entitySnapshot = this.entitySnapshots.get(entityKey) this.relations.setRelation(key, updates, entitySnapshot, fieldName) @@ -800,6 +880,7 @@ export class SnapshotStore implements SnapshotVersionBumper { resetRelation(parentType: string, parentId: string, fieldName: string): void { const key = this.getRelationKey(parentType, parentId, fieldName) + this.journal?.recordRelation(key) this.relations.resetRelation(key) this.notifyRelationSubscribers(key) } @@ -858,6 +939,9 @@ export class SnapshotStore implements SnapshotVersionBumper { */ registerParentChild(parentType: string, parentId: string, childType: string, childId: string): void { const childKey = this.getEntityKey(childType, childId) + // Capture the child's root membership before it is dropped, so undoing the + // connect/add that anchored it restores it as a reachable create. + this.journal?.recordEntity(childKey) this.roots.unregister(childKey) } @@ -906,6 +990,164 @@ export class SnapshotStore implements SnapshotVersionBumper { } } + // ==================== Undo Journal Target (cell capture / restore) ==================== + + /** + * Captures the editable-layer pre-image of an entity cell: the current snapshot + * (data + baseline), plus the meta and root membership needed to restore or + * re-create it. `present: false` means the entity did not exist — undo removes it. + */ + exportEntityCell(key: string): EntityCellImage { + const snapshot = this.entitySnapshots.get(key) + if (!snapshot) { + return { kind: 'entity', key, present: false } + } + return { + kind: 'entity', + key, + present: true, + snapshot, + existsOnServer: this.meta.existsOnServer(key), + isScheduledForDeletion: this.meta.isScheduledForDeletion(key), + isRoot: this.roots.has(key), + } + } + + exportRelationCell(key: string): RelationCellImage { + const state = this.relations.getRelation(key) + if (!state) return { kind: 'relation', key, present: false } + return { + kind: 'relation', + key, + present: true, + state: { ...state, placeholderData: { ...state.placeholderData } }, + } + } + + /** + * Live server-member ids of a has-many list by its raw relation key. Used by the + * journal's rekey to rebase a pre-image when a just-persisted create became a + * permanent member of the list (membership rebase for sealed creates). + */ + getLiveHasManyServerIds(relationKey: string): Set { + const state = this.relations.getHasMany(relationKey) + return new Set(state?.serverIds ?? []) + } + + exportHasManyCell(key: string): HasManyCellImage { + const state = this.relations.getHasMany(key) + if (!state) return { kind: 'hasMany', key, present: false } + return { + kind: 'hasMany', + key, + present: true, + state: { + serverIds: new Set(state.serverIds), + orderedIds: state.orderedIds ? [...state.orderedIds] : null, + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions: new Map(state.plannedAdditions), + version: state.version, + }, + } + } + + /** + * Restores a set of cell pre-images through the write paths (so the edge index + * reconciles and the reachability cache invalidates). Order matters: present + * entities are restored/recreated first so relations can reference live targets, + * then present relations, then un-creates, then dropped relations. + */ + applyJournalImages(images: JournalCellImage[]): void { + const notifyEntities = new Set() + const notifyRelations = new Set() + + const presentEntities: EntityCellImage[] = [] + const presentRelations: Array = [] + const absentEntities: EntityCellImage[] = [] + const absentRelations: Array = [] + + for (const img of images) { + if (img.kind === 'entity') { + notifyEntities.add(img.key) + ;(img.present ? presentEntities : absentEntities).push(img) + } else { + notifyRelations.add(img.key) + notifyEntities.add(entityKeyOfRelationKey(img.key)) + ;(img.present ? presentRelations : absentRelations).push(img) + } + } + + for (const img of presentEntities) this.applyEntityImage(img) + for (const img of presentRelations) this.applyRelationImage(img) + for (const img of absentEntities) { + const [type, id] = splitEntityKey(img.key) + this.removeEntity(type, id) + } + for (const img of absentRelations) { + if (img.kind === 'relation') this.relations.removeRelationState(img.key) + else this.relations.removeHasManyState(img.key) + } + + this.subscriptions.notifyGlobal() + for (const key of notifyEntities) this.subscriptions.notifyEntityDirect(key) + for (const key of notifyRelations) this.subscriptions.notifyRelationDirect(key) + } + + private applyEntityImage(img: EntityCellImage): void { + const snapshot = img.snapshot! + const live = this.entitySnapshots.get(img.key) + if (live) { + // Splice the editable layer (data) onto the LIVE server baseline; keep the + // live id so an undo after persist re-dirties against the current baseline. + const spliced = createEntitySnapshot( + live.id, + live.entityType, + { ...(snapshot.data as Record), id: live.id }, + live.serverData, + live.version + 1, + ) + this.entitySnapshots.importSnapshots(new Map([[img.key, spliced]])) + this.meta.importMetas(new Map([[img.key, { + existsOnServer: this.meta.existsOnServer(img.key), + isScheduledForDeletion: img.isScheduledForDeletion ?? false, + }]])) + } else { + // Entity is gone (swept / un-created): recreate it from the captured snapshot. + this.entitySnapshots.importSnapshots(new Map([[img.key, snapshot]])) + this.meta.importMetas(new Map([[img.key, { + existsOnServer: img.existsOnServer ?? false, + isScheduledForDeletion: img.isScheduledForDeletion ?? false, + }]])) + } + if (img.isRoot) this.roots.register(img.key) + else this.roots.unregister(img.key) + } + + private applyRelationImage(img: RelationCellImage | HasManyCellImage): void { + if (img.kind === 'relation') { + const s = img.state! + const live = this.relations.getRelation(img.key) + this.relations.importRelationStates(new Map([[img.key, { + currentId: s.currentId, + state: s.state, + placeholderData: { ...s.placeholderData }, + serverId: live ? live.serverId : s.serverId, + serverState: live ? live.serverState : s.serverState, + version: (live?.version ?? s.version) + 1, + }]])) + } else { + const s = img.state! + const live = this.relations.getHasMany(img.key) + this.relations.importHasManyStates(new Map([[img.key, { + serverIds: new Set(live ? live.serverIds : s.serverIds), + orderedIds: s.orderedIds ? [...s.orderedIds] : null, + plannedRemovals: new Map(s.plannedRemovals), + plannedAdditions: new Map(s.plannedAdditions), + version: (live?.version ?? s.version) + 1, + }]])) + } + } + // ==================== Dirty Tracking (delegated to DirtyTracker) ==================== getAllDirtyEntities(): Array<{ @@ -965,3 +1207,22 @@ export class SnapshotStore implements SnapshotVersionBumper { this.subscriptions.notify() } } + +/** + * Splits an entity key "entityType:id" into its parts. Entity ids never contain + * ':' (store-wide invariant), so the first ':' is the type/id boundary. + */ +function splitEntityKey(key: string): [string, string] { + const i = key.indexOf(':') + return [key.slice(0, i), key.slice(i + 1)] +} + +/** + * Derives the owning entity key "entityType:id" from a relation/has-many key + * "entityType:id:fieldName". + */ +function entityKeyOfRelationKey(key: string): string { + const first = key.indexOf(':') + const second = key.indexOf(':', first + 1) + return second === -1 ? key : key.slice(0, second) +} diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts new file mode 100644 index 00000000..8118d50c --- /dev/null +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -0,0 +1,148 @@ +import type { EntitySnapshot } from '../store/snapshots.js' +import type { StoredRelationState, StoredHasManyState } from '../store/RelationStore.js' +import type { RekeyContext } from '../store/RekeyOrchestrator.js' + +/** + * Write-journal for undo/redo. + * + * A gesture (one dispatch, or one handle operation) opens a transaction; every + * primary-store write made inside it records the EDITABLE-LAYER pre-image of the + * cell it touches (first-writer-wins per cell). On commit, the accumulated cells + * become one {@link JournalEntry} — exactly the keys the gesture actually wrote, + * each with the state to restore. Undo restores those pre-images through the + * store's write paths (so the edge index and reachability cache rebuild + * themselves); derived state is never journaled. + * + * The journal captures only the editable layer (and, for re-creates, the full + * snapshot). The server baseline (serverData / serverId / serverState / serverIds + * / existsOnServer) is spliced back from the LIVE state on restore, so undoing an + * already-persisted edit re-dirties it against the current server view instead of + * resurrecting a stale baseline. + */ + +/** A captured cell, keyed by kind + composite store key. */ +export interface EntityCellImage { + readonly kind: 'entity' + readonly key: string + /** Whether the entity existed at capture time. false ⇒ undo removes it (un-create). */ + readonly present: boolean + readonly snapshot?: EntitySnapshot + readonly existsOnServer?: boolean + readonly isScheduledForDeletion?: boolean + readonly isRoot?: boolean +} + +export interface RelationCellImage { + readonly kind: 'relation' + readonly key: string + readonly present: boolean + readonly state?: StoredRelationState +} + +export interface HasManyCellImage { + readonly kind: 'hasMany' + readonly key: string + readonly present: boolean + readonly state?: StoredHasManyState +} + +export type JournalCellImage = EntityCellImage | RelationCellImage | HasManyCellImage + +/** One undoable unit: the editable-layer pre-images of every cell a gesture touched. */ +export interface JournalEntry { + readonly cells: JournalCellImage[] +} + +/** + * The store side of the journal: produces editable-layer pre-images for a cell and + * applies a set of pre-images back (through the write paths). Implemented by + * SnapshotStore, which alone knows the sub-stores. + */ +export interface JournalTarget { + exportEntityCell(key: string): EntityCellImage + exportRelationCell(key: string): RelationCellImage + exportHasManyCell(key: string): HasManyCellImage + applyJournalImages(images: JournalCellImage[]): void +} + +function cellRefKey(image: JournalCellImage): string { + return `${image.kind}:${image.key}` +} + +export class UndoJournal { + private depth = 0 + /** Cells recorded in the currently-open transaction, deduped first-writer-wins. */ + private active: Map | null = null + + constructor( + private readonly target: JournalTarget, + private readonly onCommit: (entry: JournalEntry) => void, + private readonly onRekey?: (ctx: RekeyContext) => void, + ) {} + + get isRecording(): boolean { + return this.active !== null + } + + begin(): void { + if (this.depth === 0) { + this.active = new Map() + } + this.depth++ + } + + commit(): void { + if (this.depth === 0) return + this.depth-- + if (this.depth > 0) return + const active = this.active + this.active = null + if (active && active.size > 0) { + this.onCommit({ cells: [...active.values()] }) + } + } + + /** Records the pre-image of an entity cell (snapshot + meta + root membership). */ + recordEntity(key: string): void { + this.record(`entity:${key}`, () => this.target.exportEntityCell(key)) + } + + recordRelation(key: string): void { + this.record(`relation:${key}`, () => this.target.exportRelationCell(key)) + } + + recordHasMany(key: string): void { + this.record(`hasMany:${key}`, () => this.target.exportHasManyCell(key)) + } + + private record(refKey: string, make: () => JournalCellImage): void { + if (!this.active) return // no transaction open ⇒ not part of any gesture + if (this.active.has(refKey)) return // first-writer-wins: keep the pre-gesture state + this.active.set(refKey, make()) + } + + /** Captures the CURRENT image of each given cell — used to build the inverse entry. */ + captureCurrent(cells: JournalCellImage[]): JournalCellImage[] { + return cells.map(cell => { + switch (cell.kind) { + case 'entity': + return this.target.exportEntityCell(cell.key) + case 'relation': + return this.target.exportRelationCell(cell.key) + case 'hasMany': + return this.target.exportHasManyCell(cell.key) + } + }) + } + + apply(cells: JournalCellImage[]): void { + this.target.applyJournalImages(cells) + } + + /** Forwarded from the store when a temp id is rekeyed; lets the owner rewrite stacks. */ + rekey(ctx: RekeyContext): void { + this.onRekey?.(ctx) + } +} + +export { cellRefKey } diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index b74df923..40868ff8 100644 --- a/packages/bindx/src/undo/UndoManager.ts +++ b/packages/bindx/src/undo/UndoManager.ts @@ -1,48 +1,41 @@ import type { ActionMiddleware } from '../core/ActionDispatcher.js' -import type { Action } from '../core/actions.js' -import { - isTrackableAction, - getAffectedKeys, - mergeAffectedKeys, - createEmptyAffectedKeys, - type StoreAffectedKeys, -} from '../core/actionClassification.js' import type { SnapshotStore } from '../store/SnapshotStore.js' -import type { - PartialStoreSnapshot, - PendingUndoEntry, - UndoEntry, - UndoManagerConfig, - UndoState, -} from './types.js' +import type { RekeyContext } from '../store/RekeyOrchestrator.js' +import { UndoJournal, cellRefKey, type JournalEntry, type JournalCellImage } from './UndoJournal.js' +import { rekeyJournalEntry } from './rekeyJournalEntry.js' +import type { UndoManagerConfig, UndoState } from './types.js' type Subscriber = () => void /** - * Generates a unique ID for undo entries. + * Generates a unique id for manual group handles. */ function generateId(): string { return `undo-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } /** - * UndoManager provides undo/redo functionality for the Bindx store. + * UndoManager — the policy layer over the {@link UndoJournal}. * - * Features: - * - Automatic debounced grouping of rapid changes - * - Manual grouping via beginGroup/endGroup - * - Configurable history size - * - Block/unblock during persist operations - * - React integration via subscribe/getState + * The journal records each gesture (one dispatch / one handle transaction) as a + * {@link JournalEntry} of editable-layer pre-images. UndoManager owns the undo / + * redo stacks and the grouping policy (debounced auto-grouping + manual + * begin/endGroup), blocks during persist, and keeps the stacks aligned across a + * temp→persisted rekey. Restore goes straight through the store's write paths, so + * the edge index and reachability cache rebuild themselves; the event/interceptor + * pipeline is NOT replayed. */ export class UndoManager { - private undoStack: UndoEntry[] = [] - private redoStack: UndoEntry[] = [] - private pendingEntry: PendingUndoEntry | null = null + private readonly journal: UndoJournal + private undoStack: JournalEntry[] = [] + private redoStack: JournalEntry[] = [] + + /** Cells accumulated while a debounce window or manual group is open (first-writer-wins). */ + private pending: Map | null = null private debounceTimer: ReturnType | null = null - private isBlocked = false private manualGroupId: string | null = null - private manualGroupLabel: string | undefined = undefined + + private isBlocked = false private subscribers = new Set() private cachedState: UndoState | null = null @@ -55,333 +48,181 @@ export class UndoManager { ) { this.maxHistorySize = config.maxHistorySize ?? 100 this.debounceMs = config.debounceMs ?? 300 + this.journal = new UndoJournal( + store, + entry => this.onEntry(entry), + ctx => this.rekeyStacks(ctx), + ) } /** - * Creates middleware for ActionDispatcher. - * Must be added to dispatcher to enable undo tracking. + * Wires the journal into the store so its write paths record gestures. The + * returned middleware is a backward-compatible pass-through (recording is native + * to the store transaction, not the middleware). */ createMiddleware(): ActionMiddleware { - return (action: Action) => { - this.handleAction(action) - // Always allow action to proceed - return true - } + this.store.setJournal(this.journal) + return () => true } - /** - * Handles an action, capturing state for undo if trackable. - */ - private handleAction(action: Action): void { - // Skip if blocked or not trackable - if (this.isBlocked || !isTrackableAction(action)) { - return - } + // ==================== Recording (journal commit sink) ==================== - const keys = getAffectedKeys(action) + private onEntry(entry: JournalEntry): void { + if (this.isBlocked || entry.cells.length === 0) return - // Clear redo stack on new action + // A fresh user action invalidates the redo stack. if (this.redoStack.length > 0) { this.redoStack = [] - this.notifySubscribers() } if (this.manualGroupId !== null) { - // Manual grouping mode - this.handleManualGroupAction(keys) - } else { - // Auto-debounce mode - this.handleDebouncedAction(keys) - } - } - - /** - * Handles action in manual group mode. - */ - private handleManualGroupAction(keys: StoreAffectedKeys): void { - if (!this.pendingEntry) { - // First action in group - capture snapshot - const snapshot = this.store.exportPartialSnapshot(keys) - this.pendingEntry = { - beforeSnapshot: snapshot, - affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] }, - timestamp: Date.now(), - label: this.manualGroupLabel, - } - } else { - // Merge keys and expand snapshot if needed - const newKeys = this.getNewKeys(this.pendingEntry.affectedKeys, keys) - if (!this.isEmptyKeys(newKeys)) { - // Capture additional state for new keys - const additionalSnapshot = this.store.exportPartialSnapshot(newKeys) - this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot) - mergeAffectedKeys(this.pendingEntry.affectedKeys, keys) - } - } - } - - /** - * Handles action in debounced auto-group mode. - */ - private handleDebouncedAction(keys: StoreAffectedKeys): void { - // If debounceMs is 0, immediately create individual entries (no grouping) - if (this.debounceMs === 0) { - const snapshot = this.store.exportPartialSnapshot(keys) - this.undoStack.push({ - id: generateId(), - beforeSnapshot: snapshot, - affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] }, - timestamp: Date.now(), - }) - this.trimHistory() + this.mergeIntoPending(entry) this.notifySubscribers() return } - if (this.pendingEntry) { - // Extend existing pending entry - const newKeys = this.getNewKeys(this.pendingEntry.affectedKeys, keys) - if (!this.isEmptyKeys(newKeys)) { - const additionalSnapshot = this.store.exportPartialSnapshot(newKeys) - this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot) - mergeAffectedKeys(this.pendingEntry.affectedKeys, keys) - } - this.resetDebounceTimer() - } else { - // First action - capture snapshot and start timer - const snapshot = this.store.exportPartialSnapshot(keys) - this.pendingEntry = { - beforeSnapshot: snapshot, - affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] }, - timestamp: Date.now(), - } - this.startDebounceTimer() - this.notifySubscribers() - } - } - - /** - * Gets keys that are in source but not in target. - */ - private getNewKeys(target: StoreAffectedKeys, source: StoreAffectedKeys): StoreAffectedKeys { - return { - entityKeys: source.entityKeys.filter(k => !target.entityKeys.includes(k)), - relationKeys: source.relationKeys.filter(k => !target.relationKeys.includes(k)), - hasManyKeys: source.hasManyKeys.filter(k => !target.hasManyKeys.includes(k)), + if (this.debounceMs === 0) { + this.pushEntry(entry) + return } - } - /** - * Checks if keys object is empty. - */ - private isEmptyKeys(keys: StoreAffectedKeys): boolean { - return keys.entityKeys.length === 0 && keys.relationKeys.length === 0 && keys.hasManyKeys.length === 0 + this.mergeIntoPending(entry) + this.resetDebounceTimer() + this.notifySubscribers() } - /** - * Merges additional snapshot into target. - */ - private mergeSnapshots(target: PartialStoreSnapshot, source: PartialStoreSnapshot): void { - for (const [key, value] of source.entitySnapshots) { - if (!target.entitySnapshots.has(key)) { - target.entitySnapshots.set(key, value) - } + private mergeIntoPending(entry: JournalEntry): void { + if (!this.pending) { + this.pending = new Map() } - for (const [key, value] of source.relationStates) { - if (!target.relationStates.has(key)) { - target.relationStates.set(key, value) + for (const cell of entry.cells) { + const key = cellRefKey(cell) + // First-writer-wins: keep the state from before the group began. + if (!this.pending.has(key)) { + this.pending.set(key, cell) } } - for (const [key, value] of source.hasManyStates) { - if (!target.hasManyStates.has(key)) { - target.hasManyStates.set(key, value) - } - } - for (const [key, value] of source.entityMetas) { - if (!target.entityMetas.has(key)) { - target.entityMetas.set(key, value) - } - } - } - - /** - * Starts the debounce timer. - */ - private startDebounceTimer(): void { - this.debounceTimer = setTimeout(() => { - this.flushPendingEntry() - }, this.debounceMs) } - /** - * Resets the debounce timer. - */ - private resetDebounceTimer(): void { + private flushPending(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + const pending = this.pending + this.pending = null + if (pending && pending.size > 0) { + this.pushEntry({ cells: [...pending.values()] }) } - this.startDebounceTimer() } - /** - * Flushes pending entry to undo stack. - */ - private flushPendingEntry(): void { - if (!this.pendingEntry) return - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - this.debounceTimer = null + private pushEntry(entry: JournalEntry): void { + this.undoStack.push(entry) + while (this.undoStack.length > this.maxHistorySize) { + this.undoStack.shift() } - - this.undoStack.push({ - id: generateId(), - label: this.pendingEntry.label, - beforeSnapshot: this.pendingEntry.beforeSnapshot, - affectedKeys: this.pendingEntry.affectedKeys, - timestamp: this.pendingEntry.timestamp, - }) - - this.pendingEntry = null - this.trimHistory() this.notifySubscribers() } - /** - * Trims history to max size. - */ - private trimHistory(): void { - while (this.undoStack.length > this.maxHistorySize) { - this.undoStack.shift() + private resetDebounceTimer(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) } + this.debounceTimer = setTimeout(() => this.flushPending(), this.debounceMs) } + // ==================== Grouping ==================== + /** - * Starts a manual group. All actions until endGroup are grouped as one undo entry. - * Returns group ID that must be passed to endGroup. + * Starts a manual group: every gesture until {@link endGroup} folds into one + * undo entry. Returns a handle that must be passed back to endGroup. */ - beginGroup(label?: string): string { - // Flush any pending debounced entry first - this.flushPendingEntry() - - const groupId = generateId() - this.manualGroupId = groupId - this.manualGroupLabel = label - return groupId + beginGroup(_label?: string): string { + this.flushPending() + const id = generateId() + this.manualGroupId = id + return id } - /** - * Ends a manual group and pushes to undo stack. - */ endGroup(groupId: string): void { if (this.manualGroupId !== groupId) { console.warn(`UndoManager: endGroup called with wrong groupId. Expected ${this.manualGroupId}, got ${groupId}`) return } - this.manualGroupId = null - this.manualGroupLabel = undefined - this.flushPendingEntry() + this.flushPending() } - /** - * Undoes the last entry. - */ - undo(): void { - // Flush any pending entry first - this.flushPendingEntry() + // ==================== Undo / Redo ==================== - if (this.isBlocked || this.undoStack.length === 0) { - return - } + undo(): void { + this.flushPending() + if (this.isBlocked || this.undoStack.length === 0) return const entry = this.undoStack.pop()! - - // Capture current state for redo - const currentSnapshot = this.store.exportPartialSnapshot(entry.affectedKeys) - - // Push to redo stack with current state - this.redoStack.push({ - id: entry.id, - label: entry.label, - beforeSnapshot: currentSnapshot, - affectedKeys: entry.affectedKeys, - timestamp: Date.now(), - }) - - // Restore the before snapshot - this.store.importPartialSnapshot(entry.beforeSnapshot) + // Capture the current state of the same cells as the inverse (redo) entry, + // then restore the pre-images. + const inverse = this.journal.captureCurrent(entry.cells) + this.journal.apply(entry.cells) + this.redoStack.push({ cells: inverse }) this.notifySubscribers() } - /** - * Redoes the last undone entry. - */ redo(): void { - if (this.isBlocked || this.redoStack.length === 0) { - return - } + if (this.isBlocked || this.redoStack.length === 0) return const entry = this.redoStack.pop()! - - // Capture current state for undo - const currentSnapshot = this.store.exportPartialSnapshot(entry.affectedKeys) - - // Push back to undo stack - this.undoStack.push({ - id: entry.id, - label: entry.label, - beforeSnapshot: currentSnapshot, - affectedKeys: entry.affectedKeys, - timestamp: Date.now(), - }) - - // Restore the redo snapshot - this.store.importPartialSnapshot(entry.beforeSnapshot) + const inverse = this.journal.captureCurrent(entry.cells) + this.journal.apply(entry.cells) + this.undoStack.push({ cells: inverse }) this.notifySubscribers() } - /** - * Blocks undo/redo operations. - * Use during persist to prevent inconsistent state. - */ + // ==================== Blocking (during persist) ==================== + block(): void { - // Flush any pending entry before blocking - this.flushPendingEntry() + this.flushPending() this.isBlocked = true this.notifySubscribers() } - /** - * Unblocks undo/redo operations. - */ unblock(): void { this.isBlocked = false this.notifySubscribers() } + // ==================== Persist rekey ==================== + /** - * Clears all undo/redo history. + * Rewrites every stacked entry when a temp id is replaced by its persisted id, + * so stored cells keep valid keys / id references (and sealed creates drop out). */ + private rekeyStacks(ctx: RekeyContext): void { + const liveServerIds = (key: string): Set => this.store.getLiveHasManyServerIds(key) + this.undoStack = this.undoStack.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) + this.redoStack = this.redoStack.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) + if (this.pending) { + const rekeyed = rekeyJournalEntry({ cells: [...this.pending.values()] }, ctx, liveServerIds) + this.pending = new Map(rekeyed.cells.map(cell => [cellRefKey(cell), cell])) + } + } + + // ==================== History / State ==================== + clear(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null } - this.pendingEntry = null + this.pending = null + this.manualGroupId = null this.undoStack = [] this.redoStack = [] - this.manualGroupId = null - this.manualGroupLabel = undefined this.notifySubscribers() } - /** - * Subscribes to state changes. - * Returns unsubscribe function. - */ subscribe(callback: Subscriber): () => void { this.subscribers.add(callback) return () => { @@ -389,26 +230,20 @@ export class UndoManager { } } - /** - * Gets current state for React integration. - * Returns a cached object to satisfy useSyncExternalStore's referential equality requirement. - */ getState(): UndoState { if (!this.cachedState) { + const hasPending = this.pending !== null && this.pending.size > 0 this.cachedState = { - canUndo: this.undoStack.length > 0 || this.pendingEntry !== null, + canUndo: this.undoStack.length > 0 || hasPending, canRedo: this.redoStack.length > 0, isBlocked: this.isBlocked, - undoCount: this.undoStack.length + (this.pendingEntry ? 1 : 0), + undoCount: this.undoStack.length + (hasPending ? 1 : 0), redoCount: this.redoStack.length, } } return this.cachedState } - /** - * Notifies all subscribers of state change. - */ private notifySubscribers(): void { this.cachedState = null for (const sub of this.subscribers) { diff --git a/packages/bindx/src/undo/index.ts b/packages/bindx/src/undo/index.ts index 2bda7dc7..6701fe6b 100644 --- a/packages/bindx/src/undo/index.ts +++ b/packages/bindx/src/undo/index.ts @@ -1,9 +1,9 @@ export { UndoManager } from './UndoManager.js' +export type { UndoManagerConfig, UndoState } from './types.js' export type { - PartialStoreSnapshot, - PendingUndoEntry, - UndoEntry, - UndoManagerConfig, - UndoState, - StoreAffectedKeys, -} from './types.js' + JournalEntry, + JournalCellImage, + EntityCellImage, + RelationCellImage, + HasManyCellImage, +} from './UndoJournal.js' diff --git a/packages/bindx/src/undo/rekeyJournalEntry.ts b/packages/bindx/src/undo/rekeyJournalEntry.ts new file mode 100644 index 00000000..7797f813 --- /dev/null +++ b/packages/bindx/src/undo/rekeyJournalEntry.ts @@ -0,0 +1,145 @@ +import type { RekeyContext } from '../store/RekeyOrchestrator.js' +import { createEntitySnapshot } from '../store/snapshots.js' +import type { EntitySnapshot } from '../store/snapshots.js' +import type { StoredHasManyState } from '../store/RelationStore.js' +import type { JournalEntry, JournalCellImage } from './UndoJournal.js' + +/** + * Rewrites a journal entry for a temp→persisted rekey so stored cells stay valid + * after persist: + * - owner keys move from the temp prefix to the persisted prefix; + * - embedded id references (snapshot ids, relation currentId/serverId, has-many + * members) are remapped oldId→newId; + * - the create is SEALED: an "absent" pre-image of the now-persisted entity (or + * its owned relations) is dropped, so undo can no longer delete the + * server-backed row — only later edits to it remain undoable. + */ +/** + * Looks up the live server-member ids of a has-many list by its key. Lets the + * rekey rebase a pre-image when the just-persisted create has become a permanent + * member of that list. + */ +export type LiveServerIdsLookup = (relationKey: string) => Set + +export function rekeyJournalEntry( + entry: JournalEntry, + ctx: RekeyContext, + liveServerIds: LiveServerIdsLookup, +): JournalEntry { + const cells: JournalCellImage[] = [] + for (const cell of entry.cells) { + const rewritten = rekeyCell(cell, ctx, liveServerIds) + if (rewritten) cells.push(rewritten) + } + return { cells } +} + +function rekeyKey(key: string, ctx: RekeyContext): string { + if (key === ctx.oldKey) return ctx.newKey + if (key.startsWith(ctx.oldKeyPrefix)) return ctx.newKeyPrefix + key.slice(ctx.oldKeyPrefix.length) + return key +} + +function isOwnedByRekeyed(key: string, ctx: RekeyContext): boolean { + return key === ctx.oldKey || key.startsWith(ctx.oldKeyPrefix) +} + +function rekeyCell( + cell: JournalCellImage, + ctx: RekeyContext, + liveServerIds: LiveServerIdsLookup, +): JournalCellImage | null { + // Seal: an absent pre-image of the rekeyed entity (or a relation it owns) would + // un-create a now server-backed row on undo. Drop it. + if (!cell.present && isOwnedByRekeyed(cell.key, ctx)) { + return null + } + + if (cell.kind === 'entity') { + const key = rekeyKey(cell.key, ctx) + if (!cell.snapshot) return { ...cell, key } + return { ...cell, key, snapshot: rekeySnapshotId(cell.snapshot, ctx) } + } + + if (cell.kind === 'relation') { + const key = rekeyKey(cell.key, ctx) + if (!cell.state) return { ...cell, key } + return { + ...cell, + key, + state: { + ...cell.state, + currentId: cell.state.currentId === ctx.oldId ? ctx.newId : cell.state.currentId, + serverId: cell.state.serverId === ctx.oldId ? ctx.newId : cell.state.serverId, + }, + } + } + + const key = rekeyKey(cell.key, ctx) + if (!cell.state) return { ...cell, key } + let state = rekeyHasManyState(cell.state, ctx) + // Membership rebase: when the just-persisted create became a permanent member of + // this live list, fold it into the (older) pre-image so undo keeps it instead of + // dropping it. Default order picks it up automatically; an explicit order needs + // the id appended. + if (liveServerIds(key).has(ctx.newId) && !state.serverIds.has(ctx.newId)) { + const serverIds = new Set(state.serverIds) + serverIds.add(ctx.newId) + let orderedIds = state.orderedIds + if (orderedIds && !orderedIds.includes(ctx.newId)) { + orderedIds = [...orderedIds, ctx.newId] + } + state = { ...state, serverIds, orderedIds } + } + return { ...cell, key, state } +} + +function rekeySnapshotId(snapshot: EntitySnapshot, ctx: RekeyContext): EntitySnapshot { + if (snapshot.id !== ctx.oldId) return snapshot + return createEntitySnapshot( + ctx.newId, + snapshot.entityType, + { ...(snapshot.data as Record), id: ctx.newId }, + { ...(snapshot.serverData as Record), id: ctx.newId }, + snapshot.version, + ) +} + +function swapInSet(set: Set, oldId: string, newId: string): Set { + if (!set.has(oldId)) return set + const next = new Set(set) + next.delete(oldId) + next.add(newId) + return next +} + +function rekeyHasManyState(state: StoredHasManyState, ctx: RekeyContext): StoredHasManyState { + const serverIds = swapInSet(state.serverIds, ctx.oldId, ctx.newId) + + let orderedIds = state.orderedIds + if (orderedIds) { + const idx = orderedIds.indexOf(ctx.oldId) + if (idx !== -1) { + orderedIds = [...orderedIds] + orderedIds[idx] = ctx.newId + } + } + + let plannedAdditions = state.plannedAdditions + const additionKind = plannedAdditions.get(ctx.oldId) + if (additionKind !== undefined) { + plannedAdditions = new Map(plannedAdditions) + plannedAdditions.delete(ctx.oldId) + plannedAdditions.set(ctx.newId, additionKind) + } + + let plannedRemovals = state.plannedRemovals + const removalType = plannedRemovals.get(ctx.oldId) + if (removalType !== undefined) { + plannedRemovals = new Map(plannedRemovals) + plannedRemovals.delete(ctx.oldId) + plannedRemovals.set(ctx.newId, removalType) + } + + return { serverIds, orderedIds, plannedRemovals, plannedAdditions, version: state.version } +} diff --git a/packages/bindx/src/undo/types.ts b/packages/bindx/src/undo/types.ts index fe0de72e..74ff325e 100644 --- a/packages/bindx/src/undo/types.ts +++ b/packages/bindx/src/undo/types.ts @@ -1,58 +1,3 @@ -import type { StoreAffectedKeys } from '../core/actionClassification.js' -import type { EntitySnapshot } from '../store/snapshots.js' -import type { - StoredRelationState, - StoredHasManyState, - EntityMeta, -} from '../store/SnapshotStore.js' - -/** - * Partial snapshot of store state. - * Contains only the data affected by an action or group of actions. - */ -export interface PartialStoreSnapshot { - /** Entity snapshots (frozen, immutable) */ - entitySnapshots: Map - /** Has-one relation states */ - relationStates: Map - /** Has-many list states */ - hasManyStates: Map - /** Entity metadata (existsOnServer, deletion flags) */ - entityMetas: Map -} - -/** - * A single undo/redo entry. - * Contains the snapshot of state BEFORE the action(s) were applied. - */ -export interface UndoEntry { - /** Unique identifier for this entry */ - id: string - /** Optional human-readable label */ - label?: string - /** Snapshot of state BEFORE the action(s) */ - beforeSnapshot: PartialStoreSnapshot - /** Keys that were affected (for redo snapshot capture) */ - affectedKeys: StoreAffectedKeys - /** When this entry was created */ - timestamp: number -} - -/** - * Pending entry during debounce window. - * Accumulates affected keys while waiting for debounce to complete. - */ -export interface PendingUndoEntry { - /** Snapshot captured before the first action in the group */ - beforeSnapshot: PartialStoreSnapshot - /** Accumulated affected keys */ - affectedKeys: StoreAffectedKeys - /** When the first action occurred */ - timestamp: number - /** Optional label for manual grouping */ - label?: string -} - /** * Configuration options for UndoManager. */ @@ -79,6 +24,3 @@ export interface UndoState { /** Number of entries in redo stack */ redoCount: number } - -// Re-export StoreAffectedKeys for convenience -export type { StoreAffectedKeys } from '../core/actionClassification.js' diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts new file mode 100644 index 00000000..ecd6c4b9 --- /dev/null +++ b/tests/undo-journal.test.ts @@ -0,0 +1,343 @@ +import './setup' +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + UndoManager, + setField, + connectRelation, + disconnectRelation, + moveInList, + removeFromList, +} from '@contember/bindx' + +/** + * Deep coverage of the write-journal machinery (beyond the headline + * characterization tests in undo-stabilization.test.ts): the create-seal across a + * persist, temp→persisted id remapping inside stored entries, every has-many + * mutation, multi-cell atomic gestures, redo after a persist-surviving undo, + * absent-relation/has-many restore, and assorted edge cases. + */ +describe('undo journal — deep coverage', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let undo: UndoManager + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + undo = new UndoManager(store, { debounceMs: 0 }) + dispatcher.addMiddleware(undo.createMiddleware()) + }) + + // ============================================================ + // Seal — undo of a journaled create after it has been persisted + // ============================================================ + describe('create-seal across persist', () => { + test('default order: undo after persist keeps the created (now server) child', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s']) + store.setEntityData('Item', 's', { id: 's', name: 's-orig' }, true) + + // Gesture: create child C in P.items. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'ctemp', name: 'new' }, + }) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s', 'ctemp']) + + // Persist: commit list (C becomes a server member) and rekey C. + store.commitHasMany('Article', 'p', 'items', ['s', 'ctemp']) + store.commitEntity('Item', 'ctemp') + store.setExistsOnServer('Item', 'ctemp', true) + store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') + + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s', 'cp']) + + // Undo must NOT delete the now server-backed row. + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('cp') + expect(store.getEntitySnapshot('Item', 'cp')).toBeDefined() + }) + + test('falsification: create C under saved P + edit saved sibling S in one group; persist; undo reverts S and keeps C', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s']) + store.setEntityData('Item', 's', { id: 's', name: 's-orig' }, true) + + const groupId = undo.beginGroup('add C + edit S') + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'ctemp', name: 'C' }, + }) + dispatcher.dispatch(setField('Item', 's', ['name'], 'edited')) + undo.endGroup(groupId) + + // Persist everything: commit list + sibling, rekey C. + store.commitHasMany('Article', 'p', 'items', ['s', 'ctemp']) + store.commitEntity('Item', 's') + store.commitEntity('Item', 'ctemp') + store.setExistsOnServer('Item', 'ctemp', true) + store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') + + undo.undo() + + // S edit reverts (and re-dirties); C remains a server member. + expect(store.getEntitySnapshot<{ name: string }>('Item', 's')?.data.name).toBe('s-orig') + expect(store.getEntitySnapshot<{ name: string }>('Item', 's')?.serverData.name).toBe('edited') + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('cp') + expect(store.getEntitySnapshot('Item', 'cp')).toBeDefined() + }) + + test('explicit order: a reordered list keeps the created child after persist+undo', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2']) + + // Reorder first (explicit orderedIds), in its own gesture. + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s2', 's1']) + + // Then add C in another gesture. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'ctemp' }, + }) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s2', 's1', 'ctemp']) + + // Persist: commit + rekey C. + store.commitHasMany('Article', 'p', 'items', ['s1', 's2', 'ctemp']) + store.commitEntity('Item', 'ctemp') + store.setExistsOnServer('Item', 'ctemp', true) + store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') + + // Undo the add: C is now permanent and must remain in the list. + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('cp') + expect(store.getEntitySnapshot('Item', 'cp')).toBeDefined() + }) + }) + + // ============================================================ + // Rekey — temp→persisted id remapping inside stored entries + // ============================================================ + describe('rekey remaps embedded ids in stacked entries', () => { + test('has-many member id is rewritten in a later gesture captured before persist', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + // gesture1: add C (temp). + dispatcher.dispatch({ + type: 'ADD_TO_LIST', entityType: 'Article', entityId: 'p', fieldName: 'items', + targetType: 'Item', itemData: { id: 'ctemp' }, + }) + // gesture2: add D (temp) — its has-many pre-image contains C-temp. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', entityType: 'Article', entityId: 'p', fieldName: 'items', + targetType: 'Item', itemData: { id: 'dtemp' }, + }) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['ctemp', 'dtemp']) + + // Persist only C (rekey temp→persisted); the live list rewrites C's id too. + store.commitEntity('Item', 'ctemp') + store.setExistsOnServer('Item', 'ctemp', true) + store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') + + // Undo gesture2 (the D add): D is removed and the restored list references the + // PERSISTED C id, not the dangling temp id. + undo.undo() + const ids = store.getHasManyOrderedIds('Article', 'p', 'items') + expect(ids).toContain('cp') + expect(ids).not.toContain('ctemp') + expect(ids).not.toContain('dtemp') + }) + + test('has-one currentId is rewritten in a stored relation pre-image', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + store.setRelation('Article', 'a', 'author', { currentId: null, state: 'disconnected' }) + + // gesture1: create + connect C (temp) via the has-one path. + const cTemp = store.createEntity('Author', { id: 'ctemp', name: 'C' }) + store.transaction(() => { + dispatcher.dispatch(connectRelation('Article', 'a', 'author', cTemp, 'Author')) + }) + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('ctemp') + + // gesture2: disconnect — its relation pre-image holds currentId = C-temp. + dispatcher.dispatch(disconnectRelation('Article', 'a', 'author')) + expect(store.getRelation('Article', 'a', 'author')?.state).toBe('disconnected') + + // Persist C (rekey). + store.commitEntity('Author', 'ctemp') + store.setExistsOnServer('Author', 'ctemp', true) + store.mapTempIdToPersistedId('Author', 'ctemp', 'cp') + + // Undo gesture2 (the disconnect): reconnect points at the PERSISTED id. + undo.undo() + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('cp') + }) + }) + + // ============================================================ + // Has-many mutations (beyond add) + // ============================================================ + describe('has-many mutation undo', () => { + test('move is undone to the prior order', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['a', 'b', 'c']) + + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 2)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['b', 'c', 'a']) + + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['a', 'b', 'c']) + + undo.redo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['b', 'c', 'a']) + }) + + test('disconnect of a server item is undone (planned removal cleared)', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2']) + + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 's1', 'disconnect')) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s2']) + expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.has('s1')).toBe(true) + + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s1', 's2']) + expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.size ?? 0).toBe(0) + }) + + test('delete of a server item is undone', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2']) + + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 's1', 'delete')) + expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.get('s1')).toBe('delete') + + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s1', 's2']) + expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.size ?? 0).toBe(0) + }) + }) + + // ============================================================ + // Multi-cell atomic gesture + // ============================================================ + test('multi-cell gesture: one undo reverts field + relation + list together', () => { + store.setEntityData('Article', 'a', { id: 'a', name: 'a' }, true) + store.setEntityData('Author', 'b', { id: 'b' }, true) + store.setRelation('Article', 'a', 'author', { currentId: null, state: 'disconnected' }) + store.setHasManyServerIds('Article', 'a', 'tags', []) + + const groupId = undo.beginGroup('bulk') + dispatcher.dispatch(setField('Article', 'a', ['name'], 'A2')) + dispatcher.dispatch(connectRelation('Article', 'a', 'author', 'b', 'Author')) + dispatcher.dispatch({ + type: 'ADD_TO_LIST', entityType: 'Article', entityId: 'a', fieldName: 'tags', + targetType: 'Tag', itemData: { id: 'c1', label: 'x' }, + }) + undo.endGroup(groupId) + + expect(undo.getState().undoCount).toBe(1) + expect(store.getEntitySnapshot<{ name: string }>('Article', 'a')?.data.name).toBe('A2') + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('b') + expect(store.getHasManyOrderedIds('Article', 'a', 'tags')).toEqual(['c1']) + + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Article', 'a')?.data.name).toBe('a') + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBeNull() + expect(store.getRelation('Article', 'a', 'author')?.state).toBe('disconnected') + expect(store.getHasManyOrderedIds('Article', 'a', 'tags')).toEqual([]) + expect(store.getEntitySnapshot('Tag', 'c1')).toBeUndefined() + }) + + // ============================================================ + // Redo after a persist-surviving undo + // ============================================================ + test('redo after persist+undo re-applies the (now clean) persisted value', () => { + const tempId = store.createEntity('Article', { title: 'A' }) + dispatcher.dispatch(setField('Article', tempId, ['title'], 'B')) + + store.commitEntity('Article', tempId) + store.setExistsOnServer('Article', tempId, true) + store.mapTempIdToPersistedId('Article', tempId, 'p1') + + undo.undo() + expect(store.getEntitySnapshot<{ title: string }>('Article', 'p1')?.data.title).toBe('A') + expect(store.getDirtyFields('Article', 'p1')).toContain('title') + + undo.redo() + const snap = store.getEntitySnapshot<{ title: string }>('Article', 'p1') + expect(snap?.data.title).toBe('B') + // 'B' is now the server value → clean again. + expect(store.getDirtyFields('Article', 'p1')).toEqual([]) + }) + + // ============================================================ + // Absent restore — a relation/list that did not exist before the gesture + // ============================================================ + test('connect that creates the relation state is undone by dropping it', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + store.setEntityData('Author', 'b', { id: 'b' }, true) + // No relation state initialised — the connect creates it. + + dispatcher.dispatch(connectRelation('Article', 'a', 'author', 'b', 'Author')) + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('b') + + undo.undo() + // The relation state did not exist before the gesture → it is removed entirely. + expect(store.getRelation('Article', 'a', 'author')).toBeUndefined() + }) + + // ============================================================ + // Edge cases + // ============================================================ + test('nested field path is restored', () => { + store.setEntityData('Article', 'a', { id: 'a', meta: { title: 't' } }, true) + + dispatcher.dispatch(setField('Article', 'a', ['meta', 'title'], 't2')) + expect(store.getEntitySnapshot<{ meta: { title: string } }>('Article', 'a')?.data.meta.title).toBe('t2') + + undo.undo() + expect(store.getEntitySnapshot<{ meta: { title: string } }>('Article', 'a')?.data.meta.title).toBe('t') + }) + + test('scheduleForDeletion is undone', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + + store.transaction(() => store.scheduleForDeletion('Article', 'a')) + expect(store.isScheduledForDeletion('Article', 'a')).toBe(true) + + undo.undo() + expect(store.isScheduledForDeletion('Article', 'a')).toBe(false) + }) + + test('manual group captures each distinct cell at its pre-group value', () => { + store.setEntityData('Article', 'a', { id: 'a', name: 'a' }, true) + store.setEntityData('Article', 'b', { id: 'b', name: 'b' }, true) + + const groupId = undo.beginGroup() + dispatcher.dispatch(setField('Article', 'a', ['name'], 'a2')) + dispatcher.dispatch(setField('Article', 'b', ['name'], 'b2')) + undo.endGroup(groupId) + + expect(undo.getState().undoCount).toBe(1) + + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Article', 'a')?.data.name).toBe('a') + expect(store.getEntitySnapshot<{ name: string }>('Article', 'b')?.data.name).toBe('b') + }) +}) diff --git a/tests/undo-stabilization.test.ts b/tests/undo-stabilization.test.ts new file mode 100644 index 00000000..40f8e0eb --- /dev/null +++ b/tests/undo-stabilization.test.ts @@ -0,0 +1,193 @@ +import './setup' +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + UndoManager, + setField, +} from '@contember/bindx' + +/** + * CHARACTERIZATION TESTS — these pin the three known root-cause defects of the + * current snapshot-restore undo. They are EXPECTED TO FAIL against today's code and + * become the acceptance gates for the write-journal re-architecture + * (plan: write-journal nad dekomponovaným storem). + * + * Each test documents: the defect, its root cause, and the phase that fixes it. + */ +describe('undo stabilization (characterization — currently failing)', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let undo: UndoManager + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + undo = new UndoManager(store, { debounceMs: 0 }) // one entry per dispatch + dispatcher.addMiddleware(undo.createMiddleware()) + }) + + /** + * BUG #1 — incomplete capture of created entities in a list. + * + * Root cause: a list-add creates the child via store.createEntity() as part of the + * gesture, but getAffectedKeys(ADD_TO_LIST) only returns the parent has-many key, so + * the child entity snapshot is never captured. The add→undo→redo round-trip only + * survives by accident (the orphan lingers). Once the memory sweep reclaims the + * unreachable orphan, redo restores the list membership but the child data is gone. + * + * Fixed by: Phase 1–3 (journal captures the child's creation; redo re-creates it). + */ + test('bug #1: create-in-list survives undo → sweep → redo', () => { + store.setEntityData('Article', '1', { id: '1', comments: [] }, true) + store.setHasManyServerIds('Article', '1', 'comments', []) + + // ADD_TO_LIST with itemData (deterministic id) — the dispatcher creates the child + // INSIDE the gesture, exactly the path a real add() drives. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: '1', + fieldName: 'comments', + targetType: 'Comment', + itemData: { id: 'c1', text: 'Hello' }, + }) + + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).toContain('c1') + expect(store.getEntitySnapshot<{ text: string }>('Comment', 'c1')?.data.text).toBe('Hello') + + undo.undo() + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).not.toContain('c1') + + // Memory sweep (runs e.g. once a persist settles) reclaims the now-unreachable orphan. + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Comment', 'c1')).toBeUndefined() + + undo.redo() + // The list points at c1 again — but its data must come back too. + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).toContain('c1') + expect(store.getEntitySnapshot<{ text: string }>('Comment', 'c1')?.data.text).toBe('Hello') + }) + + /** + * BUG #3 — undo does not survive a persist's temp→persisted rekey. + * + * Root cause: stored pre-images hold the temp key. After mapTempIdToPersistedId + * rekeys the live entity to its persisted key, undo's importPartialSnapshot writes + * the pre-image back under the STALE temp key (resurrecting a zombie) and leaves the + * live persisted entity untouched. + * + * Fixed by: Phase 4 (journal participates in RekeyOrchestrator; entries remap keys; + * undo restores only the editable layer onto the live server baseline → re-dirty). + */ + test('bug #3: undo after persist+rekey reverts the live persisted entity', () => { + const tempId = store.createEntity('Article', { title: 'A' }) + + dispatcher.dispatch(setField('Article', tempId, ['title'], 'B')) + expect(store.getEntitySnapshot<{ title: string }>('Article', tempId)?.data.title).toBe('B') + + // Simulate a successful persist: commit, mark existing on server, rekey to 'p1'. + store.commitEntity('Article', tempId) + store.setExistsOnServer('Article', tempId, true) + store.mapTempIdToPersistedId('Article', tempId, 'p1') + + undo.undo() + + const live = store.getEntitySnapshot<{ title: string }>('Article', 'p1') + // Editable value reverts on the LIVE entity; server baseline stays at 'B' → dirty again. + expect(live?.data.title).toBe('A') + expect(live?.serverData.title).toBe('B') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: 'p1', + changeType: 'update', + }) + }) + + /** + * BUG #2 — root registration must travel with the undo unit. + * + * A top-level created entity's "is a pending create" status is anchored solely by + * its root membership. The journal captures root membership in the entity cell, so + * undo of a top-level create removes the entity AND its root (no phantom create), + * and redo restores it AND its root (create status comes back). + * + * Fixed by: Phase 3 (root membership captured/restored in the entity cell). + */ + test('bug #2: undo/redo of a top-level create round-trips its pending-create status', () => { + // A top-level create (e.g. ) is one undoable gesture. + let id!: string + store.transaction(() => { + id = store.createEntity('Article', { title: 'Draft' }) + }) + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: id, + changeType: 'create', + }) + expect(undo.getState().canUndo).toBe(true) + + undo.undo() + // The create is fully reverted: gone from the store, no phantom create. + expect(store.getEntitySnapshot('Article', id)).toBeUndefined() + expect(store.getAllDirtyEntities()).toEqual([]) + + undo.redo() + // Re-created WITH its root, so it is reported as a pending create again. + expect(store.getEntitySnapshot('Article', id)).toBeDefined() + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Article', + entityId: id, + changeType: 'create', + }) + }) + + /** + * The write-journal scales with EDIT size, not dataset size: editing one of many + * loaded entities captures and restores only that cell. Untouched snapshots keep + * their exact frozen reference across the edit+undo — proof the store was not + * wholesale-restored (the failure mode of a whole-store snapshot approach). + */ + test('scale: undo touches only the edited entity, not the loaded set', () => { + for (let i = 0; i < 200; i++) { + store.setEntityData('Row', `r${i}`, { id: `r${i}`, name: `n${i}` }, true) + } + const untouchedBefore = store.getEntitySnapshot('Row', 'r100') + + dispatcher.dispatch(setField('Row', 'r5', ['name'], 'edited')) + expect(undo.getState().undoCount).toBe(1) + + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Row', 'r5')?.data.name).toBe('n5') + // Untouched rows keep their exact snapshot reference. + expect(store.getEntitySnapshot('Row', 'r100')).toBe(untouchedBefore) + }) + + /** + * The path a real HasManyListHandle.add() drives: pre-create OUTSIDE the + * dispatcher, wrapped in one store transaction. The whole gesture is one undo + * unit, so it survives undo → sweep → redo (the bug #1 shape via the handle path). + */ + test('handle gesture: pre-create in a transaction round-trips create-in-list', () => { + store.setEntityData('Article', '1', { id: '1' }, true) + store.setHasManyServerIds('Article', '1', 'comments', []) + + let childId!: string + store.transaction(() => { + childId = store.createEntity('Comment', { text: 'Hi' }) + store.addToHasMany('Article', '1', 'comments', childId) + store.registerParentChild('Article', '1', 'Comment', childId) + }) + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).toContain(childId) + + undo.undo() + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).not.toContain(childId) + + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Comment', childId)).toBeUndefined() + + undo.redo() + expect(store.getHasManyOrderedIds('Article', '1', 'comments')).toContain(childId) + expect(store.getEntitySnapshot<{ text: string }>('Comment', childId)?.data.text).toBe('Hi') + }) +}) From 3abef4b92a8fcb14d2cad296222d13f82ea9312f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 1 Jul 2026 16:30:01 +0200 Subject: [PATCH 25/28] fix(bindx): isolate undo journal transactions --- packages/bindx/src/core/ActionDispatcher.ts | 52 +++++++------ packages/bindx/src/store/SnapshotStore.ts | 24 ++++-- packages/bindx/src/undo/UndoManager.ts | 33 ++++++++- tests/undo-journal.test.ts | 82 +++++++++++++++++++++ tests/undo.test.ts | 14 ++++ 5 files changed, 172 insertions(+), 33 deletions(-) diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 7b8e51df..4049884e 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -90,33 +90,33 @@ export class ActionDispatcher { * Returns true if the action was executed, false if it was cancelled by an interceptor. */ async dispatchAsync(action: Action): Promise { - this.store.beginTransaction() - try { - // Run through middlewares first - for (const middleware of this.middlewares) { - const result = middleware(action, this.store) - if (result === false) { - return false - } + // Run through middlewares first + for (const middleware of this.middlewares) { + const result = middleware(action, this.store) + if (result === false) { + return false } + } - // Create and run before event through interceptors - const beforeEvent = createBeforeEvent(action, this.store) - if (beforeEvent) { - const result = await this.eventEmitter.runInterceptors(beforeEvent) - if (result === null) { - // Interceptor cancelled the action - return false - } - // If event was modified, update action accordingly - if (result !== beforeEvent) { - action = this.updateActionFromEvent(action, result) - } + // Create and run before event through interceptors + const beforeEvent = createBeforeEvent(action, this.store) + if (beforeEvent) { + const result = await this.eventEmitter.runInterceptors(beforeEvent) + if (result === null) { + // Interceptor cancelled the action + return false + } + // If event was modified, update action accordingly + if (result !== beforeEvent) { + action = this.updateActionFromEvent(action, result) } + } - // Capture state before execution (for after events) - const stateBefore = captureStateBeforeAction(action, this.store) + // Capture state before execution (for after events) + const stateBefore = captureStateBeforeAction(action, this.store) + this.store.beginTransaction() + try { // Execute the action this.execute(action) @@ -186,6 +186,9 @@ export class ActionDispatcher { const index = this.middlewares.indexOf(middleware) if (index !== -1) { this.middlewares.splice(index, 1) + if (!this.middlewares.includes(middleware)) { + middleware.dispose?.() + } } } @@ -468,7 +471,10 @@ export class ActionDispatcher { * Can inspect/modify actions before execution. * Return false to cancel the action. */ -export type ActionMiddleware = (action: Action, store: SnapshotStore) => boolean | void +export interface ActionMiddleware { + (action: Action, store: SnapshotStore): boolean | void + dispose?: () => void +} /** * Creates a logging middleware for debugging. diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index fc407894..01661656 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -109,6 +109,18 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { this.journal = journal } + clearJournal(journal: UndoJournal): void { + if (this.journal === journal) { + this.journal = null + } + } + + private recordExistingEntity(key: string): void { + if (this.entitySnapshots.has(key)) { + this.journal?.recordEntity(key) + } + } + /** * Opens a journal transaction (re-entrant). All primary-store writes until the * matching {@link commitTransaction} are captured as ONE undoable gesture. @@ -277,7 +289,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { updates: Partial, ): EntitySnapshot | undefined { const key = this.getEntityKey(entityType, id) - this.journal?.recordEntity(key) + this.recordExistingEntity(key) const result = this.entitySnapshots.updateFields(key, updates) if (result) { this.notifyEntitySubscribers(key) @@ -292,7 +304,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { value: unknown, ): void { const key = this.getEntityKey(entityType, id) - this.journal?.recordEntity(key) + this.recordExistingEntity(key) if (this.entitySnapshots.setFieldValue(key, fieldPath, value)) { this.notifyEntitySubscribers(key) } @@ -306,7 +318,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { resetEntity(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) - this.journal?.recordEntity(key) + this.recordExistingEntity(key) this.entitySnapshots.reset(key) this.notifyEntitySubscribers(key) } @@ -385,14 +397,14 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { scheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) - this.journal?.recordEntity(key) + this.recordExistingEntity(key) this.meta.scheduleForDeletion(key) this.notifyEntitySubscribers(key) } unscheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) - this.journal?.recordEntity(key) + this.recordExistingEntity(key) this.meta.unscheduleForDeletion(key) this.notifyEntitySubscribers(key) } @@ -941,7 +953,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { const childKey = this.getEntityKey(childType, childId) // Capture the child's root membership before it is dropped, so undoing the // connect/add that anchored it restores it as a reachable create. - this.journal?.recordEntity(childKey) + this.recordExistingEntity(childKey) this.roots.unregister(childKey) } diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index 40868ff8..c6ff91c0 100644 --- a/packages/bindx/src/undo/UndoManager.ts +++ b/packages/bindx/src/undo/UndoManager.ts @@ -62,7 +62,11 @@ export class UndoManager { */ createMiddleware(): ActionMiddleware { this.store.setJournal(this.journal) - return () => true + const middleware: ActionMiddleware = () => true + middleware.dispose = () => { + this.store.clearJournal(this.journal) + } + return middleware } // ==================== Recording (journal commit sink) ==================== @@ -201,11 +205,32 @@ export class UndoManager { */ private rekeyStacks(ctx: RekeyContext): void { const liveServerIds = (key: string): Set => this.store.getLiveHasManyServerIds(key) - this.undoStack = this.undoStack.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) - this.redoStack = this.redoStack.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) + const undoCount = this.undoStack.length + const redoCount = this.redoStack.length + const hadPending = this.pending !== null && this.pending.size > 0 + + this.undoStack = this.undoStack + .map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) + .filter(entry => entry.cells.length > 0) + this.redoStack = this.redoStack + .map(entry => rekeyJournalEntry(entry, ctx, liveServerIds)) + .filter(entry => entry.cells.length > 0) if (this.pending) { const rekeyed = rekeyJournalEntry({ cells: [...this.pending.values()] }, ctx, liveServerIds) - this.pending = new Map(rekeyed.cells.map(cell => [cellRefKey(cell), cell])) + if (rekeyed.cells.length > 0) { + this.pending = new Map(rekeyed.cells.map(cell => [cellRefKey(cell), cell])) + } else { + this.pending = null + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + } + } + + const hasPending = this.pending !== null && this.pending.size > 0 + if (this.undoStack.length !== undoCount || this.redoStack.length !== redoCount || hasPending !== hadPending) { + this.notifySubscribers() } } diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index ecd6c4b9..db70ee13 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -34,6 +34,40 @@ describe('undo journal — deep coverage', () => { // Seal — undo of a journaled create after it has been persisted // ============================================================ describe('create-seal across persist', () => { + test('standalone persisted create is removed from undo history after rekey', () => { + store.transaction(() => { + store.createEntity('Article', { id: 'ctemp', title: 'draft' }) + }) + expect(undo.getState().undoCount).toBe(1) + + store.commitEntity('Article', 'ctemp') + store.setExistsOnServer('Article', 'ctemp', true) + store.mapTempIdToPersistedId('Article', 'ctemp', 'cp') + + expect(undo.getState().undoCount).toBe(0) + expect(undo.getState().canUndo).toBe(false) + expect(store.getEntitySnapshot('Article', 'cp')).toBeDefined() + }) + + test('pending standalone create is cleared after persist rekey seals it', () => { + const pendingStore = new SnapshotStore() + const pendingUndo = new UndoManager(pendingStore, { debounceMs: 1000 }) + pendingUndo.createMiddleware() + + pendingStore.transaction(() => { + pendingStore.createEntity('Article', { id: 'ctemp', title: 'draft' }) + }) + expect(pendingUndo.getState().undoCount).toBe(1) + + pendingStore.commitEntity('Article', 'ctemp') + pendingStore.setExistsOnServer('Article', 'ctemp', true) + pendingStore.mapTempIdToPersistedId('Article', 'ctemp', 'cp') + + expect(pendingUndo.getState().undoCount).toBe(0) + expect(pendingUndo.getState().canUndo).toBe(false) + expect(pendingStore.getEntitySnapshot('Article', 'cp')).toBeDefined() + }) + test('default order: undo after persist keeps the created (now server) child', () => { store.setEntityData('Article', 'p', { id: 'p' }, true) store.setHasManyServerIds('Article', 'p', 'items', ['s']) @@ -264,6 +298,42 @@ describe('undo journal — deep coverage', () => { expect(store.getEntitySnapshot('Tag', 'c1')).toBeUndefined() }) + test('dispatchAsync delayed by interceptor does not merge interleaved dispatch into its undo entry', async () => { + store.setEntityData('Article', 'a', { id: 'a', title: 'A' }, true) + store.setEntityData('Article', 'b', { id: 'b', title: 'B' }, true) + + let releaseInterceptor: (() => void) | undefined + const interceptorGate = new Promise(resolve => { + releaseInterceptor = resolve + }) + + dispatcher.getEventEmitter().intercept('field:changing', event => { + if (event.entityId === 'a') { + return interceptorGate + } + return undefined + }) + + const pendingDispatch = dispatcher.dispatchAsync(setField('Article', 'a', ['title'], 'A async')) + if (!releaseInterceptor) { + throw new Error('Interceptor did not start') + } + + dispatcher.dispatch(setField('Article', 'b', ['title'], 'B sync')) + expect(undo.getState().undoCount).toBe(1) + + releaseInterceptor() + expect(await pendingDispatch).toBe(true) + expect(undo.getState().undoCount).toBe(2) + + undo.undo() + expect(store.getEntitySnapshot<{ title: string }>('Article', 'a')?.data.title).toBe('A') + expect(store.getEntitySnapshot<{ title: string }>('Article', 'b')?.data.title).toBe('B sync') + + undo.undo() + expect(store.getEntitySnapshot<{ title: string }>('Article', 'b')?.data.title).toBe('B') + }) + // ============================================================ // Redo after a persist-surviving undo // ============================================================ @@ -302,6 +372,18 @@ describe('undo journal — deep coverage', () => { expect(store.getRelation('Article', 'a', 'author')).toBeUndefined() }) + test('connect to existing unloaded child does not undo a later server load of that child', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + + dispatcher.dispatch(connectRelation('Article', 'a', 'author', 'b', 'Author')) + store.setEntityData('Author', 'b', { id: 'b', name: 'Loaded later' }, true) + + undo.undo() + + expect(store.getRelation('Article', 'a', 'author')).toBeUndefined() + expect(store.getEntitySnapshot<{ name: string }>('Author', 'b')?.data.name).toBe('Loaded later') + }) + // ============================================================ // Edge cases // ============================================================ diff --git a/tests/undo.test.ts b/tests/undo.test.ts index e0b2d37a..97424a66 100644 --- a/tests/undo.test.ts +++ b/tests/undo.test.ts @@ -208,6 +208,20 @@ describe('UndoManager', () => { }) describe('History management', () => { + test('removing undo middleware detaches journal recording', () => { + const localStore = new SnapshotStore() + const localDispatcher = new ActionDispatcher(localStore) + const localUndo = new UndoManager(localStore, { debounceMs: 0 }) + const middleware = localUndo.createMiddleware() + localDispatcher.addMiddleware(middleware) + localDispatcher.removeMiddleware(middleware) + + localStore.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + localDispatcher.dispatch(setField('Article', '1', ['title'], 'Changed')) + + expect(localUndo.getState().canUndo).toBe(false) + }) + test('should respect maxHistorySize', () => { const limitedUndoManager = new UndoManager(store, { maxHistorySize: 3, debounceMs: 0 }) const limitedDispatcher = new ActionDispatcher(store) From e236deddda0cd4f49dad871d0bc3a86c1324e1d3 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 2 Jul 2026 13:41:10 +0200 Subject: [PATCH 26/28] fix(bindx): close undo journal entries over detached created subtrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gesture that detaches a created (never-persisted) child writes only the parent's relation/has-many cell, so the journal entry carried just that cell. sweepUnreachableCreated() — run post-persist and via the unmount cleanup, outside any journal transaction — then reclaimed the child's snapshot and owned relation state, and a later undo restored membership pointing at an entity that no longer existed: dangling reference, lost unsaved data. Entry-closure invariant: on commit each entry is now folded over the created, currently-unreachable subgraph its relation/has-many pre-images reference (entity image + owned relation cells, transitively through nested creates), gated by the exact sweep predicate. Undo entries are self-contained no matter what reclaims memory in between; reachable created siblings stay out, keeping entries O(edit). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4 --- packages/bindx/src/store/HasManyStore.ts | 10 + packages/bindx/src/store/HasOneStore.ts | 10 + packages/bindx/src/store/RelationStore.ts | 21 ++ packages/bindx/src/store/SnapshotStore.ts | 44 +++ packages/bindx/src/undo/UndoJournal.ts | 45 +++ tests/undo-sweep.test.ts | 357 ++++++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 tests/undo-sweep.test.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 80c07bd3..045cffa6 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -455,6 +455,16 @@ export class HasManyStore { this.edges.collectParents(childId, parents) } + /** + * Collects the keys of every has-many list owned by an entity (keys under the + * given owner prefix). Mirrors {@link removeOwnedRelations} but only enumerates. + */ + collectOwnedKeys(keyPrefix: string, keys: string[]): void { + for (const key of this.hasManyStates.keys()) { + if (key.startsWith(keyPrefix)) keys.push(key) + } + } + /** * Removes a single has-many list state (and its live edges). Used by undo * restore to drop a list that did not exist before the gesture. diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index 0d20f646..bd530915 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -181,6 +181,16 @@ export class HasOneStore { this.edges.collectParents(childId, parents) } + /** + * Collects the keys of every has-one relation owned by an entity (keys under the + * given owner prefix). Mirrors {@link removeOwnedRelations} but only enumerates. + */ + collectOwnedKeys(keyPrefix: string, keys: string[]): void { + for (const key of this.relationStates.keys()) { + if (key.startsWith(keyPrefix)) keys.push(key) + } + } + /** * Removes a single has-one relation state (and its live edge). Used by undo * restore to drop a relation that did not exist before the gesture. diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index d16ad9f1..24403065 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -156,6 +156,27 @@ export class RelationStore implements Rekeyable { // ==================== Bulk Operations ==================== + /** + * Keys of every has-one relation owned by an entity (owner prefix + * "parentType:parentId:"). Used by the journal's entry-closure to enumerate a + * detached created entity's own relation cells. + */ + getOwnedRelationKeys(keyPrefix: string): string[] { + const keys: string[] = [] + this.hasOne.collectOwnedKeys(keyPrefix, keys) + return keys + } + + /** + * Keys of every has-many list owned by an entity (owner prefix + * "parentType:parentId:"). Counterpart of {@link getOwnedRelationKeys}. + */ + getOwnedHasManyKeys(keyPrefix: string): string[] { + const keys: string[] = [] + this.hasMany.collectOwnedKeys(keyPrefix, keys) + return keys + } + /** * Removes a single has-one relation state by key (undo restore of an * absent-before-the-gesture relation). diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 01661656..59ebdc04 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1063,6 +1063,50 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { } } + /** + * Entry-closure export: starting from the given seed ids, walks and exports the + * entity + owned-relation cell images of every created, currently-unreachable + * subtree — the exact `!existsOnServer && !reachable` set {@link sweepUnreachableCreated} + * may reclaim. Referenced children (has-one currentId, has-many members / planned + * additions) are pushed back on the worklist, so nested created grandchildren are + * captured transitively. Lets an undo restore a detached create even after a sweep. + */ + exportUnreachableCreatedSubgraph(seedIds: ReadonlySet): JournalCellImage[] { + const reachable = this.reachability.computeReachableCreated() + const images: JournalCellImage[] = [] + const visited = new Set() + const worklist: string[] = [...seedIds] + + while (worklist.length > 0) { + const id = worklist.pop()! + const key = this.entitySnapshots.keyForId(id) + if (!key || visited.has(key)) continue + visited.add(key) + // The sweep gate: only created, currently-unreachable entities are reclaimed. + if (this.meta.existsOnServer(key) || reachable.has(key)) continue + if (!this.entitySnapshots.has(key)) continue // placeholder-only: nothing to restore + + images.push(this.exportEntityCell(key)) + + const ownerPrefix = `${key}:` + for (const relationKey of this.relations.getOwnedRelationKeys(ownerPrefix)) { + const image = this.exportRelationCell(relationKey) + images.push(image) + if (image.state?.currentId) worklist.push(image.state.currentId) + } + for (const hasManyKey of this.relations.getOwnedHasManyKeys(ownerPrefix)) { + const image = this.exportHasManyCell(hasManyKey) + images.push(image) + if (image.state) { + if (image.state.orderedIds) worklist.push(...image.state.orderedIds) + for (const additionId of image.state.plannedAdditions.keys()) worklist.push(additionId) + } + } + } + + return images + } + /** * Restores a set of cell pre-images through the write paths (so the edge index * reconciles and the reachability cache invalidates). Order matters: present diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts index 8118d50c..1966e347 100644 --- a/packages/bindx/src/undo/UndoJournal.ts +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -18,6 +18,11 @@ import type { RekeyContext } from '../store/RekeyOrchestrator.js' * / existsOnServer) is spliced back from the LIVE state on restore, so undoing an * already-persisted edit re-dirties it against the current server view instead of * resurrecting a stale baseline. + * + * Entry-closure invariant: on commit each entry is closed over the created, + * currently-unreachable subgraph its relation/has-many pre-images detach, so undo + * works no matter what memory sweep reclaims that subgraph in between (see + * {@link JournalTarget.exportUnreachableCreatedSubgraph}). */ /** A captured cell, keyed by kind + composite store key. */ @@ -62,6 +67,12 @@ export interface JournalTarget { exportEntityCell(key: string): EntityCellImage exportRelationCell(key: string): RelationCellImage exportHasManyCell(key: string): HasManyCellImage + /** + * Images of every created, currently-unreachable entity (and its owned relation + * cells) reachable from the seed ids — the subgraph a memory sweep may reclaim + * before an undo needs it. Backs the journal's entry-closure invariant. + */ + exportUnreachableCreatedSubgraph(seedIds: ReadonlySet): JournalCellImage[] applyJournalImages(images: JournalCellImage[]): void } @@ -69,6 +80,25 @@ function cellRefKey(image: JournalCellImage): string { return `${image.kind}:${image.key}` } +/** + * Ids referenced by an entry's relation/has-many PRE-images — the roots of any + * created subgraph the gesture may have detached (has-one currentId; has-many + * members / planned add/remove). The unreachable-created gate filters the rest. + */ +function collectDetachedSeedIds(active: Map): Set { + const ids = new Set() + for (const image of active.values()) { + if (image.kind === 'relation') { + if (image.state?.currentId) ids.add(image.state.currentId) + } else if (image.kind === 'hasMany' && image.state) { + if (image.state.orderedIds) for (const id of image.state.orderedIds) ids.add(id) + for (const id of image.state.plannedAdditions.keys()) ids.add(id) + for (const id of image.state.plannedRemovals.keys()) ids.add(id) + } + } + return ids +} + export class UndoJournal { private depth = 0 /** Cells recorded in the currently-open transaction, deduped first-writer-wins. */ @@ -98,10 +128,25 @@ export class UndoJournal { const active = this.active this.active = null if (active && active.size > 0) { + this.closeOverUnreachableCreated(active) this.onCommit({ cells: [...active.values()] }) } } + /** + * Folds the detached created subgraph into the entry (first-writer-wins) so undo + * survives a later sweep of that subgraph. Runs before onCommit so the manager's + * debounce/group merge already sees the enriched cells. + */ + private closeOverUnreachableCreated(active: Map): void { + const seedIds = collectDetachedSeedIds(active) + if (seedIds.size === 0) return + for (const image of this.target.exportUnreachableCreatedSubgraph(seedIds)) { + const refKey = cellRefKey(image) + if (!active.has(refKey)) active.set(refKey, image) + } + } + /** Records the pre-image of an entity cell (snapshot + meta + root membership). */ recordEntity(key: string): void { this.record(`entity:${key}`, () => this.target.exportEntityCell(key)) diff --git a/tests/undo-sweep.test.ts b/tests/undo-sweep.test.ts new file mode 100644 index 00000000..7e309081 --- /dev/null +++ b/tests/undo-sweep.test.ts @@ -0,0 +1,357 @@ +import './setup' +import { describe, test, expect, beforeEach, mock } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + UndoManager, + BatchPersister, + MutationCollector, + ContemberSchemaMutationAdapter, + setField, + connectRelation, + disconnectRelation, + removeFromList, + moveInList, + type BackendAdapter, + type TransactionMutation, + type SchemaNames, +} from '@contember/bindx' + +/** + * CHARACTERIZATION TESTS — the sweep-vs-journal capture hole. + * + * When a created (never-persisted) child is DETACHED from a relation, the gesture + * only writes the PARENT's relation/has-many cell, so the journal entry records + * just that cell — not the child's entity snapshot nor its own relation cells. + * `sweepUnreachableCreated()` (run by BatchPersister after a persist settles, and + * by the React unmount cleanup) then reclaims the now-unreachable child OUTSIDE any + * journal transaction, so nothing is recorded. A later undo of the detach restores + * the parent's membership/currentId pointing at an entity whose snapshot is gone: + * a dangling relation reference and lost unsaved child data. + * + * Tests 1–5 are EXPECTED TO FAIL against today's code (the fix must enrich each + * journal entry at commit time with the entity/relation cell images of any created, + * currently-unreachable subtree referenced by the entry's relation/has-many + * pre-images). Test 6 is the minimality guard and must pass both before and after + * the fix. Assertions describe the DESIRED post-fix behavior. + */ +describe('undo — created child survives a sweep after detach', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let undo: UndoManager + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + undo = new UndoManager(store, { debounceMs: 0 }) // one entry per dispatch + dispatcher.addMiddleware(undo.createMiddleware()) + }) + + // ============================================================ + // 1. has-many detach survives the sweep + // ============================================================ + test('has-many: detached created child is restored (data + membership + create) after sweep + undo', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + // Gesture 1: add a created child to the list. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'orig' }, + }) + // Gesture 2: edit a scalar so the child carries unsaved data beyond its defaults. + dispatcher.dispatch(setField('Item', 'c1', ['name'], 'edited')) + // Gesture 3 (the detach we later undo): remove the child from the list. + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 'c1', 'disconnect')) + + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Item', 'c1')).toBeUndefined() + + undo.undo() + + // The child snapshot is back with its edited value… + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('edited') + // …its list membership is restored… + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('c1') + // …and it is reported as a create again. + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Item', + entityId: 'c1', + changeType: 'create', + }) + }) + + // ============================================================ + // 2. nested created subtree survives the sweep + // ============================================================ + test('nested subtree: detached child + its created grandchild are both restored after sweep + undo', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + // Gesture 1: add a created child C to the list. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'child' }, + }) + // Gesture 2: give C its own created grandchild G through a has-one relation. + store.transaction(() => { + store.createEntity('Detail', { id: 'g1', label: 'grand' }) + dispatcher.dispatch(connectRelation('Item', 'c1', 'detail', 'g1', 'Detail')) + }) + expect(store.getRelation('Item', 'c1', 'detail')?.currentId).toBe('g1') + + // Gesture 3 (the detach we later undo): remove C from the parent list. + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 'c1', 'disconnect')) + + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Item', 'c1')).toBeUndefined() + expect(store.getEntitySnapshot('Detail', 'g1')).toBeUndefined() + + undo.undo() + + // Whole subtree is back: child data, child's relation cell to the grandchild, + // and the grandchild snapshot/data. + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('child') + expect(store.getRelation('Item', 'c1', 'detail')?.currentId).toBe('g1') + expect(store.getEntitySnapshot<{ label: string }>('Detail', 'g1')?.data.label).toBe('grand') + }) + + // ============================================================ + // 3. has-one create → disconnect → sweep → undo + // ============================================================ + test('has-one: created target restored (entity + currentId) after disconnect + sweep + undo', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + + // Create + connect through the has-one path (mirrors HasOneHandle.create()). + store.transaction(() => { + store.createEntity('Author', { id: 'auth1', name: 'Written' }) + dispatcher.dispatch(connectRelation('Article', 'a', 'author', 'auth1', 'Author')) + }) + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('auth1') + + // The detach we later undo. + dispatcher.dispatch(disconnectRelation('Article', 'a', 'author')) + expect(store.getRelation('Article', 'a', 'author')?.state).toBe('disconnected') + + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Author', 'auth1')).toBeUndefined() + + undo.undo() + + // currentId points back at the target — and the target snapshot is back too + // (no dangling reference). + expect(store.getRelation('Article', 'a', 'author')?.currentId).toBe('auth1') + expect(store.getEntitySnapshot<{ name: string }>('Author', 'auth1')?.data.name).toBe('Written') + }) + + // ============================================================ + // 4. undo → redo → undo round-trip across a sweep + // ============================================================ + test('round-trip: detach → sweep → undo → redo → undo restores the child fully', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'child' }, + }) + dispatcher.dispatch(setField('Item', 'c1', ['name'], 'edited')) + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 'c1', 'disconnect')) + + store.sweepUnreachableCreated() + expect(store.getEntitySnapshot('Item', 'c1')).toBeUndefined() + + // Undo the detach: the child comes back. + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('edited') + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('c1') + + // Redo the detach: it is acceptable that the recreated (unreachable) child is + // removed from the list again. + undo.redo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).not.toContain('c1') + + // Undo again: the child + membership are fully restored. + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('edited') + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('c1') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Item', + entityId: 'c1', + changeType: 'create', + }) + }) + + // ============================================================ + // 5. persist-path integration (BatchPersister-triggered sweep) + // ============================================================ + test('persist path: after a real persist sweeps the detached child, undo brings it back and the next persist re-sends its create', async () => { + const schema: SchemaNames = { + entities: { + Article: { + name: 'Article', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + items: { type: 'many', entity: 'Item' }, + }, + }, + Item: { + name: 'Item', + scalars: ['id', 'name'], + fields: { + id: { type: 'column' }, + name: { type: 'column' }, + }, + }, + }, + enums: {}, + } + + let capturedMutations: readonly TransactionMutation[] = [] + const adapter: BackendAdapter = { + query: mock(() => Promise.resolve([])), + persist: mock(() => Promise.resolve({ ok: true })), + create: mock((_entityType: string, data: Record) => + Promise.resolve({ ok: true, data: { id: 'srv', ...data } }), + ), + delete: mock(() => Promise.resolve({ ok: true })), + persistTransaction: mock((mutations: readonly TransactionMutation[]) => { + capturedMutations = mutations + return Promise.resolve({ + ok: true, + results: mutations.map(m => ({ entityType: m.entityType, entityId: m.entityId, ok: true })), + }) + }), + } + + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + const mutationCollector = new MutationCollector(store, schemaAdapter) + const persister = new BatchPersister(adapter, store, dispatcher, { mutationCollector, undoManager: undo }) + + store.setEntityData('Article', 'p', { id: 'p', title: 'orig' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + // Gesture A: dirty the parent so the first persist has something to persist + // (and therefore reaches the BatchPersister sweep). + dispatcher.dispatch(setField('Article', 'p', ['title'], 'edited-title')) + // Gesture B/C: add + edit a created child. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'child' }, + }) + dispatcher.dispatch(setField('Item', 'c1', ['name'], 'child-edited')) + // Gesture D (the detach we later undo). + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 'c1', 'disconnect')) + + // Real persist — its post-settle sweep reclaims the unreachable child. + const firstResult = await persister.persistAll() + expect(firstResult.success).toBe(true) + expect(store.getEntitySnapshot('Item', 'c1')).toBeUndefined() + + // Undo the detach: the child (with its unsaved edit) must come back. + undo.undo() + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('child-edited') + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toContain('c1') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Item', + entityId: 'c1', + changeType: 'create', + }) + + // The next persist must send a create carrying the child's data (preserve-for-retry). + capturedMutations = [] + const secondResult = await persister.persistAll() + expect(secondResult.success).toBe(true) + expect(hasCreateWithName(capturedMutations, 'child-edited')).toBe(true) + }) + + // ============================================================ + // 6. entry minimality guard — must PASS before AND after the fix + // ============================================================ + test('minimality: a move over a list of REACHABLE created children round-trips without embedding them', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + // Two created children, both reachable — nothing is ever swept here. + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'first' }, + }) + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c2', name: 'second' }, + }) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['c1', 'c2']) + + // A gesture that writes only the has-many cell whose membership contains + // reachable created children. + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['c2', 'c1']) + + // Undo restores the order and both children survive intact — the move entry + // does not need to embed the still-reachable children's data. + undo.undo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['c1', 'c2']) + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c1')?.data.name).toBe('first') + expect(store.getEntitySnapshot<{ name: string }>('Item', 'c2')?.data.name).toBe('second') + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Item', + entityId: 'c1', + changeType: 'create', + }) + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Item', + entityId: 'c2', + changeType: 'create', + }) + + undo.redo() + expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['c2', 'c1']) + expect(store.getEntitySnapshot('Item', 'c1')).toBeDefined() + expect(store.getEntitySnapshot('Item', 'c2')).toBeDefined() + }) +}) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Recursively finds an inline `create` op whose data carries `name === value`. */ +function hasCreateWithName(mutations: readonly TransactionMutation[], value: string): boolean { + const visit = (node: unknown): boolean => { + if (Array.isArray(node)) return node.some(visit) + if (isRecord(node)) { + const create = node['create'] + if (isRecord(create) && create['name'] === value) return true + return Object.values(node).some(visit) + } + return false + } + return mutations.some(m => visit(m.data ?? {})) +} From f5be781510ea23dfcc20efa3ff6cc0a7bbbf1613 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 2 Jul 2026 13:50:48 +0200 Subject: [PATCH 27/28] fix(bindx): drop undo history when the store is cleared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing wired SnapshotStore.clear() to the undo system, so after a full store wipe (logout / teardown / schema switch) the stacks kept pre-images of the wiped world — undo would resurrect stale entities into an empty store and canUndo misreported. Latent today (only tests call clear()), but a real hole the moment a teardown path uses it. Mirrors the existing rekey forwarding: store → journal.clear() → onClear → UndoManager.clear(). A mid-gesture clear drops the open transaction's recorded cells while keeping begin/commit depth paired. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4 --- packages/bindx/src/store/SnapshotStore.ts | 2 + packages/bindx/src/undo/UndoJournal.ts | 9 +++ packages/bindx/src/undo/UndoManager.ts | 1 + tests/undo-clear.test.ts | 91 +++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/undo-clear.test.ts diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 59ebdc04..88dbb9a4 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1259,6 +1259,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { this.roots.clear() this.rekeyOrchestrator.clear() this.lastPropagatedData.clear() + // Undo history describes the now-wiped world; drop it with the store. + this.journal?.clear() this.subscriptions.notify() } diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts index 1966e347..91b420ff 100644 --- a/packages/bindx/src/undo/UndoJournal.ts +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -108,6 +108,7 @@ export class UndoJournal { private readonly target: JournalTarget, private readonly onCommit: (entry: JournalEntry) => void, private readonly onRekey?: (ctx: RekeyContext) => void, + private readonly onClear?: () => void, ) {} get isRecording(): boolean { @@ -188,6 +189,14 @@ export class UndoJournal { rekey(ctx: RekeyContext): void { this.onRekey?.(ctx) } + + /** Forwarded from the store on a full clear; drops all owner history and any open gesture. */ + clear(): void { + // A full store clear wipes the world these pre-images describe. Drop the open + // gesture's cells but keep `active` non-null so begin/commit depth stays paired. + if (this.active !== null) this.active = new Map() + this.onClear?.() + } } export { cellRefKey } diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index c6ff91c0..6629d7fd 100644 --- a/packages/bindx/src/undo/UndoManager.ts +++ b/packages/bindx/src/undo/UndoManager.ts @@ -52,6 +52,7 @@ export class UndoManager { store, entry => this.onEntry(entry), ctx => this.rekeyStacks(ctx), + () => this.clear(), ) } diff --git a/tests/undo-clear.test.ts b/tests/undo-clear.test.ts new file mode 100644 index 00000000..cdc87aba --- /dev/null +++ b/tests/undo-clear.test.ts @@ -0,0 +1,91 @@ +import './setup' +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + UndoManager, + setField, +} from '@contember/bindx' + +// A full store.clear() (logout / provider teardown / schema switch) wipes every +// entity; the undo/redo history must be dropped with it, otherwise undo would +// resurrect a stale, wiped world into an empty store. +describe('store.clear() wipes undo history', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let undoManager: UndoManager + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + undoManager = new UndoManager(store, { debounceMs: 0 }) // No debounce for tests + dispatcher.addMiddleware(undoManager.createMiddleware()) + }) + + test('drops the undo stack and makes undo a no-op', () => { + store.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + dispatcher.dispatch(setField('Article', '1', ['title'], 'Changed')) + expect(undoManager.getState().canUndo).toBe(true) + + store.clear() + + expect(undoManager.getState().canUndo).toBe(false) + expect(undoManager.getState().canRedo).toBe(false) + + // Undo must not resurrect the wiped entity. + undoManager.undo() + expect(store.hasEntity('Article', '1')).toBe(false) + expect(store.getEntitySnapshot('Article', '1')).toBeUndefined() + }) + + test('drops the redo stack too', () => { + store.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + dispatcher.dispatch(setField('Article', '1', ['title'], 'Changed')) + undoManager.undo() + expect(undoManager.getState().canRedo).toBe(true) + + store.clear() + + expect(undoManager.getState().canRedo).toBe(false) + undoManager.redo() + expect(store.hasEntity('Article', '1')).toBe(false) + }) + + test('drops a pending debounced group', () => { + const localStore = new SnapshotStore() + const localDispatcher = new ActionDispatcher(localStore) + const localUndo = new UndoManager(localStore, { debounceMs: 50 }) + localDispatcher.addMiddleware(localUndo.createMiddleware()) + + localStore.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + // Change lands in the pending group; do NOT flush (no timer wait). + localDispatcher.dispatch(setField('Article', '1', ['title'], 'Changed')) + expect(localUndo.getState().canUndo).toBe(true) + + localStore.clear() + + expect(localUndo.getState().canUndo).toBe(false) + localUndo.undo() + expect(localStore.hasEntity('Article', '1')).toBe(false) + }) + + test('clear mid-gesture drops recorded cells and keeps begin/commit pairing intact', () => { + store.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + + // Fire clear() while a journal transaction is open: the recorded pre-images + // describe the wiped world and must be dropped, without corrupting depth. + store.transaction(() => { + store.setFieldValue('Article', '1', ['title'], 'MidGesture') + store.clear() + }) + + expect(undoManager.getState().canUndo).toBe(false) + + // A fresh gesture after the mid-gesture clear must still record normally. + store.setEntityData('Article', '2', { id: '2', title: 'Fresh' }, true) + dispatcher.dispatch(setField('Article', '2', ['title'], 'FreshChanged')) + expect(undoManager.getState().canUndo).toBe(true) + undoManager.undo() + expect(store.getEntitySnapshot<{ title: string }>('Article', '2')?.data.title).toBe('Fresh') + }) +}) From 698ef6216885ac861f10ddc3a78fd295cc2f1a9f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 2 Jul 2026 14:13:48 +0200 Subject: [PATCH 28/28] feat(bindx): guard undo journal completeness with editable-write counters The "record before writing" convention behind the write-journal had no enforcement: a future mutating SnapshotStore method that forgets its journal.record* call silently corrupts undo. Sub-stores now bump a cheap editable-write counter at their write funnels (a different layer than the record calls, so a forgotten record still trips it); at each outermost transaction close the journal compares per-kind deltas against the kinds of recorded cells and throws UnrecordedWriteError naming the missing kind. Server ingestion, baseline commits, undo restore imports, rekey, sweep and clear() are classified as legitimately unjournaled and do not count. Always-on: integer bumps plus an O(1) comparison per gesture, verified false-positive-free across the full suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4 --- packages/bindx/src/index.ts | 2 +- packages/bindx/src/store/EntityMetaStore.ts | 14 + .../bindx/src/store/EntitySnapshotStore.ts | 18 + packages/bindx/src/store/HasManyStore.ts | 21 ++ packages/bindx/src/store/HasOneStore.ts | 14 + packages/bindx/src/store/RelationStore.ts | 10 + packages/bindx/src/store/RootRegistry.ts | 16 + packages/bindx/src/store/SnapshotStore.ts | 17 + packages/bindx/src/undo/UndoJournal.ts | 56 +++ .../bindx/src/undo/UnrecordedWriteError.ts | 23 ++ packages/bindx/src/undo/index.ts | 1 + tests/undo-journal-guard.test.ts | 322 ++++++++++++++++++ 12 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 packages/bindx/src/undo/UnrecordedWriteError.ts create mode 100644 tests/undo-journal-guard.test.ts diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 81c62734..4f611776 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -237,7 +237,7 @@ export { MutationCollector, ContemberSchemaMutationAdapter, type SchemaNames } f export type { MutationSchemaProvider, EntityMutationResult } from './contember/index.js' // Undo/Redo -export { UndoManager } from './undo/index.js' +export { UndoManager, UnrecordedWriteError } from './undo/index.js' export type { UndoManagerConfig, UndoState, diff --git a/packages/bindx/src/store/EntityMetaStore.ts b/packages/bindx/src/store/EntityMetaStore.ts index e8e23a1f..e880bd1a 100644 --- a/packages/bindx/src/store/EntityMetaStore.ts +++ b/packages/bindx/src/store/EntityMetaStore.ts @@ -52,10 +52,22 @@ export class EntityMetaStore implements Rekeyable { */ private mutationVersion = 0 + /** + * Monotonic counter bumped on EDITABLE-layer meta writes — deletion scheduling, + * the one undoable gesture this store owns. Server-baseline (`setExistsOnServer`), + * load/persist state, removal, rekey, import and clear deliberately do NOT bump + * it. Read by the undo write-guard (see UndoJournal). + */ + private editableWriteVersion = 0 + getMutationVersion(): number { return this.mutationVersion } + getEditableWriteVersion(): number { + return this.editableWriteVersion + } + // ==================== Load State ==================== getLoadState(key: string): EntityLoadState | undefined { @@ -95,11 +107,13 @@ export class EntityMetaStore implements Rekeyable { scheduleForDeletion(key: string): void { const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false } this.entityMetas.set(key, { ...existing, isScheduledForDeletion: true }) + this.editableWriteVersion++ } unscheduleForDeletion(key: string): void { const existing = this.entityMetas.get(key) ?? { existsOnServer: false, isScheduledForDeletion: false } this.entityMetas.set(key, { ...existing, isScheduledForDeletion: false }) + this.editableWriteVersion++ } isScheduledForDeletion(key: string): boolean { diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 71f4a8d0..84febbe3 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -33,10 +33,24 @@ export class EntitySnapshotStore implements Rekeyable { */ private mutationVersion = 0 + /** + * Monotonic counter bumped on every EDITABLE-layer snapshot write (local + * setData / updateFields / setFieldValue / reset) — the writes an undo gesture + * must first record. Server-baseline writes (server setData, refreshServerData, + * commit, commitFields) and non-write bookkeeping (remove, bumpVersion, import, + * rekey, clear) deliberately do NOT bump it. Read by the undo write-guard to + * detect a mutating path that wrote without recording. See UndoJournal. + */ + private editableWriteVersion = 0 + getMutationVersion(): number { return this.mutationVersion } + getEditableWriteVersion(): number { + return this.editableWriteVersion + } + get(key: string): EntitySnapshot | undefined { return this.snapshots.get(key) } @@ -77,6 +91,7 @@ export class EntitySnapshotStore implements Rekeyable { this.snapshots.set(key, newSnapshot) this.idIndex.set(id, key) if (!existing) this.mutationVersion++ + if (!isServerData) this.editableWriteVersion++ return newSnapshot } @@ -151,6 +166,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ return newSnapshot } @@ -172,6 +188,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ return true } @@ -209,6 +226,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ } /** diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 045cffa6..5ea94cb7 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -92,10 +92,23 @@ export class HasManyStore { private mutationVersion = 0 + /** + * Monotonic counter bumped on EDITABLE-layer has-many writes (plan add/remove, + * add, connectExisting, remove, move, reset) — the writes an undo gesture must + * first record. Server- id ingestion (setHasManyServerIds), materialization + * (getOrCreateHasMany), post-persist commit, import, replaceEntityId, rekey and + * delete deliberately do NOT bump it. Read by the undo write-guard (see UndoJournal). + */ + private editableWriteVersion = 0 + getMutationVersion(): number { return this.mutationVersion } + getEditableWriteVersion(): number { + return this.editableWriteVersion + } + /** * The single write chokepoint. Reconciles the edge index by diffing the live * members of the previous state against the next, so every state-changing path @@ -215,6 +228,7 @@ export class HasManyStore { version: existing.version + 1, }) } + this.editableWriteVersion++ } /** @@ -251,6 +265,7 @@ export class HasManyStore { version: existing.version + 1, }) } + this.editableWriteVersion++ } /** @@ -282,6 +297,7 @@ export class HasManyStore { plannedAdditions: new Map(), version: existing.version + 1, }) + this.editableWriteVersion++ } /** @@ -311,6 +327,7 @@ export class HasManyStore { version: existing.version + 1, }) } + this.editableWriteVersion++ } /** @@ -351,6 +368,7 @@ export class HasManyStore { version: existing.version + 1, }) } + this.editableWriteVersion++ } /** @@ -392,8 +410,10 @@ export class HasManyStore { } this.writeHasMany(key, newState) + this.editableWriteVersion++ return true } else { + // planHasManyRemoval bumps editableWriteVersion itself. this.planHasManyRemoval(key, itemId, removalType) return true } @@ -422,6 +442,7 @@ export class HasManyStore { orderedIds: newOrderedIds, version: existing.version + 1, }) + this.editableWriteVersion++ } /** diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index bd530915..74331078 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -35,10 +35,22 @@ export class HasOneStore { private mutationVersion = 0 + /** + * Monotonic counter bumped on EDITABLE-layer has-one writes (setRelation / + * resetRelation) — the writes an undo gesture must first record. Materialization + * (getOrCreateRelation), post-persist commit, import, replaceEntityId, rekey and + * delete deliberately do NOT bump it. Read by the undo write-guard (see UndoJournal). + */ + private editableWriteVersion = 0 + getMutationVersion(): number { return this.mutationVersion } + getEditableWriteVersion(): number { + return this.editableWriteVersion + } + /** * The single write chokepoint. Reconciles the edge index by diffing the live * child of the previous state against the next, so every state-changing path @@ -130,6 +142,7 @@ export class HasOneStore { version: existing.version + 1, }) } + this.editableWriteVersion++ } /** @@ -162,6 +175,7 @@ export class HasOneStore { placeholderData: {}, version: existing.version + 1, }) + this.editableWriteVersion++ } /** diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 24403065..b441a924 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -39,6 +39,16 @@ export class RelationStore implements Rekeyable { return this.hasOne.getMutationVersion() + this.hasMany.getMutationVersion() } + /** Editable-layer has-one write counter — read by the undo write-guard. */ + getEditableHasOneWriteVersion(): number { + return this.hasOne.getEditableWriteVersion() + } + + /** Editable-layer has-many write counter — read by the undo write-guard. */ + getEditableHasManyWriteVersion(): number { + return this.hasMany.getEditableWriteVersion() + } + // ==================== Has-One Relations ==================== getOrCreateRelation( diff --git a/packages/bindx/src/store/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts index e6790592..9008afc2 100644 --- a/packages/bindx/src/store/RootRegistry.ts +++ b/packages/bindx/src/store/RootRegistry.ts @@ -27,19 +27,35 @@ export class RootRegistry implements Rekeyable { */ private mutationVersion = 0 + /** + * Monotonic counter bumped on EDITABLE-layer root changes — the create-root + * register/unregister that back an undoable gesture (createEntity, + * registerParentChild). The persist rekey and full clear deliberately do NOT + * bump it. Read by the undo write-guard (see UndoJournal). The unjournaled + * register/unregister callers (undo restore, memory sweep, unmount cleanup) all + * run outside a journal transaction, so counting them here is harmless. + */ + private editableWriteVersion = 0 + register(key: string): void { if (!this.roots.has(key)) { this.roots.add(key) this.mutationVersion++ + this.editableWriteVersion++ } } unregister(key: string): void { if (this.roots.delete(key)) { this.mutationVersion++ + this.editableWriteVersion++ } } + getEditableWriteVersion(): number { + return this.editableWriteVersion + } + keys(): IterableIterator { return this.roots.keys() } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 88dbb9a4..379b7bf0 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -25,6 +25,7 @@ import type { EntityCellImage, RelationCellImage, HasManyCellImage, + EditableWriteCounters, } from '../undo/UndoJournal.js' export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' @@ -121,6 +122,22 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { } } + /** + * Aggregates the sub-stores' editable-layer write counters into the three + * journal kinds. The "entity" kind fuses snapshot + meta + roots — the cells a + * single {@link recordEntity} pre-image captures together. Read by the journal's + * write-guard at each transaction boundary; a few integer adds, hot-path cheap. + */ + getEditableWriteCounters(): EditableWriteCounters { + return { + entity: this.entitySnapshots.getEditableWriteVersion() + + this.meta.getEditableWriteVersion() + + this.roots.getEditableWriteVersion(), + relation: this.relations.getEditableHasOneWriteVersion(), + hasMany: this.relations.getEditableHasManyWriteVersion(), + } + } + /** * Opens a journal transaction (re-entrant). All primary-store writes until the * matching {@link commitTransaction} are captured as ONE undoable gesture. diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts index 91b420ff..3f407e1b 100644 --- a/packages/bindx/src/undo/UndoJournal.ts +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -1,6 +1,7 @@ import type { EntitySnapshot } from '../store/snapshots.js' import type { StoredRelationState, StoredHasManyState } from '../store/RelationStore.js' import type { RekeyContext } from '../store/RekeyOrchestrator.js' +import { UnrecordedWriteError } from './UnrecordedWriteError.js' /** * Write-journal for undo/redo. @@ -53,6 +54,21 @@ export interface HasManyCellImage { export type JournalCellImage = EntityCellImage | RelationCellImage | HasManyCellImage +/** The kind axis shared by cell images, editable-write counters, and the guard. */ +export type JournalCellKind = JournalCellImage['kind'] + +/** + * Monotonic editable-layer write counters, one per journal kind. Provided by the + * store so the journal can prove — at transaction close — that every editable + * write was preceded by a record call (see {@link UndoJournal.commit}). Counters + * only ever increase, so a per-transaction delta is `now - base`. + */ +export interface EditableWriteCounters { + readonly entity: number + readonly relation: number + readonly hasMany: number +} + /** One undoable unit: the editable-layer pre-images of every cell a gesture touched. */ export interface JournalEntry { readonly cells: JournalCellImage[] @@ -74,6 +90,12 @@ export interface JournalTarget { */ exportUnreachableCreatedSubgraph(seedIds: ReadonlySet): JournalCellImage[] applyJournalImages(images: JournalCellImage[]): void + /** + * Current editable-layer write counters across the sub-stores. Read once when a + * transaction opens and again when it closes to detect writes that skipped their + * record call. Must be O(1) — it is on the write hot path. + */ + getEditableWriteCounters(): EditableWriteCounters } function cellRefKey(image: JournalCellImage): string { @@ -103,6 +125,8 @@ export class UndoJournal { private depth = 0 /** Cells recorded in the currently-open transaction, deduped first-writer-wins. */ private active: Map | null = null + /** Editable-write counters snapshotted when the outermost transaction opened. */ + private baseCounters: EditableWriteCounters | null = null constructor( private readonly target: JournalTarget, @@ -118,6 +142,7 @@ export class UndoJournal { begin(): void { if (this.depth === 0) { this.active = new Map() + this.baseCounters = this.target.getEditableWriteCounters() } this.depth++ } @@ -128,12 +153,41 @@ export class UndoJournal { if (this.depth > 0) return const active = this.active this.active = null + // Guard runs even for an empty entry: a write that skipped its record call + // leaves zero cells yet still advanced the counters — that is the bug to catch. + this.assertAllWritesRecorded(active) + this.baseCounters = null if (active && active.size > 0) { this.closeOverUnreachableCreated(active) this.onCommit({ cells: [...active.values()] }) } } + /** + * Coarse per-kind invariant check at transaction close: for each journal kind + * whose editable-write counter advanced during the transaction, at least one + * cell of that kind must have been recorded. A positive delta with no recorded + * cell means a mutating method wrote without recording — undo would restore + * stale/partial state — so fail loudly naming the offending kind(s). + */ + private assertAllWritesRecorded(active: Map | null): void { + const base = this.baseCounters + if (base === null) return + const now = this.target.getEditableWriteCounters() + + const recorded: Record = { entity: false, relation: false, hasMany: false } + if (active) { + for (const image of active.values()) recorded[image.kind] = true + } + + const unrecorded: JournalCellKind[] = [] + if (now.entity > base.entity && !recorded.entity) unrecorded.push('entity') + if (now.relation > base.relation && !recorded.relation) unrecorded.push('relation') + if (now.hasMany > base.hasMany && !recorded.hasMany) unrecorded.push('hasMany') + + if (unrecorded.length > 0) throw new UnrecordedWriteError(unrecorded) + } + /** * Folds the detached created subgraph into the entry (first-writer-wins) so undo * survives a later sweep of that subgraph. Runs before onCommit so the manager's @@ -194,7 +248,9 @@ export class UndoJournal { clear(): void { // A full store clear wipes the world these pre-images describe. Drop the open // gesture's cells but keep `active` non-null so begin/commit depth stays paired. + // Null the baseline too: the wiped transaction can't be meaningfully guarded. if (this.active !== null) this.active = new Map() + this.baseCounters = null this.onClear?.() } } diff --git a/packages/bindx/src/undo/UnrecordedWriteError.ts b/packages/bindx/src/undo/UnrecordedWriteError.ts new file mode 100644 index 00000000..fa4c8fdb --- /dev/null +++ b/packages/bindx/src/undo/UnrecordedWriteError.ts @@ -0,0 +1,23 @@ +import type { JournalCellKind } from './UndoJournal.js' + +/** + * Thrown when a journal transaction closes after an editable-layer store write + * that was NOT preceded by a matching `journal.record*` call — i.e. a mutating + * SnapshotStore method wrote primary state a gesture can no longer undo. + * + * This turns the "every mutating method must record before writing" convention + * into a checked invariant: the sub-store editable-write counters advanced for a + * kind the transaction never recorded. The named kinds pinpoint which record call + * is missing (recordEntity / recordRelation / recordHasMany). + */ +export class UnrecordedWriteError extends Error { + constructor(public readonly kinds: ReadonlyArray) { + super( + `Undo journal invariant violated: editable-layer ${kinds.join(', ')} write(s) happened ` + + `inside a journal transaction without a preceding record call. Every mutating SnapshotStore ` + + `method must call journal.recordEntity/recordRelation/recordHasMany before writing so the ` + + `gesture can be undone. Unrecorded kind(s): ${kinds.join(', ')}.`, + ) + this.name = 'UnrecordedWriteError' + } +} diff --git a/packages/bindx/src/undo/index.ts b/packages/bindx/src/undo/index.ts index 6701fe6b..a5d9ee4b 100644 --- a/packages/bindx/src/undo/index.ts +++ b/packages/bindx/src/undo/index.ts @@ -1,4 +1,5 @@ export { UndoManager } from './UndoManager.js' +export { UnrecordedWriteError } from './UnrecordedWriteError.js' export type { UndoManagerConfig, UndoState } from './types.js' export type { JournalEntry, diff --git a/tests/undo-journal-guard.test.ts b/tests/undo-journal-guard.test.ts new file mode 100644 index 00000000..5ece506d --- /dev/null +++ b/tests/undo-journal-guard.test.ts @@ -0,0 +1,322 @@ +import './setup' +import { describe, test, expect, beforeEach, mock } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + UndoManager, + UnrecordedWriteError, + BatchPersister, + MutationCollector, + ContemberSchemaMutationAdapter, + setField, + connectRelation, + removeFromList, + type BackendAdapter, + type TransactionMutation, + type SchemaNames, +} from '@contember/bindx' +import { + UndoJournal, + type JournalTarget, + type JournalCellImage, + type EntityCellImage, + type RelationCellImage, + type HasManyCellImage, + type EditableWriteCounters, +} from '../packages/bindx/src/undo/UndoJournal.js' + +/** + * The undo write-guard turns the "record before you write" convention into a + * checked invariant. At each transaction boundary the journal reads the + * sub-stores' editable-write counters; if a kind's counter advanced but the + * transaction recorded no cell of that kind, {@link UnrecordedWriteError} is + * thrown naming the offending kind — catching a mutating method that forgot to + * record (which would otherwise corrupt undo silently). + * + * Coverage: a real forgotten-record path (positive), the guard logic per kind via + * a rigged {@link JournalTarget}, and a battery of legitimately-unjournaled paths + * that must stay silent (server ingestion, undo/redo apply, persist + baseline + * commit, and no-transaction writes). + */ +describe('undo journal write-guard', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let undo: UndoManager + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + undo = new UndoManager(store, { debounceMs: 0 }) + dispatcher.addMiddleware(undo.createMiddleware()) + }) + + // ============================================================ + // Positive — a real editable write that skips its record call + // ============================================================ + describe('unrecorded editable write throws', () => { + test('meta write on a snapshotless entity (recordExistingEntity no-ops) is caught', () => { + // scheduleForDeletion writes meta unconditionally, but recordExistingEntity + // only records when a snapshot exists — so a snapshotless target is an + // editable write with no pre-image: exactly the corruption the guard exists + // to catch. (A real load always creates the snapshot first, so this cannot + // happen through the normal UI flow.) + let caught: unknown + try { + store.transaction(() => store.scheduleForDeletion('Article', 'ghost')) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(UnrecordedWriteError) + if (!(caught instanceof UnrecordedWriteError)) throw new Error('expected UnrecordedWriteError') + expect(caught.kinds).toEqual(['entity']) + expect(caught.message).toContain('entity') + }) + }) + + // ============================================================ + // Guard logic per kind — driven through a rigged JournalTarget + // ============================================================ + describe('guard logic (rigged target)', () => { + class RiggedTarget implements JournalTarget { + readonly counters: { entity: number; relation: number; hasMany: number } = { + entity: 0, + relation: 0, + hasMany: 0, + } + + getEditableWriteCounters(): EditableWriteCounters { + return { ...this.counters } + } + + exportEntityCell(key: string): EntityCellImage { + return { kind: 'entity', key, present: false } + } + + exportRelationCell(key: string): RelationCellImage { + return { kind: 'relation', key, present: false } + } + + exportHasManyCell(key: string): HasManyCellImage { + return { kind: 'hasMany', key, present: false } + } + + exportUnreachableCreatedSubgraph(): JournalCellImage[] { + return [] + } + + applyJournalImages(): void {} + } + + const makeJournal = (target: JournalTarget): UndoJournal => { + return new UndoJournal(target, () => {}) + } + + test.each(['entity', 'relation', 'hasMany'] as const)( + 'an unrecorded %s write throws naming that kind', + kind => { + const target = new RiggedTarget() + const journal = makeJournal(target) + + journal.begin() + target.counters[kind]++ // an editable write with no matching record call + let caught: unknown + try { + journal.commit() + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(UnrecordedWriteError) + if (!(caught instanceof UnrecordedWriteError)) throw new Error('expected UnrecordedWriteError') + expect(caught.kinds).toEqual([kind]) + expect(caught.message).toContain(kind) + }) + + test('a write preceded by its record call does not throw', () => { + const target = new RiggedTarget() + const journal = makeJournal(target) + + journal.begin() + target.counters.relation++ + journal.recordRelation('Article:a:author') // the matching record call + expect(() => journal.commit()).not.toThrow() + }) + + test('multiple unrecorded kinds are all named', () => { + const target = new RiggedTarget() + const journal = makeJournal(target) + + journal.begin() + target.counters.entity++ + target.counters.hasMany++ + let caught: unknown + try { + journal.commit() + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(UnrecordedWriteError) + if (!(caught instanceof UnrecordedWriteError)) throw new Error('expected UnrecordedWriteError') + expect(caught.kinds).toEqual(['entity', 'hasMany']) + }) + + test('a no-op transaction (no writes) does not throw', () => { + const target = new RiggedTarget() + const journal = makeJournal(target) + journal.begin() + expect(() => journal.commit()).not.toThrow() + }) + + test('only the outermost transaction is checked (nested writes fold up)', () => { + const target = new RiggedTarget() + const journal = makeJournal(target) + + journal.begin() // outer + journal.begin() // inner + target.counters.entity++ + journal.recordEntity('Article:a') + expect(() => journal.commit()).not.toThrow() // inner commit: no check yet + expect(() => journal.commit()).not.toThrow() // outer commit: recorded, silent + }) + }) + + // ============================================================ + // Negative — legitimate journaled gestures stay silent + // ============================================================ + describe('journaled gestures do not throw', () => { + test('a normal field edit', () => { + store.setEntityData('Article', 'a', { id: 'a', title: 'A' }, true) + expect(() => dispatcher.dispatch(setField('Article', 'a', ['title'], 'B'))).not.toThrow() + }) + + test('a has-many add (create + list write in one gesture)', () => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + expect(() => + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'x' }, + }), + ).not.toThrow() + }) + + test('a has-one connect', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + store.setEntityData('Author', 'b', { id: 'b' }, true) + expect(() => + dispatcher.dispatch(connectRelation('Article', 'a', 'author', 'b', 'Author')), + ).not.toThrow() + }) + + test('scheduleForDeletion of a loaded entity (snapshot present)', () => { + store.setEntityData('Article', 'a', { id: 'a' }, true) + expect(() => store.transaction(() => store.scheduleForDeletion('Article', 'a'))).not.toThrow() + }) + }) + + // ============================================================ + // Negative — legitimately-unjournaled writes stay silent + // ============================================================ + describe('unjournaled writes do not throw', () => { + test('server-data ingestion inside a transaction (isServerData does not count)', () => { + expect(() => + store.transaction(() => { + store.setEntityData('Article', 'a', { id: 'a', title: 'srv' }, true) + store.setHasManyServerIds('Article', 'a', 'items', ['x']) + }), + ).not.toThrow() + }) + + test('baseline commit (commitEntity / commitFields) inside a transaction', () => { + store.setEntityData('Article', 'a', { id: 'a', title: 'A' }, true) + store.setFieldValue('Article', 'a', ['title'], 'B') + expect(() => + store.transaction(() => { + store.commitEntity('Article', 'a') + store.commitFields('Article', 'a', ['title']) + }), + ).not.toThrow() + }) + + test('undo then redo apply do not throw', () => { + store.setEntityData('Article', 'a', { id: 'a', title: 'A' }, true) + dispatcher.dispatch(setField('Article', 'a', ['title'], 'B')) + expect(() => undo.undo()).not.toThrow() + expect(() => undo.redo()).not.toThrow() + }) + + test('a write with no transaction open is never checked', () => { + store.setEntityData('Article', 'a', { id: 'a', title: 'A' }, true) + // Direct editable write, no begin/commit around it: the guard cannot run and + // no journal recording is expected by design. + expect(() => store.setFieldValue('Article', 'a', ['title'], 'B')).not.toThrow() + }) + + test('a full persist flow (with post-settle sweep) does not throw', async () => { + const schema: SchemaNames = { + entities: { + Article: { + name: 'Article', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + items: { type: 'many', entity: 'Item' }, + }, + }, + Item: { + name: 'Item', + scalars: ['id', 'name'], + fields: { + id: { type: 'column' }, + name: { type: 'column' }, + }, + }, + }, + enums: {}, + } + + const adapter: BackendAdapter = { + query: mock(() => Promise.resolve([])), + persist: mock(() => Promise.resolve({ ok: true })), + create: mock((_entityType: string, data: Record) => + Promise.resolve({ ok: true, data: { id: 'srv', ...data } }), + ), + delete: mock(() => Promise.resolve({ ok: true })), + persistTransaction: mock((mutations: readonly TransactionMutation[]) => + Promise.resolve({ + ok: true, + results: mutations.map(m => ({ entityType: m.entityType, entityId: m.entityId, ok: true })), + }), + ), + } + + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + const mutationCollector = new MutationCollector(store, schemaAdapter) + const persister = new BatchPersister(adapter, store, dispatcher, { mutationCollector, undoManager: undo }) + + store.setEntityData('Article', 'p', { id: 'p', title: 'orig' }, true) + store.setHasManyServerIds('Article', 'p', 'items', []) + + dispatcher.dispatch(setField('Article', 'p', ['title'], 'edited')) + dispatcher.dispatch({ + type: 'ADD_TO_LIST', + entityType: 'Article', + entityId: 'p', + fieldName: 'items', + targetType: 'Item', + itemData: { id: 'c1', name: 'child' }, + }) + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 'c1', 'disconnect')) + + const result = await persister.persistAll() + expect(result.success).toBe(true) + }) + }) +})