diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index db022619..15390e63 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -283,6 +283,26 @@ export function useEntityList( [store], ) + // Discard never-persisted drafts when the list unmounts. List-level creates are + // 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.unregisterRootEntity(entityType, item.id) + unrooted = true + } + } + if (unrooted) { + store.sweepUnreachableCreated() + } + } + }, [store, entityType]) + // --- Snapshot --- const getSnapshot = useCallback((): UseEntityListResult => { const state = listStateRef.current 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-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index c0e459c8..f229ca83 100644 --- a/packages/bindx-react/src/jsx/components/Entity.tsx +++ b/packages/bindx-react/src/jsx/components/Entity.tsx @@ -185,6 +185,28 @@ function EntityCreateMode({ return id }, [entityType, store]) + // 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 () => { + if (!store.getPersistedId(entityType, tempId)) { + store.unregisterRootEntity(entityType, tempId) + store.sweepUnreachableCreated() + } + } + }, [store, entityType, tempId]) + // 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..4049884e 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() } } @@ -108,16 +115,21 @@ export class ActionDispatcher { // Capture state before execution (for after events) const stateBefore = captureStateBeforeAction(action, this.store) - // Execute the action - this.execute(action) + this.store.beginTransaction() + try { + // 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() + } } /** @@ -174,9 +186,26 @@ export class ActionDispatcher { const index = this.middlewares.indexOf(middleware) if (index !== -1) { this.middlewares.splice(index, 1) + if (!this.middlewares.includes(middleware)) { + middleware.dispose?.() + } } } + /** + * 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. */ @@ -216,7 +245,7 @@ export class ActionDispatcher { ) break - case 'CONNECT_RELATION': + case 'CONNECT_RELATION': { this.store.setRelation( action.entityType, action.entityId, @@ -228,30 +257,33 @@ export class ActionDispatcher { ) this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.targetId) break + } - case 'DISCONNECT_RELATION': - this.store.setRelation( - action.entityType, - action.entityId, - action.fieldName, - { - currentId: null, - state: 'disconnected', - placeholderData: {}, - }, - ) + case 'DISCONNECT_RELATION': { + this.disconnectRelation(action.entityType, action.entityId, action.fieldName) 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.disconnectRelation(action.entityType, action.entityId, action.fieldName) + } else { + this.store.setRelation( + action.entityType, + action.entityId, + action.fieldName, + { + state: 'deleted', + }, + ) + } break + } case 'RESET_RELATION': this.store.resetRelation( @@ -356,6 +388,7 @@ export class ActionDispatcher { action.entityType, action.entityId, action.isPersisting, + action.pessimistic ?? false, ) break @@ -438,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/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/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/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/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 6ab29ce6..483ff73c 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 ) } @@ -378,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) + // 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), - ) + // 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 35faaecd..4c727ea7 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,141 @@ 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 embeddedData = this.readEmbeddedRelatedData() + const embeddedReference = readEmbeddedRelatedReference(embeddedData) + + if (!existing) { + if (embeddedReference.kind !== 'connected') return + const serverId = this.readServerRelatedId() + this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, { + currentId: embeddedReference.id, + 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, embeddedReference, embeddedData) + } + + /** + * 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. + * + * 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, + embeddedReference: EmbeddedRelatedReference, + embeddedData: unknown, + ): void { + if (embeddedReference.kind === 'absent') return + if (this.isLocallyDirty(existing)) return + + 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 } - return null + 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: embeddedReference.id, + serverId: embeddedReference.id, + state: 'connected', + serverState: 'connected', + }, true) + } + + 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 server data, or null. */ + private readServerRelatedId(): string | null { + return extractRelatedId(this.getServerData()?.[this.fieldName]) } /** @@ -354,6 +446,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 +454,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) } /** @@ -375,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 + }) } /** @@ -593,3 +686,28 @@ 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 not an object with a string id. + */ +function extractRelatedId(embedded: unknown): string | null { + const reference = readEmbeddedRelatedReference(embedded) + return reference.kind === 'connected' ? reference.id : null +} diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 304bacd9..4f611776 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -237,13 +237,10 @@ 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, - UndoEntry, - PartialStoreSnapshot, - StoreAffectedKeys, } from './undo/index.js' // Store types (for undo) diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 8c5a1f7a..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 */ @@ -206,55 +193,42 @@ 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)) } // 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) { // Nothing to persist - return { + result = { success: true, results: [], successCount: 0, failedCount: 0, skippedCount: 0, } - } - - // 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) - } - } + return result } // Execute transaction const transactionResult = await this.executeTransaction(mutations, options?.signal) - // Process results with captured state for pessimistic mode - return 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 { // Clear in-flight status @@ -267,6 +241,14 @@ export class BatchPersister { // Unblock undo this.undoManager?.unblock() + + // Reclaim memory: drop snapshots of any created entities orphaned during + // 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() + } } } @@ -663,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 @@ -680,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.restoreAndCommitEntityState(capturedState) - } + // 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 @@ -747,9 +719,11 @@ 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) { + // 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) } } @@ -784,71 +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 and commits it. - * Used in pessimistic mode after successful server confirmation. - */ - private restoreAndCommitEntityState(capturedState: CapturedEntityState): 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 - ) - } - - // Restore and commit relations - 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, - }) - 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) - } - 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/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/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..e880bd1a 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,8 +36,37 @@ 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() + /** + * 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 + * entity removal/rekey. Load state and deletion scheduling do NOT bump it. + * Used by {@link ReachabilityAnalyzer} to memoize its walk. + */ + 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 ==================== @@ -60,8 +89,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 { @@ -71,11 +107,13 @@ export class EntityMetaStore { 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 { @@ -88,60 +126,72 @@ export class EntityMetaStore { return this.persistingEntities.has(key) } - setPersisting(key: string, isPersisting: boolean): void { + setPersisting(key: string, isPersisting: boolean, pessimistic: boolean = false): void { if (isPersisting) { - this.persistingEntities.add(key) + if (!this.persistingEntities.has(key)) { + this.persistingEntities.add(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 { - this.persistingEntities.delete(key) + if (this.persistingEntities.delete(key)) { + this.mutationVersion++ + } + this.pessimisticInFlight.delete(key) } } - // ==================== 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 + isPessimisticInFlight(key: string): boolean { + return this.pessimisticInFlight.has(key) } - 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). + */ + remove(key: string): void { + this.loadStates.delete(key) + this.entityMetas.delete(key) + this.persistingEntities.delete(key) + this.pessimisticInFlight.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) + this.loadStates.delete(ctx.oldKey) + this.loadStates.set(ctx.newKey, loadState) } - if (this.persistingEntities.has(oldKey)) { - this.persistingEntities.delete(oldKey) - this.persistingEntities.add(newKey) + if (this.persistingEntities.has(ctx.oldKey)) { + this.persistingEntities.delete(ctx.oldKey) + this.persistingEntities.add(ctx.newKey) } - // Move temp ID mapping - const persistedId = this.tempToPersistedId.get(oldKey) - if (persistedId !== undefined) { - this.tempToPersistedId.delete(oldKey) - this.tempToPersistedId.set(newKey, persistedId) + if (this.pessimisticInFlight.has(ctx.oldKey)) { + this.pessimisticInFlight.delete(ctx.oldKey) + this.pessimisticInFlight.add(ctx.newKey) } + + this.mutationVersion++ + this.setExistsOnServer(ctx.newKey, true) } // ==================== Bulk Operations ==================== @@ -158,15 +208,19 @@ 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 { this.loadStates.clear() this.entityMetas.clear() this.persistingEntities.clear() - this.tempToPersistedId.clear() + this.pessimisticInFlight.clear() + this.mutationVersion++ } } diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 47b487b4..84febbe3 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,9 +13,44 @@ 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() + /** + * 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() + + /** + * 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 + + /** + * 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) } @@ -53,6 +89,9 @@ export class EntitySnapshotStore { ) this.snapshots.set(key, newSnapshot) + this.idIndex.set(id, key) + if (!existing) this.mutationVersion++ + if (!isServerData) this.editableWriteVersion++ return newSnapshot } @@ -127,6 +166,7 @@ export class EntitySnapshotStore { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ return newSnapshot } @@ -148,6 +188,7 @@ export class EntitySnapshotStore { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ return true } @@ -185,12 +226,23 @@ export class EntitySnapshotStore { ) this.snapshots.set(key, newSnapshot) + this.editableWriteVersion++ } /** * Removes an entity snapshot. */ remove(key: string): void { + const existing = this.snapshots.get(key) + // 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.mutationVersion++ + } this.snapshots.delete(key) } @@ -263,40 +315,58 @@ 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) } + if (keys.size > 0) this.mutationVersion++ return keys } /** - * 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(ctx.newId, ctx.newKey) + this.mutationVersion++ } keys(): IterableIterator { return this.snapshots.keys() } + /** + * 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) + } + clear(): void { this.snapshots.clear() + this.idIndex.clear() + 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/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts new file mode 100644 index 00000000..5ea94cb7 --- /dev/null +++ b/packages/bindx/src/store/HasManyStore.ts @@ -0,0 +1,690 @@ +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 + 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() + + /** + * 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 + + /** + * 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 + * (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++ + } + + /** + * 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, + }) + } + this.editableWriteVersion++ + } + + /** + * 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.editableWriteVersion++ + } + + /** + * 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, + }) + this.editableWriteVersion++ + } + + /** + * 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.editableWriteVersion++ + } + + /** + * 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') + } + // 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, + plannedAdditions: newPlannedAdditions, + version: existing.version + 1, + }) + } + this.editableWriteVersion++ + } + + /** + * 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) + this.editableWriteVersion++ + return true + } else { + // planHasManyRemoval bumps editableWriteVersion itself. + 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, + }) + this.editableWriteVersion++ + } + + /** + * 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:") — an O(degree) read of the edge index. + */ + collectLiveChildIds(keyPrefix: string, ids: Set): void { + this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids) + } + + /** + * Adds the composite parent keys of every LIVE has-many edge containing + * {@link childId} — an O(degree) read of the edge index, the exact reverse of + * {@link collectLiveChildIds}. + */ + collectParentKeysForChild(childId: string, parents: Set): void { + 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. + */ + 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}. + */ + removeOwnedRelations(keyPrefix: string): void { + for (const key of [...this.hasManyStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.deleteHasMany(key) + } + } + } + + /** + * 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). + * 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][] = [] + for (const [key, value] of this.hasManyStates) { + if (key.startsWith(oldKeyPrefix)) { + toMove.push([key, value]) + } + } + for (const [oldKey, value] of toMove) { + this.deleteHasMany(oldKey) + this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + } + } + + /** + * Clears all has-many relation data. + */ + 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 new file mode 100644 index 00000000..74331078 --- /dev/null +++ b/packages/bindx/src/store/HasOneStore.ts @@ -0,0 +1,356 @@ +import type { HasOneRelationState } from '../handles/types.js' +import type { EntitySnapshot } from './snapshots.js' +import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' +import { RelationEdgeIndex } from './RelationEdgeIndex.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() + + /** + * 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 + + /** + * 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 + * (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++ + } + + /** + * 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, + }) + } + this.editableWriteVersion++ + } + + /** + * 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, + }) + this.editableWriteVersion++ + } + + /** + * Collects the ids of child entities reachable through LIVE has-one edges + * (key prefix "parentType:parentId:") — an O(degree) read of the edge index. + */ + collectLiveChildIds(keyPrefix: string, ids: Set): void { + this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids) + } + + /** + * Adds the composite parent keys of every LIVE has-one edge pointing at + * {@link childId} — an O(degree) read of the edge index, the exact reverse of + * {@link collectLiveChildIds}. + */ + collectParentKeysForChild(childId: string, parents: Set): void { + 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. + */ + 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}. + */ + removeOwnedRelations(keyPrefix: string): void { + for (const key of [...this.relationStates.keys()]) { + if (key.startsWith(keyPrefix)) { + this.deleteRelation(key) + } + } + } + + /** + * 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). + * 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][] = [] + for (const [key, value] of this.relationStates) { + if (key.startsWith(oldKeyPrefix)) { + toMove.push([key, value]) + } + } + for (const [oldKey, value] of toMove) { + this.deleteRelation(oldKey) + this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value) + } + } + + /** + * Clears all has-one relation data. + */ + 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/ReachabilityAnalyzer.ts b/packages/bindx/src/store/ReachabilityAnalyzer.ts new file mode 100644 index 00000000..5ed9460b --- /dev/null +++ b/packages/bindx/src/store/ReachabilityAnalyzer.ts @@ -0,0 +1,129 @@ +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. + * + * 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( + private readonly entitySnapshots: EntitySnapshotStore, + private readonly meta: EntityMetaStore, + private readonly relations: RelationStore, + 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[] = [] + + // 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()) { + 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)) { + 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/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/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/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index d3525411..b441a924 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -1,852 +1,298 @@ -import type { HasOneRelationState } from '../handles/types.js' import type { EntitySnapshot } from './snapshots.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' - -/** - * 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) */ - 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 - 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 type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' +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 { - /** Relation states keyed by "parentType:parentId:fieldName" */ - private readonly relationStates = new Map() +export class RelationStore implements Rekeyable { + private readonly hasOne = new HasOneStore() + private readonly hasMany = new HasManyStore() - /** Has-many list states keyed by "parentType:parentId:fieldName" */ - private readonly hasManyStates = new Map() + /** + * 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. + */ + getMutationVersion(): number { + 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 ==================== - /** - * Gets or creates relation state. - */ getOrCreateRelation( key: string, initial: Omit, ): StoredRelationState { - if (!this.relationStates.has(key)) { - this.relationStates.set(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.relationStates.set(key, { - currentId: 'currentId' in updates ? updates.currentId! : serverId, - serverId, - state: 'state' in updates ? updates.state! : serverState, - serverState, - placeholderData: updates.placeholderData ?? {}, - version: 0, - }) - } else { - this.relationStates.set(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.relationStates.set(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.relationStates.set(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.hasManyStates.set(key, { - serverIds: new Set(serverIds ?? []), - orderedIds: null, - plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), - version: 0, - }) - } else if (serverIds !== undefined) { - const newServerIds = new Set(serverIds) - if (!setsEqual(existing.serverIds, newServerIds)) { - this.hasManyStates.set(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.hasManyStates.set(key, { - serverIds: new Set(serverIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), - version: 0, - }) - } else { - this.hasManyStates.set(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.hasManyStates.set(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map([[itemId, type]]), - plannedConnections: new Set(), - createdEntities: new Set(), - version: 0, - }) - } else { - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.set(itemId, type) - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.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.hasManyStates.set(key, { - ...existing, - orderedIds: newOrderedIds, - plannedRemovals: newPlannedRemovals, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, - version: existing.version + 1, - }) - } + this.hasMany.planHasManyRemoval(key, itemId, type) } - /** - * Cancels a planned removal for a has-many item. - */ - cancelHasManyRemoval(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return + planHasManyConnection(key: string, itemId: string): void { + this.hasMany.planHasManyConnection(key, itemId) + } - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.delete(itemId) - this.hasManyStates.set(key, { - ...existing, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) + commitHasMany(key: string, newServerIds: string[]): void { + this.hasMany.commitHasMany(key, newServerIds) } - /** - * Plans a connection for a has-many item. - */ - planHasManyConnection(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.hasManyStates.set(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set(), - version: 0, - }) - } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { - newOrderedIds = [...newOrderedIds, itemId] - } - this.hasManyStates.set(key, { - ...existing, - orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) - } + resetHasMany(key: string): void { + this.hasMany.resetHasMany(key) } - /** - * Cancels a planned connection for a has-many item. - */ - cancelHasManyConnection(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return + addToHasMany(key: string, itemId: string): void { + this.hasMany.addToHasMany(key, itemId) + } + + connectExistingToHasMany(key: string, itemId: string): void { + this.hasMany.connectExistingToHasMany(key, itemId) + } - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.delete(itemId) - this.hasManyStates.set(key, { - ...existing, - plannedConnections: newPlannedConnections, - version: existing.version + 1, - }) + removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean { + return this.hasMany.removeFromHasMany(key, itemId, removalType) } - /** - * Commits has-many state after successful persist. - */ - commitHasMany(key: string, newServerIds: string[]): void { - const existing = this.hasManyStates.get(key) + moveInHasMany(key: string, fromIndex: number, toIndex: number): void { + this.hasMany.moveInHasMany(key, fromIndex, toIndex) + } - this.hasManyStates.set(key, { - serverIds: new Set(newServerIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), - version: (existing?.version ?? 0) + 1, - }) + getHasManyOrderedIds(key: string): string[] { + return this.hasMany.getHasManyOrderedIds(key) } + // ==================== Reachability / Reverse Lookup ==================== + /** - * Resets has-many state to server state (clears planned operations). + * Collects the ids of child entities currently reachable through an entity's + * LIVE relations (key prefix "parentType:parentId:"). Unions the live has-one + * edges with the live has-many members. */ - resetHasMany(key: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return - - this.hasManyStates.set(key, { - serverIds: existing.serverIds, - orderedIds: null, - plannedRemovals: new Map(), - plannedConnections: new Set(), - createdEntities: new Set(), - version: existing.version + 1, - }) + getLiveChildIds(keyPrefix: string): string[] { + const ids = new Set() + this.hasOne.collectLiveChildIds(keyPrefix, ids) + this.hasMany.collectLiveChildIds(keyPrefix, ids) + return Array.from(ids) } /** - * Adds a newly created entity to a has-many relation. - * Used by HasManyListHandle.add() for inline entity creation. + * Collects the composite keys ("parentType:parentId") of every entity that + * 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. */ - addToHasMany(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.hasManyStates.set(key, { - serverIds: new Set(), - orderedIds: [itemId], - plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set([itemId]), - version: 0, - }) - } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) - const newCreatedEntities = new Set(existing.createdEntities) - newCreatedEntities.add(itemId) - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - const newOrderedIds = [...currentOrderedIds, itemId] - this.hasManyStates.set(key, { - ...existing, - orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, - version: existing.version + 1, - }) - } + getParentKeysForChild(childId: string): Set { + const parents = new Set() + this.hasOne.collectParentKeysForChild(childId, parents) + this.hasMany.collectParentKeysForChild(childId, parents) + return parents } + // ==================== Bulk Operations ==================== + /** - * 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. + * 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. */ - connectExistingToHasMany(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) - - if (!existing) { - this.hasManyStates.set(key, { - serverIds: new Set(), - orderedIds: [itemId], - plannedRemovals: new Map(), - plannedConnections: new Set([itemId]), - createdEntities: new Set(), - version: 0, - }) - } else { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.add(itemId) - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) - const newOrderedIds = [...currentOrderedIds, itemId] - this.hasManyStates.set(key, { - ...existing, - orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - version: existing.version + 1, - }) - } + getOwnedRelationKeys(keyPrefix: string): string[] { + const keys: string[] = [] + this.hasOne.collectOwnedKeys(keyPrefix, keys) + return keys } /** - * 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. + * Keys of every has-many list owned by an entity (owner prefix + * "parentType:parentId:"). Counterpart of {@link getOwnedRelationKeys}. */ - removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): 'planned_removal' | 'cancelled_connection' | null { - const existing = this.hasManyStates.get(key) - if (!existing) return null - - const isCreatedEntity = existing.createdEntities.has(itemId) - - if (isCreatedEntity) { - const newPlannedConnections = new Set(existing.plannedConnections) - newPlannedConnections.delete(itemId) - const newCreatedEntities = new Set(existing.createdEntities) - newCreatedEntities.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } - - const newState: StoredHasManyState = { - ...existing, - orderedIds: newOrderedIds, - plannedConnections: newPlannedConnections, - createdEntities: newCreatedEntities, - version: existing.version + 1, - } - - if ( - newPlannedConnections.size === 0 && - newCreatedEntities.size === 0 && - existing.plannedRemovals.size === 0 && - newOrderedIds !== null - ) { - const defaultOrder = computeDefaultOrderedIds(newState) - if (arraysEqual(newOrderedIds, defaultOrder)) { - newState.orderedIds = null - } - } - - this.hasManyStates.set(key, newState) - return 'cancelled_connection' - } else { - this.planHasManyRemoval(key, itemId, removalType) - return 'planned_removal' - } + getOwnedHasManyKeys(keyPrefix: string): string[] { + const keys: string[] = [] + this.hasMany.collectOwnedKeys(keyPrefix, keys) + return keys } /** - * Moves an item within a has-many relation from one index to another. + * Removes a single has-one relation state by key (undo restore of an + * absent-before-the-gesture relation). */ - 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.hasManyStates.set(key, { - ...existing, - orderedIds: newOrderedIds, - version: existing.version + 1, - }) + removeRelationState(key: string): void { + this.hasOne.removeRelation(key) } /** - * Gets the ordered list of item IDs for a has-many relation. + * Removes a single has-many list state by key (undo restore of an + * absent-before-the-gesture list). */ - getHasManyOrderedIds(key: string): string[] { - const existing = this.hasManyStates.get(key) - if (!existing) return [] - - if (existing.orderedIds !== null) { - return existing.orderedIds - } - - return computeDefaultOrderedIds(existing) + removeHasManyState(key: string): void { + this.hasMany.removeHasMany(key) } /** - * Restores a has-many state from a captured snapshot. - * Used in pessimistic mode after successful server confirmation. + * 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. */ - restoreHasManyState(key: string, state: StoredHasManyState): void { - this.hasManyStates.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 + 1, - }) + removeOwnedRelations(keyPrefix: string): void { + this.hasOne.removeOwnedRelations(keyPrefix) + this.hasMany.removeOwnedRelations(keyPrefix) } - // ==================== 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.plannedConnections) { - 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) - } - } - } - - /** - * 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 + 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.plannedConnections.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), - plannedConnections: new Set(state.plannedConnections), - createdEntities: new Set(state.createdEntities), - 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.relationStates.set(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.hasManyStates.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 + 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.relationStates.set(key, { ...state, currentId, serverId, version: state.version + 1 }) - } - } - - // Replace in has-many states: serverIds, orderedIds, plannedConnections, createdEntities, 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 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) - 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.hasManyStates.set(key, { - serverIds, - orderedIds, - plannedRemovals, - plannedConnections, - createdEntities, - version: state.version + 1, - }) - } - } + this.hasOne.replaceEntityId(oldId, newId) + this.hasMany.replaceEntityId(oldId, newId) + } + + /** + * 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). */ 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.relationStates.set(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.hasManyStates.set(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() - } -} - -// ==================== Helper Functions ==================== - -/** - * Computes the default ordered IDs for a has-many relation. - * Order is: serverIds (minus removals) + plannedConnections - */ -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.plannedConnections) { - 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/RootRegistry.ts b/packages/bindx/src/store/RootRegistry.ts new file mode 100644 index 00000000..9008afc2 --- /dev/null +++ b/packages/bindx/src/store/RootRegistry.ts @@ -0,0 +1,86 @@ +/** + * 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". + */ +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' + +export class RootRegistry implements Rekeyable { + 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 + + /** + * 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() + } + + 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). + */ + rekey(ctx: RekeyContext): void { + if (this.roots.delete(ctx.oldKey)) { + this.roots.add(ctx.newKey) + this.mutationVersion++ + } + } + + clear(): void { + this.roots.clear() + this.mutationVersion++ + } + + getMutationVersion(): number { + return this.mutationVersion + } +} diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index f846f8f4..379b7bf0 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' @@ -13,6 +14,19 @@ 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' +import { RekeyOrchestrator } from './RekeyOrchestrator.js' +import type { RekeyContext } from './RekeyOrchestrator.js' +import type { + UndoJournal, + JournalTarget, + JournalCellImage, + EntityCellImage, + RelationCellImage, + HasManyCellImage, + EditableWriteCounters, +} from '../undo/UndoJournal.js' export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' export type { EntityMeta } from './EntityMetaStore.js' @@ -37,14 +51,17 @@ 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() 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 + private readonly rekeyOrchestrator: RekeyOrchestrator /** * Tracks the last embedded data reference propagated from parent to child. @@ -54,18 +71,103 @@ 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.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) + // 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. + 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) }, + ]) + } + + // ==================== 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 } - // ==================== Key Generation ==================== + 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) + } + } - /** Maps temp ID entity keys to their persisted ID entity keys for transparent resolution */ - private readonly rekeyedEntities = new Map() + /** + * 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. + */ + 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 { - 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 { @@ -77,13 +179,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 ==================== @@ -169,6 +265,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) { @@ -207,6 +306,7 @@ export class SnapshotStore implements SnapshotVersionBumper { updates: Partial, ): EntitySnapshot | undefined { const key = this.getEntityKey(entityType, id) + this.recordExistingEntity(key) const result = this.entitySnapshots.updateFields(key, updates) if (result) { this.notifyEntitySubscribers(key) @@ -221,6 +321,7 @@ export class SnapshotStore implements SnapshotVersionBumper { value: unknown, ): void { const key = this.getEntityKey(entityType, id) + this.recordExistingEntity(key) if (this.entitySnapshots.setFieldValue(key, fieldPath, value)) { this.notifyEntitySubscribers(key) } @@ -234,18 +335,52 @@ export class SnapshotStore implements SnapshotVersionBumper { resetEntity(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.recordExistingEntity(key) this.entitySnapshots.reset(key) 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, 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) + 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 key = this.entitySnapshots.keyForId(entityId) + if (!key) return false + return !this.meta.existsOnServer(key) + } + // ==================== Load State (delegated to EntityMetaStore) ==================== getLoadState(entityType: string, id: string): { status: LoadStatus; error?: FieldError } | undefined { @@ -279,12 +414,14 @@ export class SnapshotStore implements SnapshotVersionBumper { scheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.recordExistingEntity(key) this.meta.scheduleForDeletion(key) this.notifyEntitySubscribers(key) } unscheduleForDeletion(entityType: string, id: string): void { const key = this.getEntityKey(entityType, id) + this.recordExistingEntity(key) this.meta.unscheduleForDeletion(key) this.notifyEntitySubscribers(key) } @@ -306,52 +443,55 @@ 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 } - 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) - - // 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) - - // 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) + /** + * 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)) + } - // Rekey errors, touched state, and propagation tracking - this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix) - this.touched.rekey(oldKeyPrefix, newKeyPrefix) - this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix) + mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void { + // 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) + + // 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(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) ==================== @@ -398,22 +538,11 @@ 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) } - 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, @@ -432,22 +561,11 @@ 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) } - 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, @@ -455,7 +573,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( @@ -477,6 +597,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) } @@ -489,6 +610,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) } @@ -500,6 +622,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) } @@ -513,10 +636,11 @@ 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') { + 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. + if (this.relations.removeFromHasMany(key, itemId, removalType)) { this.notifyRelationSubscribers(key) } } @@ -530,6 +654,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) } @@ -553,7 +678,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( @@ -563,7 +688,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) ==================== @@ -573,12 +704,47 @@ 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) + const wasPessimistic = this.meta.isPessimisticInFlight(key) + this.meta.setPersisting(key, isPersisting, pessimistic) + if (wasPessimistic !== this.meta.isPessimisticInFlight(key)) { + this.bumpEntitySnapshotVersion(key) + } 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[] { @@ -720,12 +886,19 @@ export class SnapshotStore implements SnapshotVersionBumper { parentId: string, fieldName: string, updates: Partial>, + 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) - 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 { @@ -736,6 +909,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) } @@ -750,28 +924,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 { @@ -803,16 +955,23 @@ 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) - } - - 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) + // 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.recordExistingEntity(childKey) + this.roots.unregister(childKey) } // ==================== Partial Snapshot Export/Import ==================== @@ -860,6 +1019,208 @@ 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, + }, + } + } + + /** + * 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 + * 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<{ @@ -870,6 +1231,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,8 +1273,31 @@ export class SnapshotStore implements SnapshotVersionBumper { this.relations.clear() this.errors.clear() this.touched.clear() + 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() } } + +/** + * 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/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index a736deaf..1e937b15 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 /** @@ -8,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. * @@ -15,10 +27,10 @@ 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 { +export class SubscriptionManager implements Rekeyable { /** Subscribers per entity key */ private readonly entitySubscribers = new Map>() @@ -28,15 +40,19 @@ export class SubscriptionManager { /** 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 @@ -119,29 +135,29 @@ export class SubscriptionManager { // ==================== 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"). + * + * 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}). */ - 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 ==================== @@ -169,14 +185,14 @@ export class SubscriptionManager { } } - // 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) @@ -261,11 +277,15 @@ export class SubscriptionManager { } /** - * 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(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) { @@ -302,20 +322,5 @@ export class SubscriptionManager { 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/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)) { diff --git a/packages/bindx/src/store/relationKey.ts b/packages/bindx/src/store/relationKey.ts new file mode 100644 index 00000000..f3c2cd51 --- /dev/null +++ b/packages/bindx/src/store/relationKey.ts @@ -0,0 +1,20 @@ +/** + * 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) +} + +/** + * 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 +} diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts new file mode 100644 index 00000000..3f407e1b --- /dev/null +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -0,0 +1,258 @@ +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. + * + * 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. + * + * 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. */ +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 + +/** 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[] +} + +/** + * 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 + /** + * 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 + /** + * 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 { + 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. */ + private active: Map | null = null + /** Editable-write counters snapshotted when the outermost transaction opened. */ + private baseCounters: EditableWriteCounters | null = null + + constructor( + private readonly target: JournalTarget, + private readonly onCommit: (entry: JournalEntry) => void, + private readonly onRekey?: (ctx: RekeyContext) => void, + private readonly onClear?: () => void, + ) {} + + get isRecording(): boolean { + return this.active !== null + } + + begin(): void { + if (this.depth === 0) { + this.active = new Map() + this.baseCounters = this.target.getEditableWriteCounters() + } + this.depth++ + } + + commit(): void { + if (this.depth === 0) return + this.depth-- + 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 + * 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)) + } + + 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) + } + + /** 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. + // 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?.() + } +} + +export { cellRefKey } diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index b74df923..6629d7fd 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,207 @@ 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), + () => this.clear(), + ) } /** - * 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) + const middleware: ActionMiddleware = () => true + middleware.dispose = () => { + this.store.clearJournal(this.journal) } + return middleware } - /** - * 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) - } - } - for (const [key, value] of source.relationStates) { - if (!target.relationStates.has(key)) { - target.relationStates.set(key, value) - } - } - for (const [key, value] of source.hasManyStates) { - if (!target.hasManyStates.has(key)) { - target.hasManyStates.set(key, value) - } + private mergeIntoPending(entry: JournalEntry): void { + if (!this.pending) { + this.pending = new Map() } - for (const [key, value] of source.entityMetas) { - if (!target.entityMetas.has(key)) { - target.entityMetas.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) } } } - /** - * 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) + 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) + 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() + } + } + + // ==================== 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 +256,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/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 2bda7dc7..a5d9ee4b 100644 --- a/packages/bindx/src/undo/index.ts +++ b/packages/bindx/src/undo/index.ts @@ -1,9 +1,10 @@ export { UndoManager } from './UndoManager.js' +export { UnrecordedWriteError } from './UnrecordedWriteError.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/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/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/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') + }) +}) 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) + }) + }) +}) diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts new file mode 100644 index 00000000..db70ee13 --- /dev/null +++ b/tests/undo-journal.test.ts @@ -0,0 +1,425 @@ +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('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']) + 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() + }) + + 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 + // ============================================================ + 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() + }) + + 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 + // ============================================================ + 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') + }) +}) 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 ?? {})) +} 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) diff --git a/tests/unit/core/actionDispatcher.test.ts b/tests/unit/core/actionDispatcher.test.ts index d1a8adb0..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', () => { @@ -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/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', () => { 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/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/hasOneHandle.test.ts b/tests/unit/handles/hasOneHandle.test.ts index feb0da20..447f62eb 100644 --- a/tests/unit/handles/hasOneHandle.test.ts +++ b/tests/unit/handles/hasOneHandle.test.ts @@ -676,4 +676,302 @@ 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) + }) + }) + + // ==================== 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 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', + 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('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', + 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', () => { + 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) + }) + }) }) 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..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 @@ -110,7 +138,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 +154,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 +316,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 +334,90 @@ 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') + }) + + // 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/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/getParentKeysForChild.test.ts b/tests/unit/store/getParentKeysForChild.test.ts new file mode 100644 index 00000000..968fa1ff --- /dev/null +++ b/tests/unit/store/getParentKeysForChild.test.ts @@ -0,0 +1,158 @@ +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('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'] + 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/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']) + }) + }) +}) diff --git a/tests/unit/store/notificationPropagation.test.ts b/tests/unit/store/notificationPropagation.test.ts new file mode 100644 index 00000000..e4e2b2da --- /dev/null +++ b/tests/unit/store/notificationPropagation.test.ts @@ -0,0 +1,158 @@ +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 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) + + 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 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', + }) + + const parent = createMockSubscriber() + store.subscribeToEntity('Author', 'author-1', parent.fn) + parent.reset() + + // Mutate the now-disconnected child. + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + // 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 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') + + 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) + }) +}) diff --git a/tests/unit/store/presentationFlag.test.ts b/tests/unit/store/presentationFlag.test.ts new file mode 100644 index 00000000..5c1a6be8 --- /dev/null +++ b/tests/unit/store/presentationFlag.test.ts @@ -0,0 +1,97 @@ +// 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('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') + expect(Object.isFrozen(presented)).toBe(true) + expect(presented).not.toBe(store.getEntitySnapshot('Article', 'a1')) + }) +}) diff --git a/tests/unit/store/reachabilityCreateDetection.test.ts b/tests/unit/store/reachabilityCreateDetection.test.ts new file mode 100644 index 00000000..32bff85d --- /dev/null +++ b/tests/unit/store/reachabilityCreateDetection.test.ts @@ -0,0 +1,163 @@ +// 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', + }) + }) + + // 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' }]) + }) +}) diff --git a/tests/unit/store/reachabilityMemoization.test.ts b/tests/unit/store/reachabilityMemoization.test.ts new file mode 100644 index 00000000..2d7bec12 --- /dev/null +++ b/tests/unit/store/reachabilityMemoization.test.ts @@ -0,0 +1,236 @@ +// 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([]) + }) + + // 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. + 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([]) + }) +}) 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' }) + }) +}) 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'])) + }) +}) diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index 262726df..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', () => { @@ -439,33 +439,13 @@ 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') 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', () => { @@ -484,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) }) }) @@ -494,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', () => { @@ -502,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', () => { @@ -521,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', () => { @@ -548,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) }) }) @@ -590,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) }) }) @@ -603,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() }) }) @@ -1129,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) @@ -1137,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) }) })