From 072569c5cd1a65abe28e0b06a98dd5b78a01d808 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 15:31:41 +0200 Subject: [PATCH 01/14] refactor(bindx): extract the has-many state algebra out of HasManyStore HasManyStore mixed the keyed map, the write chokepoint and the three indexes with the pure algebra over a single state. Splitting them gets the file back under the size limit before the view restructure grows it again. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- packages/bindx/src/store/HasManyStore.ts | 193 ++----------------- packages/bindx/src/store/RelationStore.ts | 8 +- packages/bindx/src/store/hasManyState.ts | 185 ++++++++++++++++++ tests/unit/store/dirtyRelationLookup.test.ts | 3 +- 4 files changed, 205 insertions(+), 184 deletions(-) create mode 100644 packages/bindx/src/store/hasManyState.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index d9aa8d9..b7cb7aa 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -2,104 +2,20 @@ import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKe import { PlannedDeleteIndex } from './PlannedDeleteIndex.js' import { RelationEdgeIndex } from './RelationEdgeIndex.js' import { RelationOwnerIndex } from './RelationOwnerIndex.js' - -type ReconciliationResult = 'applied' | 'conflict' - -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' - -export interface SentHasManyAddition { - itemId: string - kind: HasManyAdditionKind -} - -export interface SentHasManyRemoval { - itemId: string - type: HasManyRemovalType -} - -export interface SentHasManyDelta { - additions: readonly SentHasManyAddition[] - removals: readonly SentHasManyRemoval[] -} - -/** - * 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 -} - -function cloneHasManyState(state: StoredHasManyState): StoredHasManyState { - return { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedAdditions: new Map(state.plannedAdditions), - version: state.version, - } -} - -/** - * 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 -} +import { + arraysEqual, + cloneHasManyState, + computeDefaultOrderedIds, + liveHasManyChildIds, + plannedDeleteChildIds, + reconcileHasManyState, + setsEqual, + type HasManyAdditionKind, + type HasManyRemovalType, + type ReconciliationResult, + type SentHasManyDelta, + type StoredHasManyState, +} from './hasManyState.js' /** * Owns has-many list state ("parentType:parentId:fieldName" → {@link StoredHasManyState}). @@ -763,84 +679,3 @@ export class HasManyStore { 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 -} - -function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set { - const ids = new Set() - if (!state) return ids - for (const [id, type] of state.plannedRemovals) { - if (type === 'delete') ids.add(id) - } - return ids -} - -interface HasManyReconciliation { - state: StoredHasManyState - result: ReconciliationResult -} - -function reconcileHasManyState( - existing: StoredHasManyState, - delta: SentHasManyDelta, -): HasManyReconciliation { - const currentLive = liveHasManyChildIds(existing) - const serverIds = new Set(existing.serverIds) - const plannedAdditions = new Map(existing.plannedAdditions) - const plannedRemovals = new Map(existing.plannedRemovals) - let result: ReconciliationResult = 'applied' - - for (const addition of delta.additions) { - serverIds.add(addition.itemId) - plannedAdditions.delete(addition.itemId) - plannedRemovals.delete(addition.itemId) - if (!currentLive.has(addition.itemId)) { - plannedRemovals.set( - addition.itemId, - addition.kind === 'created' ? 'delete' : 'disconnect', - ) - } - } - - for (const removal of delta.removals) { - serverIds.delete(removal.itemId) - const currentRemoval = plannedRemovals.get(removal.itemId) - if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId) - - if (!currentLive.has(removal.itemId)) { - if (removal.type === 'delete') plannedRemovals.delete(removal.itemId) - continue - } - if (removal.type === 'delete') { - result = 'conflict' - } else if (!plannedAdditions.has(removal.itemId)) { - plannedAdditions.set(removal.itemId, 'connected') - } - } - - return { - state: { - serverIds, - orderedIds: existing.orderedIds ? [...existing.orderedIds] : null, - plannedRemovals, - plannedAdditions, - version: existing.version + 1, - }, - result, - } -} diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 6dde4e4..c28037c 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -5,14 +5,14 @@ import { type SentHasOneTransition, type StoredRelationState, } from './HasOneStore.js' +import { HasManyStore } from './HasManyStore.js' import { - HasManyStore, computeDefaultOrderedIds, type HasManyAdditionKind, type HasManyRemovalType, type SentHasManyDelta, type StoredHasManyState, -} from './HasManyStore.js' +} from './hasManyState.js' // Re-exported so existing imports from './RelationStore.js' keep resolving. export type { StoredRelationState } from './HasOneStore.js' @@ -24,8 +24,8 @@ export type { SentHasManyDelta, SentHasManyRemoval, StoredHasManyState, -} from './HasManyStore.js' -export { computeDefaultOrderedIds } from './HasManyStore.js' +} from './hasManyState.js' +export { computeDefaultOrderedIds } from './hasManyState.js' export type RelationReconciliationResult = 'applied' | 'conflict' diff --git a/packages/bindx/src/store/hasManyState.ts b/packages/bindx/src/store/hasManyState.ts new file mode 100644 index 0000000..2d27358 --- /dev/null +++ b/packages/bindx/src/store/hasManyState.ts @@ -0,0 +1,185 @@ +/** + * The has-many state algebra: the stored shape plus every pure function over it. + * + * Separated from {@link HasManyStore}, which owns the keyed map, the write + * chokepoint and the three indexes. Nothing here touches the store's state. + */ + +export type ReconciliationResult = 'applied' | 'conflict' + +export 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 +} + +export 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' + +export interface SentHasManyAddition { + itemId: string + kind: HasManyAdditionKind +} + +export interface SentHasManyRemoval { + itemId: string + type: HasManyRemovalType +} + +export interface SentHasManyDelta { + additions: readonly SentHasManyAddition[] + removals: readonly SentHasManyRemoval[] +} + +/** + * 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 +} + +export function cloneHasManyState(state: StoredHasManyState): StoredHasManyState { + return { + serverIds: new Set(state.serverIds), + orderedIds: state.orderedIds ? [...state.orderedIds] : null, + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions: new Map(state.plannedAdditions), + version: state.version, + } +} + +/** + * 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 +} + +/** + * 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. + */ +export 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 +} + +export function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set { + const ids = new Set() + if (!state) return ids + for (const [id, type] of state.plannedRemovals) { + if (type === 'delete') ids.add(id) + } + return ids +} + +export interface HasManyReconciliation { + state: StoredHasManyState + result: ReconciliationResult +} + +export function reconcileHasManyState( + existing: StoredHasManyState, + delta: SentHasManyDelta, +): HasManyReconciliation { + const currentLive = liveHasManyChildIds(existing) + const serverIds = new Set(existing.serverIds) + const plannedAdditions = new Map(existing.plannedAdditions) + const plannedRemovals = new Map(existing.plannedRemovals) + let result: ReconciliationResult = 'applied' + + for (const addition of delta.additions) { + serverIds.add(addition.itemId) + plannedAdditions.delete(addition.itemId) + plannedRemovals.delete(addition.itemId) + if (!currentLive.has(addition.itemId)) { + plannedRemovals.set( + addition.itemId, + addition.kind === 'created' ? 'delete' : 'disconnect', + ) + } + } + + for (const removal of delta.removals) { + serverIds.delete(removal.itemId) + const currentRemoval = plannedRemovals.get(removal.itemId) + if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId) + + if (!currentLive.has(removal.itemId)) { + if (removal.type === 'delete') plannedRemovals.delete(removal.itemId) + continue + } + if (removal.type === 'delete') { + result = 'conflict' + } else if (!plannedAdditions.has(removal.itemId)) { + plannedAdditions.set(removal.itemId, 'connected') + } + } + + return { + state: { + serverIds, + orderedIds: existing.orderedIds ? [...existing.orderedIds] : null, + plannedRemovals, + plannedAdditions, + version: existing.version + 1, + }, + result, + } +} diff --git a/tests/unit/store/dirtyRelationLookup.test.ts b/tests/unit/store/dirtyRelationLookup.test.ts index c43453c..b3cd0e3 100644 --- a/tests/unit/store/dirtyRelationLookup.test.ts +++ b/tests/unit/store/dirtyRelationLookup.test.ts @@ -1,6 +1,7 @@ import { describe, expect, spyOn, test } from 'bun:test' import { HasOneStore, type StoredRelationState } from '../../../packages/bindx/src/store/HasOneStore.js' -import { HasManyStore, type StoredHasManyState } from '../../../packages/bindx/src/store/HasManyStore.js' +import { HasManyStore } from '../../../packages/bindx/src/store/HasManyStore.js' +import type { StoredHasManyState } from '../../../packages/bindx/src/store/hasManyState.js' function hasOneState(): StoredRelationState { return { From f3cf06bb668e142459fd9ece172cc945ac83615b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 15:57:39 +0200 Subject: [PATCH 02/14] fix(bindx): key has-many state by the schema field name, views inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A has-many selected with args (filter/orderBy/limit/offset) is fetched under a generated alias, and the store used that alias as the relation key. Everything that names the relation the way the schema and the server do then failed to find the pending writes: - add() fell through to a detached root create (issue #121) — against a Contember project with a non-null inverse the mutation is rejected outright; - remove() was a completely silent no-op — no mutation, no error, still dirty; - persistScope({ type: 'relation', relationName }) matched nothing, because the dirty-relation list reported the alias instead of the field name; - usePersistEntity().dirtyRelations exposed `tags_a7x9k2` to application code. The state is now keyed by the schema field name alone, with per-args views inside it. Reads stay isolated per view; the pending writes belong to the relation, because the backend has one relation and one mutation input for it. Subset closure only holds one way, so the two directions are asymmetric: a removal hides the item in every view, while an addition renders in the views it was made in plus any view whose args cannot exclude it. MutationCollector, BatchPersister and errorPathResolver are unchanged — they already addressed the relation correctly. BC: StoredHasManyState changes shape; HasManyListHandle.isDirty and reset() are now relation-scoped, since persisting either view sends the whole relation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- packages/bindx/src/core/ActionDispatcher.ts | 1 - packages/bindx/src/core/actions.ts | 4 +- .../bindx/src/handles/HasManyListHandle.ts | 28 +- packages/bindx/src/store/HasManyStore.ts | 546 ++++++++---------- packages/bindx/src/store/RelationStore.ts | 48 +- packages/bindx/src/store/SnapshotStore.ts | 163 +++--- packages/bindx/src/store/hasManyState.ts | 282 +++++++-- packages/bindx/src/store/relationKey.ts | 9 + packages/bindx/src/undo/UndoJournal.ts | 4 +- packages/bindx/src/undo/UndoManager.ts | 2 +- packages/bindx/src/undo/rekeyJournalEntry.ts | 74 +-- .../addWithArgsPersistsNested.test.tsx | 197 +++++++ tests/undo-journal.test.ts | 6 +- tests/unit/handles/hasManyAlias.test.ts | 216 +++---- tests/unit/handles/staleRelationData.test.ts | 2 +- tests/unit/store/dirtyRelationLookup.test.ts | 3 +- .../unit/store/getParentKeysForChild.test.ts | 14 +- tests/unit/store/hasManyAdditionKind.test.ts | 34 +- .../store/reachabilityMemoization.test.ts | 6 +- tests/unit/store/relationEdgeIndex.test.ts | 14 +- tests/unit/store/snapshotStore.test.ts | 28 +- 21 files changed, 1006 insertions(+), 675 deletions(-) create mode 100644 tests/react/relations/hasMany/addWithArgsPersistsNested.test.tsx diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 7945d6c..f1674f4 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -360,7 +360,6 @@ export class ActionDispatcher { action.fieldName, action.itemKey, action.removalType, - action.alias, ) break diff --git a/packages/bindx/src/core/actions.ts b/packages/bindx/src/core/actions.ts index d48da81..5d226a6 100644 --- a/packages/bindx/src/core/actions.ts +++ b/packages/bindx/src/core/actions.ts @@ -165,7 +165,6 @@ export interface RemoveFromListAction { readonly fieldName: string readonly itemKey: string readonly removalType: 'disconnect' | 'delete' - readonly alias?: string } /** @@ -581,9 +580,8 @@ export function removeFromList( fieldName: string, itemKey: string, removalType: 'disconnect' | 'delete', - alias?: string, ): RemoveFromListAction { - return { type: 'REMOVE_FROM_LIST', entityType, entityId, fieldName, itemKey, removalType, alias } + return { type: 'REMOVE_FROM_LIST', entityType, entityId, fieldName, itemKey, removalType } } /** diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 1f8984a..34eada1 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -406,23 +406,29 @@ export class HasManyListHandle 0 || - state.plannedAdditions.size > 0 || - state.orderedIds !== null + relation.plannedRemovals.size > 0 || + relation.plannedAdditions.size > 0 || + view.orderedIds !== null ) } @@ -452,7 +458,7 @@ export class HasManyListHandle() private readonly owners = new RelationOwnerIndex() @@ -107,149 +119,176 @@ export class HasManyStore { } /** - * Gets or creates has-many list state. + * A view addressed by the field name itself is the unparameterized selection, so + * its args cannot exclude any member of the relation. */ - getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { + private viewMembership(key: string, alias: string): HasManyViewMembership { + return alias === fieldFromRelationKey(key) ? 'total' : 'partial' + } + + /** + * Starts an edit: a deep copy of the current state (or a fresh one), with the + * addressed view materialized. Every mutator builds on this, so no path can hand + * {@link writeHasMany} a state sharing mutable structure with the live one — which + * SnapshotStore's dirty-version memo and the undo journal's images both rely on. + */ + private editableState(key: string, alias?: 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, - }) - } + const state = existing ? cloneHasManyState(existing) : emptyHasManyState() + if (alias !== undefined && !state.views.has(alias)) { + state.views.set(alias, createHasManyView(this.viewMembership(key, alias))) } + return state + } - return cloneHasManyState(this.hasManyStates.get(key)!) + private commitEdit(key: string, state: StoredHasManyState): void { + const existing = this.hasManyStates.get(key) + state.version = existing ? existing.version + 1 : 0 + this.writeHasMany(key, state) } /** - * Gets has-many list state. + * Gets or creates the relation state and the addressed view, refreshing that + * view's server baseline when one is supplied. */ + getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void { + const view = this.hasManyStates.get(key)?.views.get(alias) + if (view && (serverIds === undefined || setsEqual(view.serverIds, new Set(serverIds)))) { + return + } + + const state = this.editableState(key, alias) + if (serverIds !== undefined) { + const next = state.views.get(alias)! + next.serverIds = new Set(serverIds) + // A refreshed baseline invalidates only THIS view's manual ordering. + next.orderedIds = null + } + this.commitEdit(key, state) + } + + /** Relation state, deep-copied. For the export / undo paths that move whole states. */ getHasMany(key: string): StoredHasManyState | undefined { const state = this.hasManyStates.get(key) return state ? cloneHasManyState(state) : undefined } + /** What persistence, dirty tracking and error paths see: one relation, no views. */ + getRelationProjection(key: string): HasManyRelationProjection | undefined { + const state = this.hasManyStates.get(key) + return state ? toRelationProjection(state) : undefined + } + + /** What one mounted has-many handle sees. */ + getViewProjection(key: string, alias: string): HasManyViewProjection { + return toViewProjection(this.hasManyStates.get(key), alias) + } + + /** Replaces one view's server baseline. */ + setHasManyServerIds(key: string, alias: string, serverIds: string[]): void { + const state = this.editableState(key, alias) + const view = state.views.get(alias)! + view.serverIds = new Set(serverIds) + view.orderedIds = null + this.commitEdit(key, state) + } + /** - * Sets server IDs for a has-many relation. + * Plans a removal. Relation-level: the item stops being a member of the relation, + * so it leaves every view's ordering too. */ - setHasManyServerIds(key: string, serverIds: string[]): void { - const existing = this.hasManyStates.get(key) + planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { + const state = this.editableState(key) + state.plannedRemovals.set(itemId, type) + state.plannedAdditions.delete(itemId) + this.dropFromEveryExplicitOrder(state, itemId) + this.commitEdit(key, state) + this.editableWriteVersion++ + } - 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 connection of an existing entity, rendered in the view it was made in. + */ + planHasManyConnection(key: string, alias: string, itemId: string): void { + const state = this.editableState(key, alias) + this.recordAddition(state, alias, itemId, 'connected') + this.appendToExplicitOrder(state, alias, itemId) + this.commitEdit(key, state) + this.editableWriteVersion++ } /** - * Plans a removal for a has-many item. + * Adds a newly created entity to a has-many relation. + * Used by HasManyListHandle.add() for inline entity creation. */ - planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { - const existing = this.hasManyStates.get(key) + addToHasMany(key: string, alias: string, itemId: string): void { + const state = this.editableState(key, alias) + // The order is materialized BEFORE the addition is recorded, so the append + // cannot duplicate an id the default order would by then already contain. + const order = viewOrderedIds(state, alias) + this.recordAddition(state, alias, itemId, 'created') + state.views.get(alias)!.orderedIds = [...order, itemId] + this.commitEdit(key, state) + this.editableWriteVersion++ + } - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map([[itemId, type]]), - plannedAdditions: new Map(), - version: 0, - }) + /** + * 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, alias: string, itemId: string): void { + const fresh = !this.hasManyStates.has(key) + const state = this.editableState(key, alias) + this.recordAddition(state, alias, itemId, 'connected') + if (fresh) { + state.views.get(alias)!.orderedIds = [itemId] } 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.appendToExplicitOrder(state, alias, itemId) } + this.commitEdit(key, state) this.editableWriteVersion++ } /** - * Plans a connection for a has-many item. + * Records an addition and the view it renders in. Never downgrades an existing + * 'created' addition to 'connected', and cancels a pending removal of the same + * item — leaving both recorded would hide a listed item from the live-edge index. */ - 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, - }) + private recordAddition( + state: StoredHasManyState, + alias: string, + itemId: string, + kind: 'created' | 'connected', + ): void { + const existing = state.plannedAdditions.get(itemId) + if (existing) { + existing.origins.add(alias) + if (kind === 'created') existing.kind = 'created' } 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, - }) + state.plannedAdditions.set(itemId, { kind, origins: new Set([alias]) }) } - this.editableWriteVersion++ + state.plannedRemovals.delete(itemId) } /** - * Commits has-many state after successful persist. + * Only touches an EXPLICIT order — the default order already derives from + * plannedAdditions. Guards against re-appending an id that is already listed: + * the connect paths re-run whenever an embedded reference is re-materialized. */ - commitHasMany(key: string, newServerIds: string[]): void { - const existing = this.hasManyStates.get(key) + private appendToExplicitOrder(state: StoredHasManyState, alias: string, itemId: string): void { + const view = state.views.get(alias)! + if (view.orderedIds !== null && !view.orderedIds.includes(itemId)) { + view.orderedIds = [...view.orderedIds, itemId] + } + } - this.writeHasMany(key, { - serverIds: new Set(newServerIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: (existing?.version ?? 0) + 1, - }) + private dropFromEveryExplicitOrder(state: StoredHasManyState, itemId: string): void { + for (const view of state.views.values()) { + if (view.orderedIds !== null) { + view.orderedIds = view.orderedIds.filter(id => id !== itemId) + } + } } /** @@ -267,95 +306,17 @@ export class HasManyStore { /** * Resets has-many state to server state (clears planned operations). + * Relation-level: planned removals carry no view, so a per-view reset could not + * know which of them to drop. */ resetHasMany(key: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return + if (!this.hasManyStates.has(key)) 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). - // A re-connect cancels a pending removal of the same item (mirrors - // planHasManyConnection); leaving both recorded hides the item from the - // live-edge index while it is still listed. - 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, - }) - } + const state = this.editableState(key) + state.plannedRemovals.clear() + state.plannedAdditions.clear() + for (const view of state.views.values()) view.orderedIds = null + this.commitEdit(key, state) this.editableWriteVersion++ } @@ -369,52 +330,40 @@ export class HasManyStore { const existing = this.hasManyStates.get(key) if (!existing) return false - const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created' + if (existing.plannedAdditions.get(itemId)?.kind !== 'created') { + // planHasManyRemoval bumps editableWriteVersion itself. + this.planHasManyRemoval(key, itemId, removalType) + return true + } - if (isCreatedEntity) { - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } + // Cancelling a pending create cancels it in every view at once: the entity is + // going away, so leaving it listed elsewhere would show a row nothing persists. + const state = this.editableState(key) + state.plannedAdditions.delete(itemId) + this.dropFromEveryExplicitOrder(state, 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 + if (state.plannedAdditions.size === 0 && state.plannedRemovals.size === 0) { + for (const [alias, view] of state.views) { + if (view.orderedIds !== null && arraysEqual(view.orderedIds, computeViewOrderedIds(state, alias))) { + view.orderedIds = null } } - - this.writeHasMany(key, newState) - this.editableWriteVersion++ - return true - } else { - // planHasManyRemoval bumps editableWriteVersion itself. - this.planHasManyRemoval(key, itemId, removalType) - return true } + + this.commitEdit(key, state) + this.editableWriteVersion++ + return true } /** - * Moves an item within a has-many relation from one index to another. + * Moves an item within one view of a has-many relation. Ordering is presentation + * only — nothing persists it — so it stays per view. */ - moveInHasMany(key: string, fromIndex: number, toIndex: number): void { + moveInHasMany(key: string, alias: string, fromIndex: number, toIndex: number): void { const existing = this.hasManyStates.get(key) if (!existing) return - const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing) + const currentOrderedIds = viewOrderedIds(existing, alias) if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return if (toIndex < 0 || toIndex >= currentOrderedIds.length) return @@ -425,26 +374,19 @@ export class HasManyStore { if (movedItem === undefined) return newOrderedIds.splice(toIndex, 0, movedItem) - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - version: existing.version + 1, - }) + const state = this.editableState(key, alias) + state.views.get(alias)!.orderedIds = newOrderedIds + this.commitEdit(key, state) this.editableWriteVersion++ } /** - * Gets the ordered list of item IDs for a has-many relation. + * Gets the ordered list of item IDs shown by one view. */ - getHasManyOrderedIds(key: string): string[] { + getHasManyOrderedIds(key: string, alias: string): string[] { const existing = this.hasManyStates.get(key) if (!existing) return [] - - if (existing.orderedIds !== null) { - return [...existing.orderedIds] - } - - return computeDefaultOrderedIds(existing) + return viewOrderedIds(existing, alias) } /** @@ -499,38 +441,39 @@ export class HasManyStore { * * `pendingItems` (by relation key) names planned additions/removals that were NOT * sent — they stay planned instead of being folded into the server baseline. + * + * A confirmed removal leaves every view; a confirmed addition joins the views it + * renders in. Only the views that actually changed lose their manual ordering — + * an untouched sibling view keeps the order its user arranged. */ commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap>): void { - for (const [key, state] of this.hasManyStates) { + for (const [key, existing] of this.hasManyStates) { if (!key.startsWith(keyPrefix)) continue const pending = pendingItems?.get(key) - const newServerIds = new Set(state.serverIds) - const keptRemovals = new Map() - const keptAdditions = new Map() - - for (const [removedId, type] of state.plannedRemovals) { - if (pending?.has(removedId)) { - keptRemovals.set(removedId, type) - } else { - newServerIds.delete(removedId) + const state = cloneHasManyState(existing) + const touched = new Set() + + for (const removedId of existing.plannedRemovals.keys()) { + if (pending?.has(removedId)) continue + state.plannedRemovals.delete(removedId) + for (const [alias, view] of state.views) { + if (view.serverIds.delete(removedId)) touched.add(alias) } } - for (const [addedId, kind] of state.plannedAdditions) { - if (pending?.has(addedId)) { - keptAdditions.set(addedId, kind) - } else { - newServerIds.add(addedId) + for (const [addedId, addition] of existing.plannedAdditions) { + if (pending?.has(addedId)) continue + state.plannedAdditions.delete(addedId) + for (const alias of additionFoldTargets(existing, addition)) { + state.views.get(alias)!.serverIds.add(addedId) + touched.add(alias) } } - this.writeHasMany(key, { - serverIds: newServerIds, - orderedIds: null, - plannedRemovals: keptRemovals, - plannedAdditions: keptAdditions, - version: state.version + 1, - }) + for (const alias of touched) state.views.get(alias)!.orderedIds = null + + state.version = existing.version + 1 + this.writeHasMany(key, state) } } @@ -580,13 +523,9 @@ export class HasManyStore { 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, - }) + const imported = cloneHasManyState(state) + imported.version = state.version + 1 + this.writeHasMany(key, imported) keys.push(key) } return keys @@ -594,58 +533,47 @@ export class HasManyStore { /** * Replaces all occurrences of oldId with newId across has-many states - * (serverIds, orderedIds, plannedAdditions, plannedRemovals). + * (every view's serverIds/orderedIds, plannedAdditions, plannedRemovals). */ replaceEntityId(oldId: string, newId: string): void { - for (const [key, state] of this.hasManyStates) { + for (const [key, existing] of this.hasManyStates) { let changed = false + const state = cloneHasManyState(existing) - 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 + for (const view of state.views.values()) { + if (view.serverIds.delete(oldId)) { + view.serverIds.add(newId) changed = true } + if (view.orderedIds) { + const idx = view.orderedIds.indexOf(oldId) + if (idx !== -1) { + view.orderedIds[idx] = newId + changed = true + } + } } - let plannedAdditions = state.plannedAdditions - if (plannedAdditions.has(oldId)) { - plannedAdditions = new Map(plannedAdditions) - plannedAdditions.delete(oldId) + const addition = state.plannedAdditions.get(oldId) + if (addition) { + state.plannedAdditions.delete(oldId) // The id only changes when the item was persisted, so a still-planned // 'created' addition (the item went out on its own, not nested in this // parent) must now connect the server row rather than create a second one. - plannedAdditions.set(newId, 'connected') + state.plannedAdditions.set(newId, { kind: 'connected', origins: addition.origins }) 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) + const removalType = state.plannedRemovals.get(oldId) + if (removalType !== undefined) { + state.plannedRemovals.delete(oldId) + state.plannedRemovals.set(newId, removalType) changed = true } if (changed) { - this.writeHasMany(key, { - serverIds, - orderedIds, - plannedRemovals, - plannedAdditions, - version: state.version + 1, - }) + state.version = existing.version + 1 + this.writeHasMany(key, state) } } } diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index c28037c..d09c476 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -7,9 +7,10 @@ import { } from './HasOneStore.js' import { HasManyStore } from './HasManyStore.js' import { - computeDefaultOrderedIds, type HasManyAdditionKind, + type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewProjection, type SentHasManyDelta, type StoredHasManyState, } from './hasManyState.js' @@ -19,13 +20,16 @@ export type { StoredRelationState } from './HasOneStore.js' export type { SentHasOneTransition } from './HasOneStore.js' export type { HasManyAdditionKind, + HasManyRelationProjection, HasManyRemovalType, + HasManyView, + HasManyViewProjection, + PlannedHasManyAddition, SentHasManyAddition, SentHasManyDelta, SentHasManyRemoval, StoredHasManyState, } from './hasManyState.js' -export { computeDefaultOrderedIds } from './hasManyState.js' export type RelationReconciliationResult = 'applied' | 'conflict' @@ -103,28 +107,32 @@ export class RelationStore implements Rekeyable { // ==================== Has-Many Relations ==================== - getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { - return this.hasMany.getOrCreateHasMany(key, serverIds) + getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void { + this.hasMany.getOrCreateHasMany(key, alias, serverIds) } getHasMany(key: string): StoredHasManyState | undefined { return this.hasMany.getHasMany(key) } - setHasManyServerIds(key: string, serverIds: string[]): void { - this.hasMany.setHasManyServerIds(key, serverIds) + getHasManyRelation(key: string): HasManyRelationProjection | undefined { + return this.hasMany.getRelationProjection(key) } - planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { - this.hasMany.planHasManyRemoval(key, itemId, type) + getHasManyView(key: string, alias: string): HasManyViewProjection { + return this.hasMany.getViewProjection(key, alias) + } + + setHasManyServerIds(key: string, alias: string, serverIds: string[]): void { + this.hasMany.setHasManyServerIds(key, alias, serverIds) } - planHasManyConnection(key: string, itemId: string): void { - this.hasMany.planHasManyConnection(key, itemId) + planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { + this.hasMany.planHasManyRemoval(key, itemId, type) } - commitHasMany(key: string, newServerIds: string[]): void { - this.hasMany.commitHasMany(key, newServerIds) + planHasManyConnection(key: string, alias: string, itemId: string): void { + this.hasMany.planHasManyConnection(key, alias, itemId) } reconcileSentHasMany( @@ -138,24 +146,24 @@ export class RelationStore implements Rekeyable { this.hasMany.resetHasMany(key) } - addToHasMany(key: string, itemId: string): void { - this.hasMany.addToHasMany(key, itemId) + addToHasMany(key: string, alias: string, itemId: string): void { + this.hasMany.addToHasMany(key, alias, itemId) } - connectExistingToHasMany(key: string, itemId: string): void { - this.hasMany.connectExistingToHasMany(key, itemId) + connectExistingToHasMany(key: string, alias: string, itemId: string): void { + this.hasMany.connectExistingToHasMany(key, alias, itemId) } removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean { return this.hasMany.removeFromHasMany(key, itemId, removalType) } - moveInHasMany(key: string, fromIndex: number, toIndex: number): void { - this.hasMany.moveInHasMany(key, fromIndex, toIndex) + moveInHasMany(key: string, alias: string, fromIndex: number, toIndex: number): void { + this.hasMany.moveInHasMany(key, alias, fromIndex, toIndex) } - getHasManyOrderedIds(key: string): string[] { - return this.hasMany.getHasManyOrderedIds(key) + getHasManyOrderedIds(key: string, alias: string): string[] { + return this.hasMany.getHasManyOrderedIds(key, alias) } /** diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index b5a2ca9..8bd473f 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -5,7 +5,9 @@ import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult import { ErrorStore } from './ErrorStore.js' import { RelationStore, + type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewProjection, type RelationReconciliationResult, type SentHasManyDelta, type SentHasOneTransition, @@ -32,7 +34,12 @@ import type { } from '../undo/UndoJournal.js' export type { + HasManyAdditionKind, + HasManyRelationProjection, HasManyRemovalType, + HasManyView, + HasManyViewProjection, + PlannedHasManyAddition, RelationReconciliationResult, SentHasManyAddition, SentHasManyDelta, @@ -530,25 +537,44 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // ==================== Has-Many State (delegated to RelationStore) ==================== + /** + * Materializes the relation and one args-view of it, refreshing that view's + * server baseline when one is supplied. + * + * `alias` addresses a VIEW inside the relation state, never a key of its own: + * the state is always keyed by the schema field name, so everything that names + * the relation the way the schema and the server do finds it. + */ getOrCreateHasMany( parentType: string, parentId: string, fieldName: string, serverIds?: string[], alias?: string, - ): StoredHasManyState { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getOrCreateHasMany(key, serverIds) + ): void { + const key = this.getRelationKey(parentType, parentId, fieldName) + this.relations.getOrCreateHasMany(key, alias ?? fieldName, serverIds) } + /** The relation as persistence and dirty tracking see it — server baseline unioned across views. */ getHasMany( parentType: string, parentId: string, fieldName: string, + ): HasManyRelationProjection | undefined { + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyRelation(key) + } + + /** The relation as ONE mounted has-many handle sees it. */ + getHasManyView( + parentType: string, + parentId: string, + fieldName: string, alias?: string, - ): StoredHasManyState | undefined { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasMany(key) + ): HasManyViewProjection { + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyView(key, alias ?? fieldName) } setHasManyServerIds( @@ -558,8 +584,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { serverIds: string[], alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - this.relations.setHasManyServerIds(key, serverIds) + const key = this.getRelationKey(parentType, parentId, fieldName) + this.relations.setHasManyServerIds(key, alias ?? fieldName, serverIds) this.notifyRelationSubscribers(key) } @@ -569,9 +595,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { fieldName: string, itemId: string, type: HasManyRemovalType, - alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) this.relations.planHasManyRemoval(key, itemId, type) this.notifyRelationSubscribers(key) @@ -581,10 +606,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentType: string, parentId: string, fieldName: string, - alias?: string, ): Map | undefined { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasMany(key)?.plannedRemovals + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyRelation(key)?.plannedRemovals } /** @@ -603,34 +627,22 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { itemId: string, alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.planHasManyConnection(key, itemId) + this.relations.planHasManyConnection(key, alias ?? fieldName, itemId) this.notifyRelationSubscribers(key) } + /** The connections the next persist will send — relation-level, not per view. */ getHasManyPlannedConnections( parentType: string, parentId: string, fieldName: string, - alias?: string, ): Set | undefined { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - const state = this.relations.getHasMany(key) - if (!state) return undefined - return new Set(state.plannedAdditions.keys()) - } - - commitHasMany( - parentType: string, - parentId: string, - fieldName: string, - newServerIds: string[], - alias?: string, - ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - this.relations.commitHasMany(key, newServerIds) - this.notifyRelationSubscribers(key) + const key = this.getRelationKey(parentType, parentId, fieldName) + const relation = this.relations.getHasManyRelation(key) + if (!relation) return undefined + return new Set(relation.plannedAdditions.keys()) } reconcileSentHasMany( @@ -638,9 +650,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentId: string, fieldName: string, delta: SentHasManyDelta, - alias?: string, ): RelationReconciliationResult { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) const result = this.relations.reconcileSentHasMany(key, delta) this.notifyRelationSubscribers(key) return result @@ -650,9 +661,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentType: string, parentId: string, fieldName: string, - alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) this.relations.resetHasMany(key) this.notifyRelationSubscribers(key) @@ -665,9 +675,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { itemId: string, alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.addToHasMany(key, itemId) + this.relations.addToHasMany(key, alias ?? fieldName, itemId) this.notifyRelationSubscribers(key) } @@ -676,10 +686,11 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentId: string, fieldName: string, itemId: string, + alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.connectExistingToHasMany(key, itemId) + this.relations.connectExistingToHasMany(key, alias ?? fieldName, itemId) this.notifyRelationSubscribers(key) } @@ -689,9 +700,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { fieldName: string, itemId: string, removalType: HasManyRemovalType, - alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) // Cancelling the add of a never-persisted child just removes it from the // list; its now-unreachable snapshot is no longer reported as a `create` and @@ -709,9 +719,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { toIndex: number, alias?: string, ): void { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.moveInHasMany(key, fromIndex, toIndex) + this.relations.moveInHasMany(key, alias ?? fieldName, fromIndex, toIndex) this.notifyRelationSubscribers(key) } @@ -721,8 +731,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { fieldName: string, alias?: string, ): string[] { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasManyOrderedIds(key) + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyOrderedIds(key, alias ?? fieldName) } getPresentationHasManyOrderedIds( @@ -731,13 +741,13 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { fieldName: string, alias?: string, ): string[] { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const key = this.getRelationKey(parentType, parentId, fieldName) + const view = alias ?? fieldName if (!this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) { - return this.relations.getHasManyOrderedIds(key) + return this.relations.getHasManyOrderedIds(key, view) } - const state = this.relations.getHasMany(key) - return state ? [...state.serverIds] : [] + return [...this.relations.getHasManyView(key, view).serverIds] } isHasManyItemCreated( @@ -745,24 +755,21 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentId: string, fieldName: string, itemId: string, - alias?: string, ): boolean { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - const state = this.relations.getHasMany(key) - return state?.plannedAdditions.get(itemId) === 'created' + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyRelation(key)?.plannedAdditions.get(itemId) === 'created' } getHasManyCreatedEntities( parentType: string, parentId: string, fieldName: string, - alias?: string, ): Set | undefined { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - const state = this.relations.getHasMany(key) - if (!state) return undefined + const key = this.getRelationKey(parentType, parentId, fieldName) + const relation = this.relations.getHasManyRelation(key) + if (!relation) return undefined const created = new Set() - for (const [id, kind] of state.plannedAdditions) { + for (const [id, kind] of relation.plannedAdditions) { if (kind === 'created') created.add(id) } return created @@ -1161,9 +1168,13 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * 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 { + getLiveHasManyServerIds(relationKey: string): ReadonlyMap> { const state = this.relations.getHasMany(relationKey) - return new Set(state?.serverIds ?? []) + const byView = new Map>() + if (state) { + for (const [alias, view] of state.views) byView.set(alias, view.serverIds) + } + return byView } exportHasManyCell(key: string): HasManyCellImage { @@ -1173,13 +1184,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { 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, - }, + // getHasMany already returns a deep copy; the image owns it outright. + state, } } @@ -1218,7 +1224,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { const image = this.exportHasManyCell(hasManyKey) images.push(image) if (image.state) { - if (image.state.orderedIds) worklist.push(...image.state.orderedIds) + for (const view of image.state.views.values()) { + if (view.orderedIds) worklist.push(...view.orderedIds) + } for (const additionId of image.state.plannedAdditions.keys()) worklist.push(additionId) } } @@ -1319,9 +1327,24 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { } else { const s = img.state! const live = this.relations.getHasMany(img.key) + // The editable layer (planned writes, manual ordering) comes from the image; + // the server baseline always from the live state, which may have advanced + // since the gesture. A view the image does not know was created afterwards, + // by a non-journaled materialization, so it keeps its live ordering too. + const views = new Map(live?.views ?? []) + for (const [alias, view] of views) { + const recorded = s.views.get(alias) + views.set(alias, { + serverIds: new Set(view.serverIds), + orderedIds: recorded ? (recorded.orderedIds ? [...recorded.orderedIds] : null) : view.orderedIds, + membership: view.membership, + }) + } + for (const [alias, recorded] of s.views) { + if (!views.has(alias)) views.set(alias, recorded) + } this.relations.importHasManyStates(new Map([[img.key, { - serverIds: new Set(live ? live.serverIds : s.serverIds), - orderedIds: s.orderedIds ? [...s.orderedIds] : null, + views, plannedRemovals: new Map(s.plannedRemovals), plannedAdditions: new Map(s.plannedAdditions), version: (live?.version ?? s.version) + 1, diff --git a/packages/bindx/src/store/hasManyState.ts b/packages/bindx/src/store/hasManyState.ts index 2d27358..bc151b5 100644 --- a/packages/bindx/src/store/hasManyState.ts +++ b/packages/bindx/src/store/hasManyState.ts @@ -3,6 +3,25 @@ * * Separated from {@link HasManyStore}, which owns the keyed map, the write * chokepoint and the three indexes. Nothing here touches the store's state. + * + * ## Reads are per view, writes are per relation + * + * A has-many selected with params (filter/orderBy/limit/offset) is fetched under an + * auto-generated alias (`tags_a7x9k2`), and several such VIEWS of one relation can be + * mounted at once. What the server returned differs per view, so {@link HasManyView} + * is keyed by alias. The pending mutations do NOT: the backend has one `tags` relation + * and one mutation input for it, so `plannedAdditions` / `plannedRemovals` live at the + * relation level and the state is keyed by the SCHEMA FIELD NAME alone. + * + * The two directions are deliberately asymmetric, because subset-closure only holds + * one way: + * + * - A **removal** says the item is no longer a member of the relation. Every view is + * a subset of the relation, so the item is hidden in all of them. + * - An **addition** says the item is a member of the relation, which does not settle + * whether it belongs in a *filtered* view — the client cannot evaluate the filter. + * So an addition renders in the views it was made in ({@link PlannedHasManyAddition.origins}) + * plus every view that cannot exclude anything ({@link HasManyView.membership} `'total'`). */ export type ReconciliationResult = 'applied' | 'conflict' @@ -35,6 +54,32 @@ export type HasManyRemovalType = 'disconnect' | 'delete' */ export type HasManyAdditionKind = 'created' | 'connected' +/** + * Whether a view's query args can exclude a member of the relation. + * - 'total': no filter/limit/offset, so every member belongs in this view. + * - 'partial': the args may exclude members and the client cannot evaluate them. + */ +export type HasManyViewMembership = 'total' | 'partial' + +/** What the server returned for ONE args-view of the relation, plus its local order. */ +export interface HasManyView { + /** IDs of items the server returned for this view's args */ + serverIds: Set + /** Explicit ordered list of item IDs, null means use the view's default order */ + orderedIds: string[] | null + membership: HasManyViewMembership +} + +export interface PlannedHasManyAddition { + kind: HasManyAdditionKind + /** + * Aliases of the views the addition was made in — never empty. Presentation only: + * it never affects what is sent. A `connect()` of the same id can legitimately + * arrive from two views, and both are entitled to show it, hence a set. + */ + origins: Set +} + export interface SentHasManyAddition { itemId: string kind: HasManyAdditionKind @@ -51,68 +96,209 @@ export interface SentHasManyDelta { } /** - * Has-many list state stored in SnapshotStore + * Has-many relation state stored in SnapshotStore, keyed by + * "parentType:parentId:fieldName" — the schema field name, never an alias. */ 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 */ + /** Per-args-view server data, keyed by alias; the unparameterized view uses the field name. */ + views: Map + /** Planned removals (disconnect or delete) keyed by entity ID. Relation-level. */ 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. + * Planned additions keyed by entity ID. Relation-level: these are exactly the + * operations the next persist will send. The entries whose kind is 'created' are + * exactly the created entities, so the "created ⊆ connections" invariant is structural. */ + plannedAdditions: Map + version: number +} + +/** + * What every consumer that names the relation the way the schema and the server do + * sees: persistence, dirty tracking, error paths. Views are not its business, so the + * baseline it reads is the union across them. + */ +export interface HasManyRelationProjection { + serverIds: Set + plannedRemovals: Map plannedAdditions: Map version: number } +/** What one mounted has-many handle sees. */ +export interface HasManyViewProjection { + exists: boolean + serverIds: Set + orderedIds: string[] | null + plannedRemovals: Map + /** The additions that render in THIS view. */ + plannedAdditions: Map +} + +export function createHasManyView( + membership: HasManyViewMembership, + serverIds?: Iterable, +): HasManyView { + return { serverIds: new Set(serverIds ?? []), orderedIds: null, membership } +} + +export function cloneHasManyView(view: HasManyView): HasManyView { + return { + serverIds: new Set(view.serverIds), + orderedIds: view.orderedIds ? [...view.orderedIds] : null, + membership: view.membership, + } +} + +export function cloneViews(views: ReadonlyMap): Map { + const clone = new Map() + for (const [alias, view] of views) clone.set(alias, cloneHasManyView(view)) + return clone +} + +export function cloneAddition(addition: PlannedHasManyAddition): PlannedHasManyAddition { + return { kind: addition.kind, origins: new Set(addition.origins) } +} + +export function cloneAdditions( + additions: ReadonlyMap, +): Map { + const clone = new Map() + for (const [id, addition] of additions) clone.set(id, cloneAddition(addition)) + return clone +} + +/** + * Deep clone. Every path that hands a state out or stores a new one goes through + * this: {@link SnapshotStore}'s dirty-version memo assumes values are REPLACED, never + * mutated in place, and with two nesting levels a shallow copy would share mutable + * Sets across store generations. + */ export function cloneHasManyState(state: StoredHasManyState): StoredHasManyState { return { - serverIds: new Set(state.serverIds), - orderedIds: state.orderedIds ? [...state.orderedIds] : null, + views: cloneViews(state.views), plannedRemovals: new Map(state.plannedRemovals), - plannedAdditions: new Map(state.plannedAdditions), + plannedAdditions: cloneAdditions(state.plannedAdditions), version: state.version, } } +export function emptyHasManyState(): StoredHasManyState { + return { views: new Map(), plannedRemovals: new Map(), plannedAdditions: new Map(), version: 0 } +} + +/** Whether an addition renders in {@link alias} — see the asymmetry note at the top. */ +export function additionRendersIn( + views: ReadonlyMap, + alias: string, + addition: PlannedHasManyAddition, +): boolean { + if (addition.origins.has(alias)) return true + return views.get(alias)?.membership === 'total' +} + +/** The aliases of existing views a confirmed addition folds into. */ +export function additionFoldTargets( + state: StoredHasManyState, + addition: PlannedHasManyAddition, +): string[] { + const targets: string[] = [] + for (const alias of state.views.keys()) { + if (additionRendersIn(state.views, alias, addition)) targets.push(alias) + } + return targets +} + /** - * Computes the default ordered IDs for a has-many relation. - * Order is: serverIds (minus removals) + plannedAdditions + * Default order of one view: its server rows minus the relation's planned removals, + * then the planned additions that render there. */ -export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { +export function computeViewOrderedIds(state: StoredHasManyState, alias: string): string[] { const result: string[] = [] + const view = state.views.get(alias) - for (const id of state.serverIds) { - if (!state.plannedRemovals.has(id)) { - result.push(id) + if (view) { + for (const id of view.serverIds) { + if (!state.plannedRemovals.has(id)) result.push(id) } } - for (const id of state.plannedAdditions.keys()) { - if (!result.includes(id)) { - result.push(id) - } + for (const [id, addition] of state.plannedAdditions) { + if (state.plannedRemovals.has(id)) continue + if (!additionRendersIn(state.views, alias, addition)) continue + if (!result.includes(id)) result.push(id) } return result } +/** Explicit order of a view if it has one, otherwise its default order. */ +export function viewOrderedIds(state: StoredHasManyState, alias: string): string[] { + const explicit = state.views.get(alias)?.orderedIds + return explicit !== null && explicit !== undefined ? [...explicit] : computeViewOrderedIds(state, alias) +} + +/** The relation's server baseline: the union across views. */ +export function relationServerIds(state: StoredHasManyState): Set { + const ids = new Set() + for (const view of state.views.values()) { + for (const id of view.serverIds) ids.add(id) + } + return ids +} + +export function toRelationProjection(state: StoredHasManyState): HasManyRelationProjection { + const plannedAdditions = new Map() + for (const [id, addition] of state.plannedAdditions) plannedAdditions.set(id, addition.kind) + return { + serverIds: relationServerIds(state), + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions, + version: state.version, + } +} + +export function toViewProjection( + state: StoredHasManyState | undefined, + alias: string, +): HasManyViewProjection { + if (!state) { + return { + exists: false, + serverIds: new Set(), + orderedIds: null, + plannedRemovals: new Map(), + plannedAdditions: new Map(), + } + } + + const view = state.views.get(alias) + const plannedAdditions = new Map() + for (const [id, addition] of state.plannedAdditions) { + if (additionRendersIn(state.views, alias, addition)) plannedAdditions.set(id, addition.kind) + } + + return { + exists: view !== undefined, + serverIds: new Set(view?.serverIds), + orderedIds: view?.orderedIds ? [...view.orderedIds] : null, + plannedRemovals: new Map(state.plannedRemovals), + plannedAdditions, + } +} + /** * 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. + * (every view's serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and + * consumed by the write chokepoint so the forward/reverse index can never drift from it. */ export 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 view of state.views.values()) { + for (const id of view.serverIds) { + if (!state.plannedRemovals.has(id)) live.add(id) + } } for (const id of state.plannedAdditions.keys()) { if (!state.plannedRemovals.has(id)) live.add(id) @@ -134,18 +320,34 @@ export interface HasManyReconciliation { result: ReconciliationResult } +/** + * Advances the server baseline by the confirmed sent delta and rebases local edits + * made after the request went out. + * + * The rebase logic is relation-level and unchanged by views — "did the user locally + * undo this while the request was in flight" has one answer per relation. Only the + * baseline bookkeeping is per view: a confirmed addition joins the views it renders + * in, a confirmed removal leaves every view. + */ export function reconcileHasManyState( existing: StoredHasManyState, delta: SentHasManyDelta, ): HasManyReconciliation { const currentLive = liveHasManyChildIds(existing) - const serverIds = new Set(existing.serverIds) - const plannedAdditions = new Map(existing.plannedAdditions) + const views = cloneViews(existing.views) + const plannedAdditions = cloneAdditions(existing.plannedAdditions) const plannedRemovals = new Map(existing.plannedRemovals) let result: ReconciliationResult = 'applied' for (const addition of delta.additions) { - serverIds.add(addition.itemId) + // Read the origins before dropping the record. A locally cancelled addition + // leaves none, and the row then joins only the views that cannot exclude it. + const planned = plannedAdditions.get(addition.itemId) + const targets = planned + ? additionFoldTargets(existing, planned) + : [...views].filter(([, view]) => view.membership === 'total').map(([alias]) => alias) + for (const alias of targets) views.get(alias)?.serverIds.add(addition.itemId) + plannedAdditions.delete(addition.itemId) plannedRemovals.delete(addition.itemId) if (!currentLive.has(addition.itemId)) { @@ -157,7 +359,8 @@ export function reconcileHasManyState( } for (const removal of delta.removals) { - serverIds.delete(removal.itemId) + for (const view of views.values()) view.serverIds.delete(removal.itemId) + const currentRemoval = plannedRemovals.get(removal.itemId) if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId) @@ -168,18 +371,17 @@ export function reconcileHasManyState( if (removal.type === 'delete') { result = 'conflict' } else if (!plannedAdditions.has(removal.itemId)) { - plannedAdditions.set(removal.itemId, 'connected') + // Re-plan the connection in every view that showed the row, so the rebase is + // visible where the user is looking. + plannedAdditions.set(removal.itemId, { + kind: 'connected', + origins: new Set(existing.views.keys()), + }) } } return { - state: { - serverIds, - orderedIds: existing.orderedIds ? [...existing.orderedIds] : null, - plannedRemovals, - plannedAdditions, - version: existing.version + 1, - }, + state: { views, plannedRemovals, plannedAdditions, version: existing.version + 1 }, result, } } diff --git a/packages/bindx/src/store/relationKey.ts b/packages/bindx/src/store/relationKey.ts index f3c2cd5..7b478b9 100644 --- a/packages/bindx/src/store/relationKey.ts +++ b/packages/bindx/src/store/relationKey.ts @@ -9,6 +9,15 @@ export function parentKeyFromRelationKey(relationKey: string): string { return relationKey.slice(0, lastSeparator) } +/** + * Derives the field name from a relation key ("parentType:parentId:fieldName"). + * The counterpart of {@link parentKeyFromRelationKey}: entity ids and field names + * never contain ':', so the field is everything after the last separator. + */ +export function fieldFromRelationKey(relationKey: string): string { + return relationKey.slice(relationKey.lastIndexOf(':') + 1) +} + /** * Derives the parent composite key ("parentType:parentId") from an owner key * prefix ("parentType:parentId:") by dropping the trailing separator. Callers diff --git a/packages/bindx/src/undo/UndoJournal.ts b/packages/bindx/src/undo/UndoJournal.ts index 3f407e1..4f74af2 100644 --- a/packages/bindx/src/undo/UndoJournal.ts +++ b/packages/bindx/src/undo/UndoJournal.ts @@ -113,7 +113,9 @@ function collectDetachedSeedIds(active: Map): Set => this.store.getLiveHasManyServerIds(key) + const liveServerIds = (key: string): ReadonlyMap> => this.store.getLiveHasManyServerIds(key) const undoCount = this.undoStack.length const redoCount = this.redoStack.length const hadPending = this.pending !== null && this.pending.size > 0 diff --git a/packages/bindx/src/undo/rekeyJournalEntry.ts b/packages/bindx/src/undo/rekeyJournalEntry.ts index 7797f81..4cf83cb 100644 --- a/packages/bindx/src/undo/rekeyJournalEntry.ts +++ b/packages/bindx/src/undo/rekeyJournalEntry.ts @@ -1,7 +1,7 @@ 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 { cloneHasManyState, type StoredHasManyState } from '../store/hasManyState.js' import type { JournalEntry, JournalCellImage } from './UndoJournal.js' /** @@ -15,11 +15,11 @@ import type { JournalEntry, JournalCellImage } from './UndoJournal.js' * 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. + * Looks up the live server-member ids of a has-many relation by its key, per + * args-view. 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 type LiveServerIdsLookup = (relationKey: string) => ReadonlyMap> export function rekeyJournalEntry( entry: JournalEntry, @@ -77,19 +77,19 @@ function rekeyCell( const key = rekeyKey(cell.key, ctx) if (!cell.state) return { ...cell, key } - let state = rekeyHasManyState(cell.state, ctx) + const 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] + // a live view, fold it into the (older) pre-image of that view so undo keeps it + // instead of dropping it. Default order picks it up automatically; an explicit + // order needs the id appended. Views born after the gesture are left alone — + // the pre-image says nothing about them. + const live = liveServerIds(key) + for (const [alias, view] of state.views) { + if (!live.get(alias)?.has(ctx.newId) || view.serverIds.has(ctx.newId)) continue + view.serverIds.add(ctx.newId) + if (view.orderedIds && !view.orderedIds.includes(ctx.newId)) { + view.orderedIds = [...view.orderedIds, ctx.newId] } - state = { ...state, serverIds, orderedIds } } return { ...cell, key, state } } @@ -105,41 +105,29 @@ function rekeySnapshotId(snapshot: EntitySnapshot, ctx: RekeyContext): EntitySna ) } -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 -} - +/** Returns a deep copy with oldId swapped for newId everywhere it appears. */ function rekeyHasManyState(state: StoredHasManyState, ctx: RekeyContext): StoredHasManyState { - const serverIds = swapInSet(state.serverIds, ctx.oldId, ctx.newId) + const next = cloneHasManyState(state) - let orderedIds = state.orderedIds - if (orderedIds) { - const idx = orderedIds.indexOf(ctx.oldId) - if (idx !== -1) { - orderedIds = [...orderedIds] - orderedIds[idx] = ctx.newId + for (const view of next.views.values()) { + if (view.serverIds.delete(ctx.oldId)) view.serverIds.add(ctx.newId) + if (view.orderedIds) { + const idx = view.orderedIds.indexOf(ctx.oldId) + if (idx !== -1) view.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) + const addition = next.plannedAdditions.get(ctx.oldId) + if (addition) { + next.plannedAdditions.delete(ctx.oldId) + next.plannedAdditions.set(ctx.newId, addition) } - let plannedRemovals = state.plannedRemovals - const removalType = plannedRemovals.get(ctx.oldId) + const removalType = next.plannedRemovals.get(ctx.oldId) if (removalType !== undefined) { - plannedRemovals = new Map(plannedRemovals) - plannedRemovals.delete(ctx.oldId) - plannedRemovals.set(ctx.newId, removalType) + next.plannedRemovals.delete(ctx.oldId) + next.plannedRemovals.set(ctx.newId, removalType) } - return { serverIds, orderedIds, plannedRemovals, plannedAdditions, version: state.version } + return next } diff --git a/tests/react/relations/hasMany/addWithArgsPersistsNested.test.tsx b/tests/react/relations/hasMany/addWithArgsPersistsNested.test.tsx new file mode 100644 index 0000000..aaac46e --- /dev/null +++ b/tests/react/relations/hasMany/addWithArgsPersistsNested.test.tsx @@ -0,0 +1,197 @@ +import '../../../setup' +// Regression tests for https://github.com/contember/bindx/issues/121 +// (a has-many selected WITH args was stored under its query alias, so everything +// that addresses the relation by its schema field name — the persister above all — +// never found the pending writes) +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, useEntity, usePersist, usePersistEntity } from '@contember/bindx-react' +import type { CreateResult, PersistResult } from '@contember/bindx' +import { createMockData, entityDefs, queryByTestId, schema } from './setup' + +afterEach(() => { + cleanup() +}) + +type RecordedCall = + | { kind: 'persist'; entityType: string; id: string; changes: Record } + | { kind: 'create'; entityType: string; data: Record } + +/** MockAdapter that records what the persister asks it to write. */ +class RecordingAdapter extends MockAdapter { + readonly calls: RecordedCall[] = [] + + override async persist(entityType: string, id: string, changes: Record): Promise { + this.calls.push({ kind: 'persist', entityType, id, changes }) + return super.persist(entityType, id, changes) + } + + override async create(entityType: string, data: Record): Promise { + this.calls.push({ kind: 'create', entityType, data }) + return super.create(entityType, data) + } +} + +/** + * What a loaded article lets a test do. Deliberately actions rather than accessors: + * each variant keeps its own fully-inferred selection type and publishes only these. + */ +interface ArticleActions { + addTag: () => void + removeTag: (id: string) => void + dirtyRelations: () => readonly string[] + persistAll: () => Promise + persistTagsRelation: () => Promise +} + +/** The minimal has-many surface these tests drive. */ +interface TagList { + add: (data: { name: string }) => unknown + remove: (id: string) => void +} + +type ArticleProps = { publish: (actions: ArticleActions) => void } + +interface Harness { + readonly adapter: RecordingAdapter + /** Runs a gesture inside act(). */ + act: (gesture: (actions: ArticleActions) => void) => void + run: (gesture: (actions: ArticleActions) => Promise) => Promise +} + +/** Renders the given article component and waits for article-1 to load. */ +async function mount(Article: React.ComponentType): Promise { + const adapter = new RecordingAdapter(createMockData(), { delay: 0 }) + let actions: ArticleActions | null = null + + const { container } = render( + +
{ actions = next }} /> + , + ) + await waitFor(() => expect(queryByTestId(container, 'tag-count')).not.toBeNull()) + + return { + adapter, + act: gesture => { + act(() => { + gesture(actions!) + }) + }, + run: async gesture => { + await act(async () => { + await gesture(actions!) + }) + }, + } +} + +/** Everything both variants share — all of it except the selection itself. */ +function usePublishedActions(publish: (actions: ArticleActions) => void, tags: TagList): void { + const persist = usePersist() + const entityPersist = usePersistEntity('Article', 'article-1') + + publish({ + addTag: () => { tags.add({ name: 'New Tag' }) }, + removeTag: id => { tags.remove(id) }, + dirtyRelations: () => entityPersist.dirtyRelations, + persistAll: () => persist.persistAll(), + persistTagsRelation: () => persist.persistScope({ + type: 'relation', + entityType: 'Article', + entityId: 'article-1', + relationName: 'tags', + }), + }) +} + +/** Stand-in while the article is still loading — hooks must run unconditionally. */ +const NO_TAGS: TagList = { add: () => undefined, remove: () => undefined } + +function ArticleWithoutArgs({ publish }: ArticleProps) { + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => e.id().title().tags(t => t.id().name())) + const ready = article.$status === 'ready' ? article : null + usePublishedActions(publish, ready ? ready.tags : NO_TAGS) + + if (!ready) return
Loading
+ return {ready.tags.length} +} + +function ArticleWithOrderBy({ publish }: ArticleProps) { + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, e => e.id().title().tags({ orderBy: [{ name: 'asc' }] }, t => t.id().name())) + const ready = article.$status === 'ready' ? article : null + usePublishedActions(publish, ready ? ready.tags : NO_TAGS) + + if (!ready) return
Loading
+ return {ready.tags.length} +} + +/** The operations the article update carried for its `tags` relation. */ +const tagOperations = (harness: Harness): unknown[] => + harness.adapter.calls + .filter((call): call is Extract => call.kind === 'persist' && call.entityType === 'Article') + .flatMap(call => (Array.isArray(call.changes['tags']) ? (call.changes['tags'] as unknown[]) : [])) + +const operationsOfKind = (harness: Harness, kind: string): unknown[] => + tagOperations(harness).filter(op => typeof op === 'object' && op !== null && kind in op) + +// Selection args only shape the read. They never change what the parent owns, so +// both variants must persist identically. With args the relation is fetched under a +// generated alias (`tags_`) instead of its field name — that is the whole +// difference this file is about. +const variants = [ + { label: 'without args', Article: ArticleWithoutArgs }, + { label: 'with orderBy', Article: ArticleWithOrderBy }, +] + +describe('has-many persist is independent of the selection args', () => { + for (const { label, Article } of variants) { + describe(label, () => { + test('add() nests the new item under its parent', async () => { + const harness = await mount(Article) + + harness.act(actions => actions.addTag()) + await harness.run(actions => actions.persistAll()) + + // A standalone create would lose the relation to the article; against a + // Contember project with a non-null inverse it fails outright with + // "Validation has failed: … Field is required". + expect(operationsOfKind(harness, 'create')).toHaveLength(1) + expect(harness.adapter.calls.filter(call => call.kind === 'create')).toHaveLength(0) + }) + + test('remove() disconnects the item', async () => { + const harness = await mount(Article) + + harness.act(actions => actions.removeTag('tag-1')) + await harness.run(actions => actions.persistAll()) + + expect(operationsOfKind(harness, 'disconnect')).toEqual([ + { disconnect: { id: 'tag-1' }, alias: 'tag-1' }, + ]) + }) + + test('the relation reports dirty under its schema field name', async () => { + const harness = await mount(Article) + + harness.act(actions => actions.addTag()) + + // usePersistEntity exposes this to application code, and persistScope + // matches RelationScope.relationName against it. + harness.act(actions => { + expect(actions.dirtyRelations()).toContain('tags') + }) + }) + + test('persistScope on the relation saves it', async () => { + const harness = await mount(Article) + + harness.act(actions => actions.addTag()) + await harness.run(actions => actions.persistTagsRelation()) + + expect(operationsOfKind(harness, 'create')).toHaveLength(1) + }) + }) + } +}) diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index db70ee1..c991657 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -85,7 +85,7 @@ describe('undo journal — deep coverage', () => { 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.commitAllRelations('Article', 'p') store.commitEntity('Item', 'ctemp') store.setExistsOnServer('Item', 'ctemp', true) store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') @@ -116,7 +116,7 @@ describe('undo journal — deep coverage', () => { undo.endGroup(groupId) // Persist everything: commit list + sibling, rekey C. - store.commitHasMany('Article', 'p', 'items', ['s', 'ctemp']) + store.commitAllRelations('Article', 'p') store.commitEntity('Item', 's') store.commitEntity('Item', 'ctemp') store.setExistsOnServer('Item', 'ctemp', true) @@ -151,7 +151,7 @@ describe('undo journal — deep coverage', () => { expect(store.getHasManyOrderedIds('Article', 'p', 'items')).toEqual(['s2', 's1', 'ctemp']) // Persist: commit + rekey C. - store.commitHasMany('Article', 'p', 'items', ['s1', 's2', 'ctemp']) + store.commitAllRelations('Article', 'p') store.commitEntity('Item', 'ctemp') store.setExistsOnServer('Item', 'ctemp', true) store.mapTempIdToPersistedId('Item', 'ctemp', 'cp') diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index 36c4b50..990e93c 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -139,10 +139,19 @@ describe('HasMany with Alias Support', () => { }) }) - // ==================== Multiple HasMany with Different Aliases ==================== - - describe('Multiple HasMany with Different Aliases', () => { - test('should store data separately for different aliases', () => { + // ==================== Views of one relation ==================== + + /** + * A has-many selected with params is fetched under an alias, and several such + * VIEWS of one relation can be mounted at once. What the server returned is per + * view; the pending writes are not — the backend has one `tags` relation and one + * mutation input for it. + */ + describe('Views of one relation', () => { + const activeAlias = generateHasManyAlias('tags', { filter: { active: true } }) + const inactiveAlias = generateHasManyAlias('tags', { filter: { active: false } }) + + beforeEach(() => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', @@ -151,126 +160,92 @@ describe('HasMany with Alias Support', () => { { id: 't-2', name: 'Tag 2', active: false }, ], }, true) + }) - // Create two handles with different aliases (simulating different filters) - const alias1 = generateHasManyAlias('tags', { filter: { active: true } }) - const alias2 = generateHasManyAlias('tags', { filter: { active: false } }) - - const handle1 = createHasManyHandle(alias1) - const handle2 = createHasManyHandle(alias2) - - // Initialize both with different server IDs (simulating different filtered results) - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], alias2) + test('each view keeps the server rows its own args returned', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], inactiveAlias) - // Now connect different items to each - store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', alias1) - store.planHasManyConnection('Article', 'a-1', 'tags', 't-4', alias2) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual(['t-2']) + }) - // Verify they have different planned connections - const connections1 = store.getHasManyPlannedConnections('Article', 'a-1', 'tags', alias1) - const connections2 = store.getHasManyPlannedConnections('Article', 'a-1', 'tags', alias2) + test('the relation baseline is the union across views', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], inactiveAlias) - expect(connections1?.has('t-3')).toBe(true) - expect(connections1?.has('t-4')).toBe(false) - expect(connections2?.has('t-4')).toBe(true) - expect(connections2?.has('t-3')).toBe(false) + expect(store.getHasMany('Article', 'a-1', 'tags')?.serverIds).toEqual(new Set(['t-1', 't-2'])) }) - test('should track dirty state independently for different aliases', () => { - store.setEntityData('Article', 'a-1', { - id: 'a-1', - title: 'Test', - tags: [ - { id: 't-1', name: 'Tag 1', active: true }, - { id: 't-2', name: 'Tag 2', active: false }, - ], - }, true) - - const alias1 = generateHasManyAlias('tags', { filter: { active: true } }) - const alias2 = generateHasManyAlias('tags', { filter: { active: false } }) + test('a connection is one pending write on the relation, not one per view', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], inactiveAlias) - const handle1 = createHasManyHandle(alias1) - const handle2 = createHasManyHandle(alias2) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) - // Initialize both - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], alias2) + // What the next persist will send — addressed by the schema field name. + expect(store.getHasManyPlannedConnections('Article', 'a-1', 'tags')).toEqual(new Set(['t-3'])) + }) - // Both should be clean initially - expect(handle1.isDirty).toBe(false) - expect(handle2.isDirty).toBe(false) + test('an addition renders only in the view it was made in', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], inactiveAlias) - // Make changes only to handle1 - handle1.connect('t-3') + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) - // Only handle1 should be dirty - expect(handle1.isDirty).toBe(true) - expect(handle2.isDirty).toBe(false) + // The client cannot evaluate the other view's filter, so it must not guess. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-1', 't-3']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual(['t-2']) }) - test('should maintain separate ordered IDs for different aliases', () => { - store.setEntityData('Article', 'a-1', { - id: 'a-1', - title: 'Test', - tags: [ - { id: 't-1', name: 'Tag 1' }, - { id: 't-2', name: 'Tag 2' }, - { id: 't-3', name: 'Tag 3' }, - ], - }, true) + test('an addition also renders in a view whose args cannot exclude it', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2']) - const alias1 = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) - const alias2 = generateHasManyAlias('tags', { orderBy: [{ name: 'desc' }] }) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) - // Initialize with different ordered IDs - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2', 't-3'], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-3', 't-2', 't-1'], alias2) + // The unparameterized view has no filter to violate. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 't-3']) + }) - // Move in one alias shouldn't affect the other - store.moveInHasMany('Article', 'a-1', 'tags', 0, 2, alias1) + test('a removal hides the item in every view', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], inactiveAlias) - const orderedIds1 = store.getHasManyOrderedIds('Article', 'a-1', 'tags', alias1) - const orderedIds2 = store.getHasManyOrderedIds('Article', 'a-1', 'tags', alias2) + store.planHasManyRemoval('Article', 'a-1', 'tags', 't-1', 'disconnect') - expect(orderedIds1).toEqual(['t-2', 't-3', 't-1']) - expect(orderedIds2).toEqual(['t-3', 't-2', 't-1']) + // The row leaves the relation, so no view may keep showing it — the persist + // disconnects it regardless of which view the user was looking at. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual([]) }) - test('should reset each alias independently', () => { - store.setEntityData('Article', 'a-1', { - id: 'a-1', - title: 'Test', - tags: [], - }, true) - - const alias1 = generateHasManyAlias('tags', { filter: { active: true } }) - const alias2 = generateHasManyAlias('tags', { filter: { active: false } }) + test('manual ordering stays per view', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2', 't-3'], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-3', 't-2', 't-1'], inactiveAlias) - // Initialize both - store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias2) + store.moveInHasMany('Article', 'a-1', 'tags', 0, 2, activeAlias) - // Make changes to both - store.planHasManyConnection('Article', 'a-1', 'tags', 't-1', alias1) - store.planHasManyConnection('Article', 'a-1', 'tags', 't-2', alias2) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-3', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual(['t-3', 't-2', 't-1']) + }) - // Reset only alias1 - store.resetHasMany('Article', 'a-1', 'tags', alias1) + test('reset clears the relation, not one view of it', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', [], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', [], inactiveAlias) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-1', activeAlias) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-2', inactiveAlias) - // alias1 should be reset, alias2 should still have its connection - const state1 = store.getHasMany('Article', 'a-1', 'tags', alias1) - const state2 = store.getHasMany('Article', 'a-1', 'tags', alias2) + store.resetHasMany('Article', 'a-1', 'tags') - expect(state1?.plannedAdditions.size).toBe(0) - expect(state2?.plannedAdditions.size).toBe(1) + expect(store.getHasMany('Article', 'a-1', 'tags')?.plannedAdditions.size).toBe(0) }) }) // ==================== Handle with Alias ==================== describe('HasManyListHandle with Alias', () => { - test('should use alias for all store operations', () => { + test('a connection made through a view is a pending write of the relation', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', @@ -280,19 +255,13 @@ describe('HasMany with Alias Support', () => { const alias = generateHasManyAlias('tags', { filter: { active: true } }) const handle = createHasManyHandle(alias) - // Access items to initialize state handle.items - - // Connect via handle handle.connect('t-2') - // Should be stored under the alias - const connections = store.getHasManyPlannedConnections('Article', 'a-1', 'tags', alias) - expect(connections?.has('t-2')).toBe(true) - - // Should NOT be stored under the base fieldName - const baseConnections = store.getHasManyPlannedConnections('Article', 'a-1', 'tags') - expect(baseConnections).toBeUndefined() + // Addressed by the schema field name — this is what the persister reads. + expect(store.getHasManyPlannedConnections('Article', 'a-1', 'tags')?.has('t-2')).toBe(true) + // And it renders in the view it was made in. + expect(handle.items.map(item => `${item.id}`)).toContain('t-2') }) test('should add new items under the correct alias', () => { @@ -307,12 +276,11 @@ describe('HasMany with Alias Support', () => { const tempId = handle.add({ name: 'New Tag' }) - // Should be tracked under the alias - const state = store.getHasMany('Article', 'a-1', 'tags', alias) - expect(state?.plannedAdditions.get(tempId) === 'created').toBe(true) + expect(store.isHasManyItemCreated('Article', 'a-1', 'tags', tempId)).toBe(true) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', alias)).toEqual([tempId]) }) - test('should remove items from the correct alias', () => { + test('should remove items through the relation', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', @@ -322,33 +290,37 @@ describe('HasMany with Alias Support', () => { const alias = generateHasManyAlias('tags', { filter: { active: true } }) const handle = createHasManyHandle(alias) - // Initialize state store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], alias) handle.remove('t-1') - const removals = store.getHasManyPlannedRemovals('Article', 'a-1', 'tags', alias) - expect(removals?.has('t-1')).toBe(true) + expect(store.getHasManyPlannedRemovals('Article', 'a-1', 'tags')?.has('t-1')).toBe(true) }) - test('should get dirty state from the correct alias', () => { + test('a view reports the relation as dirty, including a sibling view\'s change', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', tags: [], }, true) - const alias = generateHasManyAlias('tags', { filter: { active: true } }) - const handle = createHasManyHandle(alias) + const activeAlias = generateHasManyAlias('tags', { filter: { active: true } }) + const inactiveAlias = generateHasManyAlias('tags', { filter: { active: false } }) + const active = createHasManyHandle(activeAlias) + const inactive = createHasManyHandle(inactiveAlias) - // Initialize state - store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', [], activeAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', [], inactiveAlias) - expect(handle.isDirty).toBe(false) + expect(active.isDirty).toBe(false) + expect(inactive.isDirty).toBe(false) - handle.connect('t-1') + active.connect('t-1') - expect(handle.isDirty).toBe(true) + // Persisting either view sends the whole relation, so a view claiming to be + // clean while pushing its sibling's write would be incoherent. + expect(active.isDirty).toBe(true) + expect(inactive.isDirty).toBe(true) }) test('should move items within the correct alias', () => { @@ -364,16 +336,14 @@ describe('HasMany with Alias Support', () => { const alias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) const handle = createHasManyHandle(alias) - // Initialize state store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], alias) handle.move(0, 1) - const orderedIds = store.getHasManyOrderedIds('Article', 'a-1', 'tags', alias) - expect(orderedIds).toEqual(['t-2', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', alias)).toEqual(['t-2', 't-1']) }) - test('should reset the correct alias', () => { + test('reset clears the pending writes the view can see', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', @@ -383,7 +353,6 @@ describe('HasMany with Alias Support', () => { const alias = generateHasManyAlias('tags', { filter: { active: true } }) const handle = createHasManyHandle(alias) - // Initialize and make changes store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias) handle.connect('t-1') @@ -395,6 +364,7 @@ describe('HasMany with Alias Support', () => { }) }) + // ==================== Backwards Compatibility ==================== describe('Backwards Compatibility', () => { diff --git a/tests/unit/handles/staleRelationData.test.ts b/tests/unit/handles/staleRelationData.test.ts index 0ea8cae..a3e2227 100644 --- a/tests/unit/handles/staleRelationData.test.ts +++ b/tests/unit/handles/staleRelationData.test.ts @@ -331,7 +331,7 @@ describe('Stale relation data after re-fetch', () => { expect(list2.items.length).toBe(1) // T2: Persist succeeds — commit has-many with new serverIds - store.commitHasMany('Program', 'prog-1', 'expertGuarantors', ['g-1']) + store.commitAllRelations('Program', 'prog-1') // Verify committed state via store directly (not through items getter, // which would trigger the bug) diff --git a/tests/unit/store/dirtyRelationLookup.test.ts b/tests/unit/store/dirtyRelationLookup.test.ts index b3cd0e3..536dd58 100644 --- a/tests/unit/store/dirtyRelationLookup.test.ts +++ b/tests/unit/store/dirtyRelationLookup.test.ts @@ -16,8 +16,7 @@ function hasOneState(): StoredRelationState { function hasManyState(): StoredHasManyState { return { - serverIds: new Set(['child']), - orderedIds: null, + views: new Map([['articles', { serverIds: new Set(['child']), orderedIds: null, membership: 'total' }]]), plannedRemovals: new Map([['child', 'disconnect']]), plannedAdditions: new Map(), version: 0, diff --git a/tests/unit/store/getParentKeysForChild.test.ts b/tests/unit/store/getParentKeysForChild.test.ts index 968fa1f..d971755 100644 --- a/tests/unit/store/getParentKeysForChild.test.ts +++ b/tests/unit/store/getParentKeysForChild.test.ts @@ -51,7 +51,7 @@ describe('RelationStore.getParentKeysForChild', () => { test('has-many add returns the parent; remove (disconnect) removes it', () => { const relations = new RelationStore() - relations.addToHasMany('Author:a1:articles', 'art1') + relations.addToHasMany('Author:a1:articles', 'articles', 'art1') expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) relations.removeFromHasMany('Author:a1:articles', 'art1', 'disconnect') @@ -61,7 +61,7 @@ describe('RelationStore.getParentKeysForChild', () => { test('has-many server item is a live parent until removed', () => { const relations = new RelationStore() - relations.setHasManyServerIds('Author:a1:articles', ['art1', 'art2']) + relations.setHasManyServerIds('Author:a1:articles', 'articles', ['art1', 'art2']) expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) relations.planHasManyRemoval('Author:a1:articles', 'art1', 'delete') @@ -77,7 +77,7 @@ describe('RelationStore.getParentKeysForChild', () => { undefined, 'featured', ) - relations.addToHasMany('Tag:t1:articles', 'art1') + relations.addToHasMany('Tag:t1:articles', 'articles', 'art1') expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1', 'Tag:t1'])) }) @@ -93,7 +93,7 @@ describe('RelationStore.getParentKeysForChild', () => { // 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') + relations.addToHasMany('Tag:t1:articles', 'articles', 'shared') expect(relations.getParentKeysForChild('shared')).toEqual(new Set(['Author:a1', 'Tag:t1'])) }) @@ -129,16 +129,16 @@ describe('RelationStore.getParentKeysForChild', () => { relations.setRelation(hasOneKey, { currentId: child, state: 'deleted' }, undefined, 'featured') break case 3: - relations.addToHasMany(hasManyKey, child) + relations.addToHasMany(hasManyKey, 'articles', child) break case 4: - relations.planHasManyConnection(hasManyKey, child) + relations.planHasManyConnection(hasManyKey, 'articles', child) break case 5: relations.removeFromHasMany(hasManyKey, child, 'disconnect') break case 6: - relations.setHasManyServerIds(hasManyKey, [child]) + relations.setHasManyServerIds(hasManyKey, 'articles', [child]) break } diff --git a/tests/unit/store/hasManyAdditionKind.test.ts b/tests/unit/store/hasManyAdditionKind.test.ts index 319c29d..914f6c9 100644 --- a/tests/unit/store/hasManyAdditionKind.test.ts +++ b/tests/unit/store/hasManyAdditionKind.test.ts @@ -20,28 +20,28 @@ describe('HasMany planned-addition kind', () => { 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') + relations.addToHasMany(KEY, 'tags', 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).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') + relations.planHasManyConnection(KEY, 'tags', 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).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') + relations.addToHasMany(KEY, 'tags', 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).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') + relations.connectExistingToHasMany(KEY, 'tags', 'temp-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).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') + relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') + expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')?.kind).toBe('connected') }) }) @@ -49,20 +49,20 @@ describe('HasMany planned-addition kind', () => { 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') + relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') + relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') - expect(relations.getHasManyOrderedIds(KEY)).toEqual(['persisted-1']) - expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')).toBe('connected') + expect(relations.getHasManyOrderedIds(KEY, 'tags')).toEqual(['persisted-1']) + expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')?.kind).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.setHasManyServerIds(KEY, 'tags', ['persisted-1', 'persisted-2']) - relations.connectExistingToHasMany(KEY, 'persisted-1') + relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') - expect(relations.getHasManyOrderedIds(KEY)).toEqual(['persisted-1', 'persisted-2']) + expect(relations.getHasManyOrderedIds(KEY, 'tags')).toEqual(['persisted-1', 'persisted-2']) }) }) }) diff --git a/tests/unit/store/reachabilityMemoization.test.ts b/tests/unit/store/reachabilityMemoization.test.ts index 2d7bec1..4b2fb24 100644 --- a/tests/unit/store/reachabilityMemoization.test.ts +++ b/tests/unit/store/reachabilityMemoization.test.ts @@ -53,7 +53,7 @@ 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') + h.relations.addToHasMany('Article:a1:comments', 'comments', 'c1') } const sortedKeys = (set: Set): string[] => [...set].sort() @@ -95,7 +95,7 @@ describe('reachability memoization', () => { const calls = h.walkCount() h.entitySnapshots.setData('Comment:c2', 'c2', 'Comment', { id: 'c2' }, false) - h.relations.addToHasMany('Article:a1:comments', 'c2') + h.relations.addToHasMany('Article:a1:comments', 'comments', 'c2') const result = h.analyzer.computeReachableCreated() expect(h.walkCount()).toBeGreaterThan(calls) // recomputed @@ -160,7 +160,7 @@ describe('reachability memoization', () => { const v1 = relations.getMutationVersion() expect(v1).toBeGreaterThan(v0) // has-one write counted - relations.addToHasMany('Article:a1:comments', 'c1') + relations.addToHasMany('Article:a1:comments', 'comments', 'c1') const v2 = relations.getMutationVersion() expect(v2).toBeGreaterThan(v1) // has-many write also counted (the sum) }) diff --git a/tests/unit/store/relationEdgeIndex.test.ts b/tests/unit/store/relationEdgeIndex.test.ts index 37c4c8e..92cb41f 100644 --- a/tests/unit/store/relationEdgeIndex.test.ts +++ b/tests/unit/store/relationEdgeIndex.test.ts @@ -65,7 +65,7 @@ describe('RelationStore edge index integration', () => { 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') + relations.addToHasMany('Author:a1:articles', 'articles', 'art1') expect(relations.getParentKeysForChild('art1')).toEqual(new Set(['Author:a1'])) @@ -78,7 +78,7 @@ describe('RelationStore edge index integration', () => { test('replaceEntityId migrates the child id in both directions', () => { const relations = new RelationStore() - relations.addToHasMany('Author:a1:articles', 'old1') + relations.addToHasMany('Author:a1:articles', 'articles', 'old1') relations.setRelation('Author:a1:featured', { currentId: 'old1', state: 'connected' }, undefined, 'featured') expect(relations.getParentKeysForChild('old1')).toEqual(new Set(['Author:a1'])) @@ -95,7 +95,7 @@ describe('RelationStore edge index integration', () => { test('rekeyOwner migrates the parent key in both directions', () => { const relations = new RelationStore() - relations.addToHasMany('Author:a1:articles', 'art1') + relations.addToHasMany('Author:a1:articles', 'articles', 'art1') relations.setRelation('Author:a1:featured', { currentId: 'art2', state: 'connected' }, undefined, 'featured') relations.rekeyOwner('Author:a1:', 'Author:p1:') @@ -108,7 +108,7 @@ describe('RelationStore edge index integration', () => { test('removeOwnedRelations drops all of an owner edges', () => { const relations = new RelationStore() - relations.addToHasMany('Author:a1:articles', 'art1') + relations.addToHasMany('Author:a1:articles', 'articles', 'art1') relations.setRelation('Author:a1:featured', { currentId: 'art2', state: 'connected' }, undefined, 'featured') relations.removeOwnedRelations('Author:a1:') @@ -120,12 +120,12 @@ describe('RelationStore edge index integration', () => { 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') + relations.setHasManyServerIds('Author:a1:articles', 'articles', ['s1', 's2']) + relations.addToHasMany('Author:a1:articles', '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']) + relations.commitAllRelations('Author:a1:') expect(new Set(relations.getLiveChildIds('Author:a1:'))).toEqual(new Set(['s1', 's2', 'c1'])) expect(relations.getParentKeysForChild('c1')).toEqual(new Set(['Author:a1'])) diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index e83adcf..5b5a768 100644 --- a/tests/unit/store/snapshotStore.test.ts +++ b/tests/unit/store/snapshotStore.test.ts @@ -349,25 +349,26 @@ describe('SnapshotStore', () => { describe('Has-Many State', () => { describe('getOrCreateHasMany', () => { test('should create has-many state with server IDs', () => { - const state = store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2']) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2']) - expect(state.serverIds).toEqual(new Set(['t-1', 't-2'])) - expect(state.plannedRemovals.size).toBe(0) - expect(state.plannedAdditions.size).toBe(0) + const state = store.getHasMany('Article', 'a-1', 'tags') + expect(state?.serverIds).toEqual(new Set(['t-1', 't-2'])) + expect(state?.plannedRemovals.size).toBe(0) + expect(state?.plannedAdditions.size).toBe(0) }) test('should update serverIds when called with new values', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) - const state2 = store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2']) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2']) - expect(state2.serverIds).toEqual(new Set(['t-2'])) + expect(store.getHasMany('Article', 'a-1', 'tags')?.serverIds).toEqual(new Set(['t-2'])) }) test('should preserve existing state when called without serverIds', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) - const state2 = store.getOrCreateHasMany('Article', 'a-1', 'tags') + store.getOrCreateHasMany('Article', 'a-1', 'tags') - expect(state2.serverIds).toEqual(new Set(['t-1'])) + expect(store.getHasMany('Article', 'a-1', 'tags')?.serverIds).toEqual(new Set(['t-1'])) }) test('returned state mutations do not bypass the store write path', () => { @@ -393,12 +394,11 @@ describe('SnapshotStore', () => { }) test('should reset orderedIds when server IDs change', () => { - const state = store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) store.moveInHasMany('Article', 'a-1', 'tags', 0, 0) // triggers orderedIds store.setHasManyServerIds('Article', 'a-1', 'tags', ['t-1', 't-2']) - const newState = store.getHasMany('Article', 'a-1', 'tags') - expect(newState?.orderedIds).toBeNull() + expect(store.getHasManyView('Article', 'a-1', 'tags').orderedIds).toBeNull() }) test('ordered id reads cannot mutate the stored explicit order', () => { @@ -585,11 +585,11 @@ describe('SnapshotStore', () => { }) }) - describe('commitHasMany', () => { + describe('commitAllRelations', () => { test('should update server IDs and clear planned operations', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1']) store.planHasManyConnection('Article', 'a-1', 'tags', 't-2') - store.commitHasMany('Article', 'a-1', 'tags', ['t-1', 't-2']) + store.commitAllRelations('Article', 'a-1') const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.serverIds).toEqual(new Set(['t-1', 't-2'])) @@ -607,7 +607,7 @@ describe('SnapshotStore', () => { const state = store.getHasMany('Article', 'a-1', 'tags') expect(state?.plannedRemovals.size).toBe(0) expect(state?.plannedAdditions.size).toBe(0) - expect(state?.orderedIds).toBeNull() + expect(store.getHasManyView('Article', 'a-1', 'tags').orderedIds).toBeNull() }) }) From 11ff4feafe65dc2995939091063eda27f1d034a7 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:01:36 +0200 Subject: [PATCH 03/14] test(bindx): cover undo of has-many gestures made through an args-view Undo over a relation read under a generated alias had no coverage at all. The move case pins the per-view splice in applyRelationImage: restoring the live views wholesale instead of the recorded ordering makes undo of a move silently do nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- tests/undo-journal.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index c991657..073f356 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -9,6 +9,7 @@ import { disconnectRelation, moveInList, removeFromList, + connectToList, } from '@contember/bindx' /** @@ -267,6 +268,57 @@ describe('undo journal — deep coverage', () => { }) }) + // ============================================================ + // Gestures made through an args-view of a relation (issue #121) + // ============================================================ + describe('has-many undo through an args-view', () => { + // A has-many selected with args is read under a generated alias. The journal + // records the relation, so a gesture made in one view undoes as one gesture — + // and a manual order belonging to another view survives it. + const ORDERED = 'items_ordered' + const FILTERED = 'items_filtered' + + beforeEach(() => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2'], ORDERED) + store.setHasManyServerIds('Article', 'p', 'items', ['s1'], FILTERED) + }) + + test('a removal made in one view is undone in every view', () => { + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 's1', 'disconnect')) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual([]) + + undo.undo() + + expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.size ?? 0).toBe(0) + }) + + test('a move in one view is undone without touching the other view', () => { + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1, ORDERED)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s2', 's1']) + + undo.undo() + + expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + }) + + test('an addition made in one view is undone out of that view', () => { + dispatcher.dispatch(connectToList('Article', 'p', 'items', 's3', 'Item', FILTERED)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1', 's3']) + // The other view is filtered, so the client cannot claim membership there. + expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) + + undo.undo() + + expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + expect(store.getHasManyPlannedConnections('Article', 'p', 'items')?.size ?? 0).toBe(0) + }) + }) + // ============================================================ // Multi-cell atomic gesture // ============================================================ From 6f49cf6eb87eb57c9dba5bafdbd59c0c117c8dfd Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:45:25 +0200 Subject: [PATCH 04/14] fix(bindx): keep a server-confirmed has-many member in the relation baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline of a relation is the UNION across its views, so a row that joins no view is a server row the store has lost. Two paths could produce one once the state was keyed by the field name with views inside it: - a confirmed addition whose local record was cancelled while the request was in flight folded only into views that cannot exclude it, and a relation read only through filtered views has none. The compensating removal masked it until the user reset the relation, at which point the row vanished from the client while it existed on the server; - the disconnect rebase re-planned the connection with origins spanning EVERY view, which is the "guess membership in a filtered view" the module's own asymmetry rule forbids. Both now go through renderTargets(): render where the row belongs, else in the views that cannot exclude it, else park it in the unparameterized view — which is total by definition, so parking shows it to nobody it does not belong to. The rebase reads which views listed the row as the baseline drops it, instead of assuming all of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- packages/bindx/src/store/HasManyStore.ts | 2 +- packages/bindx/src/store/hasManyState.ts | 49 +++++++++++++++--- ...stedRelationBaselineReconciliation.test.ts | 51 ++++++++++++++++++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 81dbcad..2ca1dc7 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -299,7 +299,7 @@ export class HasManyStore { const existing = this.hasManyStates.get(key) if (!existing) return 'conflict' - const next = reconcileHasManyState(existing, delta) + const next = reconcileHasManyState(existing, delta, fieldFromRelationKey(key)) this.writeHasMany(key, next.state) return next.result } diff --git a/packages/bindx/src/store/hasManyState.ts b/packages/bindx/src/store/hasManyState.ts index bc151b5..b279ebe 100644 --- a/packages/bindx/src/store/hasManyState.ts +++ b/packages/bindx/src/store/hasManyState.ts @@ -197,6 +197,37 @@ export function additionRendersIn( return views.get(alias)?.membership === 'total' } +/** The aliases of views whose args cannot exclude any member of the relation. */ +function totalAliases(views: ReadonlyMap): string[] { + const aliases: string[] = [] + for (const [alias, view] of views) { + if (view.membership === 'total') aliases.push(alias) + } + return aliases +} + +/** + * The aliases a confirmed member should render in, never empty. + * + * {@link preferred} is what the local record says; when nothing is left of it the row + * falls back to the views that cannot exclude it. If there are none — every mounted + * view is filtered — it is parked in the unparameterized view, created here if absent: + * the relation's baseline is the UNION across views, so a row that joins no view is a + * server row the store has lost. That view is `total` by definition, so parking shows + * it to nobody it does not belong to. + */ +function renderTargets( + views: Map, + fieldName: string, + preferred: string[], +): string[] { + if (preferred.length > 0) return preferred + const totals = totalAliases(views) + if (totals.length > 0) return totals + if (!views.has(fieldName)) views.set(fieldName, createHasManyView('total')) + return [fieldName] +} + /** The aliases of existing views a confirmed addition folds into. */ export function additionFoldTargets( state: StoredHasManyState, @@ -332,6 +363,7 @@ export interface HasManyReconciliation { export function reconcileHasManyState( existing: StoredHasManyState, delta: SentHasManyDelta, + fieldName: string, ): HasManyReconciliation { const currentLive = liveHasManyChildIds(existing) const views = cloneViews(existing.views) @@ -343,9 +375,7 @@ export function reconcileHasManyState( // Read the origins before dropping the record. A locally cancelled addition // leaves none, and the row then joins only the views that cannot exclude it. const planned = plannedAdditions.get(addition.itemId) - const targets = planned - ? additionFoldTargets(existing, planned) - : [...views].filter(([, view]) => view.membership === 'total').map(([alias]) => alias) + const targets = renderTargets(views, fieldName, planned ? additionFoldTargets(existing, planned) : []) for (const alias of targets) views.get(alias)?.serverIds.add(addition.itemId) plannedAdditions.delete(addition.itemId) @@ -359,7 +389,12 @@ export function reconcileHasManyState( } for (const removal of delta.removals) { - for (const view of views.values()) view.serverIds.delete(removal.itemId) + // Which views listed the row, read as the baseline drops it: a rebase below must + // not claim membership in a filtered view that never showed it. + const shownIn: string[] = [] + for (const [alias, view] of views) { + if (view.serverIds.delete(removal.itemId)) shownIn.push(alias) + } const currentRemoval = plannedRemovals.get(removal.itemId) if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId) @@ -371,11 +406,11 @@ export function reconcileHasManyState( if (removal.type === 'delete') { result = 'conflict' } else if (!plannedAdditions.has(removal.itemId)) { - // Re-plan the connection in every view that showed the row, so the rebase is - // visible where the user is looking. + // Re-plan the connection where the row actually was, so the rebase is visible + // where the user is looking without inventing membership elsewhere. plannedAdditions.set(removal.itemId, { kind: 'connected', - origins: new Set(existing.views.keys()), + origins: new Set(renderTargets(views, fieldName, shownIn)), }) } } diff --git a/tests/unit/store/persistedRelationBaselineReconciliation.test.ts b/tests/unit/store/persistedRelationBaselineReconciliation.test.ts index c5f5b37..f6ac3ab 100644 --- a/tests/unit/store/persistedRelationBaselineReconciliation.test.ts +++ b/tests/unit/store/persistedRelationBaselineReconciliation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { SnapshotStore } from '@contember/bindx' +import { SnapshotStore, generateHasManyAlias } from '@contember/bindx' const parentType = 'Article' const parentId = 'article-1' @@ -253,3 +253,52 @@ describe('persisted relation baseline reconciliation', () => { expect(hasManyNotifications).toBe(1) }) }) + +describe('reconciling a relation read only through args-views', () => { + const active = generateHasManyAlias('tags', { filter: { active: true } }) + const inactive = generateHasManyAlias('tags', { filter: { active: false } }) + + test('a confirmed addition no view can claim still joins the baseline', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['A'], active) + store.planHasManyConnection(parentType, parentId, 'tags', 'B', active) + // The user cancels while the request is in flight, so nothing is left to say + // which view B belongs to — and every mounted view is filtered. + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'connected' }], + removals: [], + }) + // Dropping the compensating removal must not lose the row: the server has it. + store.resetHasMany(parentType, parentId, 'tags') + + expect(result).toBe('applied') + expect(store.getHasMany(parentType, parentId, 'tags')?.serverIds).toEqual(new Set(['A', 'B'])) + // Parked in the unparameterized view, so no filtered view starts showing it. + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags', active)).toEqual(['A']) + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags')).toEqual(['B']) + }) + + test('a rebased disconnect reconnects only where the row was listed', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['B'], active) + store.getOrCreateHasMany(parentType, parentId, 'tags', [], inactive) + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + // Reverting the removal while the disconnect is in flight leaves B live through + // the server baseline of the view that listed it, with no planned addition. + store.resetHasMany(parentType, parentId, 'tags') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [], + removals: [{ itemId: 'B', type: 'disconnect' }], + }) + + expect(result).toBe('applied') + expect(store.getHasMany(parentType, parentId, 'tags')?.plannedAdditions).toEqual(new Map([['B', 'connected']])) + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags', active)).toEqual(['B']) + // The other view never showed B and cannot evaluate its filter, so it must not + // start claiming membership now. + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags', inactive)).toEqual([]) + }) +}) From 8679f9aed42b3df7979779d3f152ddf0e07aa28b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:50:08 +0200 Subject: [PATCH 05/14] fix(bindx): classify a has-many view by its real params, not by its alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view was 'total' — its args cannot exclude a member — only when its alias equaled the field name. But an alias is minted for `orderBy` alone, and ordering leaves nobody out, so two sorted views of one relation never saw each other's additions: the row added in one silently missed the other until a refetch. The module's own doc already defined 'total' as "no filter/limit/offset"; the alias string cannot answer that, because it is a hash of the params. The selection can. EntityHandle declares the membership from `hasManyParams` before it hands out a handle — strictly before any path can materialize the view — and the store keeps it in a small "fieldName:alias" registry, one entry per distinct selection. An undeclared alias stays 'partial': the safe direction, since the client then never claims membership it cannot prove. Declaring beats threading a parameter: it also covers views materialized lazily by a mutator, and explicit `as` aliases, which carry no hash at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- packages/bindx-client/src/index.ts | 2 +- .../bindx-client/src/utils/aliasGenerator.ts | 13 ++++ packages/bindx/src/handles/EntityHandle.ts | 10 +++ packages/bindx/src/store/HasManyStore.ts | 23 +++++- packages/bindx/src/store/RelationStore.ts | 6 ++ packages/bindx/src/store/SnapshotStore.ts | 11 +++ packages/bindx/src/utils/aliasGenerator.ts | 2 +- .../hasMany/orderByViewMembership.test.tsx | 70 +++++++++++++++++++ tests/unit/handles/hasManyAlias.test.ts | 23 ++++++ 9 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 tests/react/relations/hasMany/orderByViewMembership.test.tsx diff --git a/packages/bindx-client/src/index.ts b/packages/bindx-client/src/index.ts index 707ad74..470b44e 100644 --- a/packages/bindx-client/src/index.ts +++ b/packages/bindx-client/src/index.ts @@ -96,7 +96,7 @@ export { ComponentBrand } from './brand/ComponentBrand.js' export type { AnyBrand } from './brand/ComponentBrand.js' // Utils -export { generateHasManyAlias } from './utils/aliasGenerator.js' +export { canHasManyParamsExcludeMembers, generateHasManyAlias } from './utils/aliasGenerator.js' export type { IsPlainObject } from './utils/fieldShape.js' // Query Builder (static qb module) diff --git a/packages/bindx-client/src/utils/aliasGenerator.ts b/packages/bindx-client/src/utils/aliasGenerator.ts index 7bf3196..13394af 100644 --- a/packages/bindx-client/src/utils/aliasGenerator.ts +++ b/packages/bindx-client/src/utils/aliasGenerator.ts @@ -54,3 +54,16 @@ export function generateHasManyAlias(fieldName: string, params?: HasManyParams): return `${fieldName}_${hashStr}` } + +/** + * Whether a has-many selected with {@link params} can be missing members of the + * relation — i.e. whether the server may have left rows out of this read. + * + * `orderBy` and `totalCount` only shape how the rows come back, so a view that + * carries nothing else still contains every member even though it is read under a + * generated alias. + */ +export function canHasManyParamsExcludeMembers(params?: HasManyParams): boolean { + if (!params) return false + return params.filter !== undefined || params.limit !== undefined || params.offset !== undefined +} diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 2fdd86c..92b6b62 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -13,6 +13,7 @@ import { } from '../core/actions.js' import { FIELD_REF_META, type FieldRefMeta, type FieldAccessor, type HasOneAccessor, type HasManyAccessor, type EntityFieldsAccessor, type Unsubscribe, type EntityAccessor } from './types.js' import { deepEqual } from '../utils/deepEqual.js' +import { canHasManyParamsExcludeMembers } from '../utils/aliasGenerator.js' import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js' import type { EventTypeMap, @@ -511,6 +512,15 @@ export class EntityHandle extends Enti // Has-many relation - return HasManyListHandle. // Thread the selected alias so the handle reads data stored under the // auto-generated alias (e.g. `tags_`) for params-bearing relations. + // The alias is a hash of the params, so the store cannot tell a filtered + // view from one that only orders — say so here, where the params are. + if (fieldMeta && fieldMeta.alias !== schemaFieldName) { + this.store.declareHasManyViewMembership( + schemaFieldName, + fieldMeta.alias, + canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total', + ) + } return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 2ca1dc7..306d16e 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -118,12 +118,31 @@ export class HasManyStore { return this.plannedDeletes.has(childId) } + /** + * Membership declared for an args-view, keyed by "fieldName:alias" — one entry per + * distinct selection in the application, so it does not grow with the data. The + * alias alone would not do: two fields may carry the same explicit `as`. + * + * Only the caller that selected the relation knows its params, and the alias is a + * hash it cannot reconstruct them from. {@link EntityHandle} declares them before + * it hands out a handle, which is strictly before any path can materialize the view. + */ + private readonly declaredMembership = new Map() + + declareViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { + this.declaredMembership.set(`${fieldName}:${alias}`, membership) + } + /** * A view addressed by the field name itself is the unparameterized selection, so - * its args cannot exclude any member of the relation. + * its args cannot exclude any member of the relation. Anything else needs a + * declaration; without one the view is assumed filtered, which is the safe + * direction — the client then never claims membership it cannot prove. */ private viewMembership(key: string, alias: string): HasManyViewMembership { - return alias === fieldFromRelationKey(key) ? 'total' : 'partial' + const fieldName = fieldFromRelationKey(key) + if (alias === fieldName) return 'total' + return this.declaredMembership.get(`${fieldName}:${alias}`) ?? 'partial' } /** diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index d09c476..9ea7168 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -10,6 +10,7 @@ import { type HasManyAdditionKind, type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewMembership, type HasManyViewProjection, type SentHasManyDelta, type StoredHasManyState, @@ -23,6 +24,7 @@ export type { HasManyRelationProjection, HasManyRemovalType, HasManyView, + HasManyViewMembership, HasManyViewProjection, PlannedHasManyAddition, SentHasManyAddition, @@ -107,6 +109,10 @@ export class RelationStore implements Rekeyable { // ==================== Has-Many Relations ==================== + declareHasManyViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { + this.hasMany.declareViewMembership(fieldName, alias, membership) + } + getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void { this.hasMany.getOrCreateHasMany(key, alias, serverIds) } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 8bd473f..6d33528 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -7,6 +7,7 @@ import { RelationStore, type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewMembership, type HasManyViewProjection, type RelationReconciliationResult, type SentHasManyDelta, @@ -38,6 +39,7 @@ export type { HasManyRelationProjection, HasManyRemovalType, HasManyView, + HasManyViewMembership, HasManyViewProjection, PlannedHasManyAddition, RelationReconciliationResult, @@ -537,6 +539,15 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // ==================== Has-Many State (delegated to RelationStore) ==================== + /** + * Declares what a view read under {@link alias} can leave out, so the store can tell + * a filtered view from one that merely orders or renames. Only the caller that + * selected the relation knows the params; the alias is a hash of them. + */ + declareHasManyViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { + this.relations.declareHasManyViewMembership(fieldName, alias, membership) + } + /** * Materializes the relation and one args-view of it, refreshing that view's * server baseline when one is supplied. diff --git a/packages/bindx/src/utils/aliasGenerator.ts b/packages/bindx/src/utils/aliasGenerator.ts index 48dad97..c8c802e 100644 --- a/packages/bindx/src/utils/aliasGenerator.ts +++ b/packages/bindx/src/utils/aliasGenerator.ts @@ -1 +1 @@ -export { generateHasManyAlias } from '@contember/bindx-client' +export { canHasManyParamsExcludeMembers, generateHasManyAlias } from '@contember/bindx-client' diff --git a/tests/react/relations/hasMany/orderByViewMembership.test.tsx b/tests/react/relations/hasMany/orderByViewMembership.test.tsx new file mode 100644 index 0000000..9a01db5 --- /dev/null +++ b/tests/react/relations/hasMany/orderByViewMembership.test.tsx @@ -0,0 +1,70 @@ +import '../../../setup' +// A has-many selected with args is read under a generated alias, and the store +// classifies that VIEW by whether its args can leave members out. The alias is a +// hash, so only the selection knows — this pins that the selection tells the store. +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, useBindxContext, useEntity } from '@contember/bindx-react' +import type { SnapshotStore } from '@contember/bindx' +import { generateHasManyAlias } from '@contember/bindx' +import { createMockData, entityDefs, queryByTestId, schema } from './setup' + +afterEach(() => { + cleanup() +}) + +const orderByParams = { orderBy: [{ name: 'asc' as const }] } +const filterParams = { filter: { name: 'React' } } + +type Props = { params: object; publish: (store: SnapshotStore) => void } + +function Article({ params, publish }: Props) { + const { store } = useBindxContext() + const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, + e => e.id().title().tags(params, t => t.id().name())) + publish(store) + + if (article.$status !== 'ready') return
Loading
+ return {article.tags.length} +} + +async function mount(params: object): Promise { + let store: SnapshotStore | null = null + const { container } = render( + +
{ store = next }} /> + , + ) + await waitFor(() => expect(queryByTestId(container, 'tag-count')).not.toBeNull()) + return store! +} + +describe('a has-many view is classified by its args, not by its alias', () => { + test('an ordering-only view shows a member added elsewhere', async () => { + const store = await mount(orderByParams) + const alias = generateHasManyAlias('tags', orderByParams) + + // A connection made through another view of the same relation. `orderBy` cannot + // exclude anything, so this view contains every member and must show it. + act(() => { + store.planHasManyConnection('Article', 'article-1', 'tags', 'tag-9') + }) + + expect(store.getHasManyOrderedIds('Article', 'article-1', 'tags', alias)) + .toEqual(['tag-1', 'tag-2', 'tag-9']) + }) + + test('a filtered view does not', async () => { + const store = await mount(filterParams) + const alias = generateHasManyAlias('tags', filterParams) + const before = store.getHasManyOrderedIds('Article', 'article-1', 'tags', alias) + + act(() => { + store.planHasManyConnection('Article', 'article-1', 'tags', 'tag-9') + }) + + // The client cannot evaluate the filter, so it must not claim membership. + expect(store.getHasManyOrderedIds('Article', 'article-1', 'tags', alias)).toEqual(before) + }) +}) diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index 990e93c..f02d8a9 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -208,6 +208,29 @@ describe('HasMany with Alias Support', () => { expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 't-3']) }) + test('a view that only orders is not treated as filtered', () => { + const orderedAlias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) + // What the selection knows and the alias hash does not: ordering leaves nobody out. + store.declareHasManyViewMembership('tags', orderedAlias, 'total') + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], orderedAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-1', 't-3']) + }) + + test('an undeclared view is assumed filtered', () => { + const unknownAlias = 'tags_handwritten' + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], unknownAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) + + // The safe direction: never claim membership the client cannot prove. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', unknownAlias)).toEqual(['t-1']) + }) + test('a removal hides the item in every view', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], activeAlias) store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], inactiveAlias) From 6497f7c614ba16bf807f7ef657007d3bbdafedbc Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:52:50 +0200 Subject: [PATCH 06/14] refactor(bindx): drop the has-many surface nothing reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HasManyViewProjection carried `exists`, `plannedRemovals` and `plannedAdditions`, none of which has a reader: a view is asked for its server rows and its manual order, and the pending writes belong to the relation. Building them meant a Map copy plus a pass over every addition on each call. - SnapshotStore.connectExistingToHasMany took an `alias` its only caller never passed — an untested extension point for a path that always addresses the field. - RelationStore.getHasMany returned the raw state while SnapshotStore.getHasMany returns a projection with no views at all. Renaming the raw one getHasManyState keeps an edit inside SnapshotStore from reaching for the wrong layer and silently losing the per-view data. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- packages/bindx/src/store/RelationStore.ts | 7 ++++- packages/bindx/src/store/SnapshotStore.ts | 9 +++--- packages/bindx/src/store/hasManyState.ts | 30 ++++---------------- tests/unit/store/hasManyAdditionKind.test.ts | 12 ++++---- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 9ea7168..9b41548 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -117,7 +117,12 @@ export class RelationStore implements Rekeyable { this.hasMany.getOrCreateHasMany(key, alias, serverIds) } - getHasMany(key: string): StoredHasManyState | undefined { + /** + * The raw stored state, views and all. Named apart from the projections above it: + * {@link SnapshotStore.getHasMany} hands out a relation projection with no views, + * and the undo / export paths that need the real thing must not get that by mistake. + */ + getHasManyState(key: string): StoredHasManyState | undefined { return this.hasMany.getHasMany(key) } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 6d33528..c0f8fe3 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -697,11 +697,10 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { parentId: string, fieldName: string, itemId: string, - alias?: string, ): void { const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.connectExistingToHasMany(key, alias ?? fieldName, itemId) + this.relations.connectExistingToHasMany(key, fieldName, itemId) this.notifyRelationSubscribers(key) } @@ -1180,7 +1179,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * permanent member of the list (membership rebase for sealed creates). */ getLiveHasManyServerIds(relationKey: string): ReadonlyMap> { - const state = this.relations.getHasMany(relationKey) + const state = this.relations.getHasManyState(relationKey) const byView = new Map>() if (state) { for (const [alias, view] of state.views) byView.set(alias, view.serverIds) @@ -1189,7 +1188,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { } exportHasManyCell(key: string): HasManyCellImage { - const state = this.relations.getHasMany(key) + const state = this.relations.getHasManyState(key) if (!state) return { kind: 'hasMany', key, present: false } return { kind: 'hasMany', @@ -1337,7 +1336,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { }]])) } else { const s = img.state! - const live = this.relations.getHasMany(img.key) + const live = this.relations.getHasManyState(img.key) // The editable layer (planned writes, manual ordering) comes from the image; // the server baseline always from the live state, which may have advanced // since the gesture. A view the image does not know was created afterwards, diff --git a/packages/bindx/src/store/hasManyState.ts b/packages/bindx/src/store/hasManyState.ts index b279ebe..15bea31 100644 --- a/packages/bindx/src/store/hasManyState.ts +++ b/packages/bindx/src/store/hasManyState.ts @@ -125,14 +125,14 @@ export interface HasManyRelationProjection { version: number } -/** What one mounted has-many handle sees. */ +/** + * What is this view's own, as opposed to the relation's: the server rows its args + * returned and the order its user arranged. The pending writes are not here — they + * belong to the relation, so consumers read {@link HasManyRelationProjection}. + */ export interface HasManyViewProjection { - exists: boolean serverIds: Set orderedIds: string[] | null - plannedRemovals: Map - /** The additions that render in THIS view. */ - plannedAdditions: Map } export function createHasManyView( @@ -293,28 +293,10 @@ export function toViewProjection( state: StoredHasManyState | undefined, alias: string, ): HasManyViewProjection { - if (!state) { - return { - exists: false, - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - } - } - - const view = state.views.get(alias) - const plannedAdditions = new Map() - for (const [id, addition] of state.plannedAdditions) { - if (additionRendersIn(state.views, alias, addition)) plannedAdditions.set(id, addition.kind) - } - + const view = state?.views.get(alias) return { - exists: view !== undefined, serverIds: new Set(view?.serverIds), orderedIds: view?.orderedIds ? [...view.orderedIds] : null, - plannedRemovals: new Map(state.plannedRemovals), - plannedAdditions, } } diff --git a/tests/unit/store/hasManyAdditionKind.test.ts b/tests/unit/store/hasManyAdditionKind.test.ts index 914f6c9..6488745 100644 --- a/tests/unit/store/hasManyAdditionKind.test.ts +++ b/tests/unit/store/hasManyAdditionKind.test.ts @@ -21,27 +21,27 @@ describe('HasMany planned-addition kind', () => { test('planHasManyConnection does not downgrade a created addition', () => { const relations = new RelationStore() relations.addToHasMany(KEY, 'tags', 'temp-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') // connect() after add() on the SAME id must keep it a create. relations.planHasManyConnection(KEY, 'tags', 'temp-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') }) test('connectExistingToHasMany does not downgrade a created addition', () => { const relations = new RelationStore() relations.addToHasMany(KEY, 'tags', 'temp-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') // The embedded-connect materialization path must not downgrade either. relations.connectExistingToHasMany(KEY, 'tags', 'temp-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('temp-1')?.kind).toBe('created') }) test('a genuine connect of a never-added id is recorded as connected', () => { const relations = new RelationStore() relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')?.kind).toBe('connected') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('persisted-1')?.kind).toBe('connected') }) }) @@ -53,7 +53,7 @@ describe('HasMany planned-addition kind', () => { relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') expect(relations.getHasManyOrderedIds(KEY, 'tags')).toEqual(['persisted-1']) - expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')?.kind).toBe('connected') + expect(relations.getHasManyState(KEY)?.plannedAdditions.get('persisted-1')?.kind).toBe('connected') }) test('connecting an id already present as a server member does not duplicate it', () => { From 94623e9c566045f11e0374da4b7661460fcd4b25 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:57:01 +0200 Subject: [PATCH 07/14] perf(bindx): answer the has-many read-only questions without building projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDirty ran per render for every mounted list and materialized two projections to read three sizes and one null check — a relation projection unions every view's server ids and copies two maps, a view projection copied a set. It now asks two predicates that touch the stored state directly. getLiveHasManyServerIds deep-cloned the whole relation, orderedIds and every addition's origins included, to hand back per-view server ids the caller only probes with has(). UndoManager.rekeyStacks calls it for every has-many cell of the undo stack, the redo stack and the pending entry on each temp -> persisted mapping. It now reads the sets by reference: a stored set is never mutated in place, only replaced, so a reference can go stale but cannot be written through. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- .../bindx/src/handles/HasManyListHandle.ts | 15 ++-------- packages/bindx/src/store/HasManyStore.ts | 29 +++++++++++++++++++ packages/bindx/src/store/RelationStore.ts | 12 ++++++++ packages/bindx/src/store/SnapshotStore.ts | 25 +++++++++++----- packages/bindx/src/undo/UndoManager.ts | 2 +- packages/bindx/src/undo/rekeyJournalEntry.ts | 2 +- 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 34eada1..a74a4db 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -415,20 +415,9 @@ export class HasManyListHandle 0 || - relation.plannedAdditions.size > 0 || - view.orderedIds !== null + this.store.hasPendingHasManyWrites(this.entityType, this.entityId, this.fieldName) + || this.store.hasExplicitHasManyOrder(this.entityType, this.entityId, this.fieldName, this.alias) ) } diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 306d16e..6f0a1b1 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -203,6 +203,35 @@ export class HasManyStore { return toViewProjection(this.hasManyStates.get(key), alias) } + /** Whether the relation carries writes the next persist would send. */ + hasPendingWrites(key: string): boolean { + const state = this.hasManyStates.get(key) + if (!state) return false + return state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0 + } + + /** Whether one view carries an order its user arranged rather than the default one. */ + hasExplicitOrder(key: string, alias: string): boolean { + const orderedIds = this.hasManyStates.get(key)?.views.get(alias)?.orderedIds + return orderedIds !== null && orderedIds !== undefined + } + + /** + * Per-view server baselines by reference, for callers that only read them. + * + * Not cloned, deliberately: every mutator builds on {@link editableState}, so a + * stored Set is never mutated in place — only replaced. A handed-out reference can + * therefore go stale but can never be written through. + */ + collectViewServerIds(key: string): ReadonlyMap> { + const byView = new Map>() + const state = this.hasManyStates.get(key) + if (state) { + for (const [alias, view] of state.views) byView.set(alias, view.serverIds) + } + return byView + } + /** Replaces one view's server baseline. */ setHasManyServerIds(key: string, alias: string, serverIds: string[]): void { const state = this.editableState(key, alias) diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 9b41548..caae2d3 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -134,6 +134,18 @@ export class RelationStore implements Rekeyable { return this.hasMany.getViewProjection(key, alias) } + hasPendingHasManyWrites(key: string): boolean { + return this.hasMany.hasPendingWrites(key) + } + + hasExplicitHasManyOrder(key: string, alias: string): boolean { + return this.hasMany.hasExplicitOrder(key, alias) + } + + collectHasManyViewServerIds(key: string): ReadonlyMap> { + return this.hasMany.collectViewServerIds(key) + } + setHasManyServerIds(key: string, alias: string, serverIds: string[]): void { this.hasMany.setHasManyServerIds(key, alias, serverIds) } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index c0f8fe3..1dd5218 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -577,6 +577,22 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { return this.relations.getHasManyRelation(key) } + /** Whether the relation carries writes the next persist would send. */ + hasPendingHasManyWrites(parentType: string, parentId: string, fieldName: string): boolean { + return this.relations.hasPendingHasManyWrites(this.getRelationKey(parentType, parentId, fieldName)) + } + + /** Whether one view carries an order its user arranged rather than the default one. */ + hasExplicitHasManyOrder( + parentType: string, + parentId: string, + fieldName: string, + alias?: string, + ): boolean { + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.hasExplicitHasManyOrder(key, alias ?? fieldName) + } + /** The relation as ONE mounted has-many handle sees it. */ getHasManyView( parentType: string, @@ -1178,13 +1194,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * 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): ReadonlyMap> { - const state = this.relations.getHasManyState(relationKey) - const byView = new Map>() - if (state) { - for (const [alias, view] of state.views) byView.set(alias, view.serverIds) - } - return byView + getLiveHasManyServerIds(relationKey: string): ReadonlyMap> { + return this.relations.collectHasManyViewServerIds(relationKey) } exportHasManyCell(key: string): HasManyCellImage { diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index 1807bed..45807f0 100644 --- a/packages/bindx/src/undo/UndoManager.ts +++ b/packages/bindx/src/undo/UndoManager.ts @@ -214,7 +214,7 @@ export class UndoManager { * so stored cells keep valid keys / id references (and sealed creates drop out). */ private rekeyStacks(ctx: RekeyContext): void { - const liveServerIds = (key: string): ReadonlyMap> => this.store.getLiveHasManyServerIds(key) + const liveServerIds = (key: string): ReadonlyMap> => this.store.getLiveHasManyServerIds(key) const undoCount = this.undoStack.length const redoCount = this.redoStack.length const hadPending = this.pending !== null && this.pending.size > 0 diff --git a/packages/bindx/src/undo/rekeyJournalEntry.ts b/packages/bindx/src/undo/rekeyJournalEntry.ts index 4cf83cb..8c60d53 100644 --- a/packages/bindx/src/undo/rekeyJournalEntry.ts +++ b/packages/bindx/src/undo/rekeyJournalEntry.ts @@ -19,7 +19,7 @@ import type { JournalEntry, JournalCellImage } from './UndoJournal.js' * args-view. 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) => ReadonlyMap> +export type LiveServerIdsLookup = (relationKey: string) => ReadonlyMap> export function rekeyJournalEntry( entry: JournalEntry, From e62354189e6c34df917d1aa459dc94c059a3f7ba Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 22 Sep 2026 16:57:31 +0200 Subject: [PATCH 08/14] test(bindx): name the args-view undo fixtures after what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aliases were hand-written as `items_ordered` / `items_filtered` while the assertions reason about two filtered views — one comment even called the "ordered" one filtered. Since a view that only orders is no longer treated as filtered, the names now say the opposite of what the test pins. Both fixtures come from generateHasManyAlias with a real filter instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm --- tests/undo-journal.test.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index 073f356..62e7d87 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -10,6 +10,7 @@ import { moveInList, removeFromList, connectToList, + generateHasManyAlias, } from '@contember/bindx' /** @@ -275,46 +276,46 @@ describe('undo journal — deep coverage', () => { // A has-many selected with args is read under a generated alias. The journal // records the relation, so a gesture made in one view undoes as one gesture — // and a manual order belonging to another view survives it. - const ORDERED = 'items_ordered' - const FILTERED = 'items_filtered' + const WIDE = generateHasManyAlias('items', { filter: { archived: false } }) + const NARROW = generateHasManyAlias('items', { filter: { starred: true } }) beforeEach(() => { store.setEntityData('Article', 'p', { id: 'p' }, true) - store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2'], ORDERED) - store.setHasManyServerIds('Article', 'p', 'items', ['s1'], FILTERED) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2'], WIDE) + store.setHasManyServerIds('Article', 'p', 'items', ['s1'], NARROW) }) test('a removal made in one view is undone in every view', () => { dispatcher.dispatch(removeFromList('Article', 'p', 'items', 's1', 'disconnect')) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s2']) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual([]) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual([]) undo.undo() - expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s1', 's2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual(['s1']) expect(store.getHasManyPlannedRemovals('Article', 'p', 'items')?.size ?? 0).toBe(0) }) test('a move in one view is undone without touching the other view', () => { - dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1, ORDERED)) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s2', 's1']) + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1, WIDE)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s2', 's1']) undo.undo() - expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s1', 's2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual(['s1']) }) test('an addition made in one view is undone out of that view', () => { - dispatcher.dispatch(connectToList('Article', 'p', 'items', 's3', 'Item', FILTERED)) - expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1', 's3']) + dispatcher.dispatch(connectToList('Article', 'p', 'items', 's3', 'Item', NARROW)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual(['s1', 's3']) // The other view is filtered, so the client cannot claim membership there. - expect(store.getHasManyOrderedIds('Article', 'p', 'items', ORDERED)).toEqual(['s1', 's2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s1', 's2']) undo.undo() - expect(store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED)).toEqual(['s1']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual(['s1']) expect(store.getHasManyPlannedConnections('Article', 'p', 'items')?.size ?? 0).toBe(0) }) }) From 82ed41bcf5dd6d2f8bffeba10aedee682c159fa1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:50:05 +0200 Subject: [PATCH 09/14] fix(bindx): scope a has-many view declaration to the entity that selected it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The membership declared for an args-view was keyed by "fieldName:alias". Two entities with a has-many of the same name can select different args under the same explicit alias, so a declaration made for one of them classified the other's view too — and an ordering-only declaration would then show additions in a view whose args filter them out. The key now starts with the entity type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx/src/handles/EntityHandle.ts | 1 + packages/bindx/src/store/HasManyStore.ts | 20 +++++++++++++------- packages/bindx/src/store/RelationStore.ts | 9 +++++++-- packages/bindx/src/store/SnapshotStore.ts | 9 +++++++-- packages/bindx/src/store/relationKey.ts | 8 ++++++++ tests/unit/handles/hasManyAlias.test.ts | 14 +++++++++++++- 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 92b6b62..a9b1289 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -516,6 +516,7 @@ export class EntityHandle extends Enti // view from one that only orders — say so here, where the params are. if (fieldMeta && fieldMeta.alias !== schemaFieldName) { this.store.declareHasManyViewMembership( + this.entityType, schemaFieldName, fieldMeta.alias, canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total', diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 6f0a1b1..d45a91f 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -1,4 +1,4 @@ -import { fieldFromRelationKey, parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' +import { entityTypeFromRelationKey, fieldFromRelationKey, parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' import { PlannedDeleteIndex } from './PlannedDeleteIndex.js' import { RelationEdgeIndex } from './RelationEdgeIndex.js' import { RelationOwnerIndex } from './RelationOwnerIndex.js' @@ -119,9 +119,10 @@ export class HasManyStore { } /** - * Membership declared for an args-view, keyed by "fieldName:alias" — one entry per - * distinct selection in the application, so it does not grow with the data. The - * alias alone would not do: two fields may carry the same explicit `as`. + * Membership declared for an args-view, keyed by "entityType:fieldName:alias" — one + * entry per distinct selection in the application, so it does not grow with the data. + * The alias alone would not do: two fields, or two entities' fields of the same name, + * may carry the same explicit `as`. * * Only the caller that selected the relation knows its params, and the alias is a * hash it cannot reconstruct them from. {@link EntityHandle} declares them before @@ -129,8 +130,13 @@ export class HasManyStore { */ private readonly declaredMembership = new Map() - declareViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { - this.declaredMembership.set(`${fieldName}:${alias}`, membership) + declareViewMembership( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.declaredMembership.set(`${entityType}:${fieldName}:${alias}`, membership) } /** @@ -142,7 +148,7 @@ export class HasManyStore { private viewMembership(key: string, alias: string): HasManyViewMembership { const fieldName = fieldFromRelationKey(key) if (alias === fieldName) return 'total' - return this.declaredMembership.get(`${fieldName}:${alias}`) ?? 'partial' + return this.declaredMembership.get(`${entityTypeFromRelationKey(key)}:${fieldName}:${alias}`) ?? 'partial' } /** diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index caae2d3..fe56d7d 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -109,8 +109,13 @@ export class RelationStore implements Rekeyable { // ==================== Has-Many Relations ==================== - declareHasManyViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { - this.hasMany.declareViewMembership(fieldName, alias, membership) + declareHasManyViewMembership( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.hasMany.declareViewMembership(entityType, fieldName, alias, membership) } getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void { diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 1dd5218..aa6ef59 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -544,8 +544,13 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * a filtered view from one that merely orders or renames. Only the caller that * selected the relation knows the params; the alias is a hash of them. */ - declareHasManyViewMembership(fieldName: string, alias: string, membership: HasManyViewMembership): void { - this.relations.declareHasManyViewMembership(fieldName, alias, membership) + declareHasManyViewMembership( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.relations.declareHasManyViewMembership(entityType, fieldName, alias, membership) } /** diff --git a/packages/bindx/src/store/relationKey.ts b/packages/bindx/src/store/relationKey.ts index 7b478b9..80e9fa2 100644 --- a/packages/bindx/src/store/relationKey.ts +++ b/packages/bindx/src/store/relationKey.ts @@ -9,6 +9,14 @@ export function parentKeyFromRelationKey(relationKey: string): string { return relationKey.slice(0, lastSeparator) } +/** + * Derives the parent entity type from a relation key ("parentType:parentId:fieldName"). + * Entity type names never contain ':', so the type is everything before the first separator. + */ +export function entityTypeFromRelationKey(relationKey: string): string { + return relationKey.slice(0, relationKey.indexOf(':')) +} + /** * Derives the field name from a relation key ("parentType:parentId:fieldName"). * The counterpart of {@link parentKeyFromRelationKey}: entity ids and field names diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index f02d8a9..e346494 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -211,7 +211,7 @@ describe('HasMany with Alias Support', () => { test('a view that only orders is not treated as filtered', () => { const orderedAlias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) // What the selection knows and the alias hash does not: ordering leaves nobody out. - store.declareHasManyViewMembership('tags', orderedAlias, 'total') + store.declareHasManyViewMembership('Article', 'tags', orderedAlias, 'total') store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], orderedAlias) store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) @@ -253,6 +253,18 @@ describe('HasMany with Alias Support', () => { expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual(['t-3', 't-2', 't-1']) }) + test('a declaration classifies the view of one entity only', () => { + const orderedAlias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) + store.declareHasManyViewMembership('Article', 'tags', orderedAlias, 'total') + store.getOrCreateHasMany('Page', 'p-1', 'tags', ['t-1'], orderedAlias) + store.getOrCreateHasMany('Page', 'p-1', 'tags', ['t-1']) + + store.planHasManyConnection('Page', 'p-1', 'tags', 't-3') + + // Another entity's field of the same name may select other args under the same alias. + expect(store.getHasManyOrderedIds('Page', 'p-1', 'tags', orderedAlias)).toEqual(['t-1']) + }) + test('reset clears the relation, not one view of it', () => { store.getOrCreateHasMany('Article', 'a-1', 'tags', [], activeAlias) store.getOrCreateHasMany('Article', 'a-1', 'tags', [], inactiveAlias) From 981dca509f3f041365d4f49534312c54b9c9274a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:50:24 +0200 Subject: [PATCH 10/14] fix(bindx): list a has-many addition in every manual order it renders in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual order replaces a view's default order, and the default order is the only place a pending addition otherwise appears. An addition reached the manual order of the view it was made in and nowhere else, so a sibling view that must show it — one whose args cannot exclude anything — hid it until the next refetch whenever that sibling carried a manual order. That covers a move in a sorted view followed by add() in another, two sorted views that each add(), a connect in a filtered view next to an unfiltered view that already add()ed, and a sibling order that survives the persist of the addition. Recording an addition now appends the item to the manual order of every view it renders in, the origin included, which also subsumes the per-origin append the connect paths did on their own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx/src/store/HasManyStore.ts | 37 ++++++++------ tests/unit/handles/hasManyAlias.test.ts | 65 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index d45a91f..63641a8 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -4,6 +4,7 @@ import { RelationEdgeIndex } from './RelationEdgeIndex.js' import { RelationOwnerIndex } from './RelationOwnerIndex.js' import { additionFoldTargets, + additionRendersIn, arraysEqual, cloneHasManyState, computeViewOrderedIds, @@ -20,6 +21,7 @@ import { type HasManyRemovalType, type HasManyViewMembership, type HasManyViewProjection, + type PlannedHasManyAddition, type ReconciliationResult, type SentHasManyDelta, type StoredHasManyState, @@ -266,7 +268,6 @@ export class HasManyStore { planHasManyConnection(key: string, alias: string, itemId: string): void { const state = this.editableState(key, alias) this.recordAddition(state, alias, itemId, 'connected') - this.appendToExplicitOrder(state, alias, itemId) this.commitEdit(key, state) this.editableWriteVersion++ } @@ -297,8 +298,6 @@ export class HasManyStore { this.recordAddition(state, alias, itemId, 'connected') if (fresh) { state.views.get(alias)!.orderedIds = [itemId] - } else { - this.appendToExplicitOrder(state, alias, itemId) } this.commitEdit(key, state) this.editableWriteVersion++ @@ -316,24 +315,30 @@ export class HasManyStore { kind: 'created' | 'connected', ): void { const existing = state.plannedAdditions.get(itemId) - if (existing) { - existing.origins.add(alias) - if (kind === 'created') existing.kind = 'created' - } else { - state.plannedAdditions.set(itemId, { kind, origins: new Set([alias]) }) - } + const addition = existing ?? { kind, origins: new Set() } + addition.origins.add(alias) + if (kind === 'created') addition.kind = 'created' + state.plannedAdditions.set(itemId, addition) state.plannedRemovals.delete(itemId) + this.appendToRenderingExplicitOrders(state, itemId, addition) } /** - * Only touches an EXPLICIT order — the default order already derives from - * plannedAdditions. Guards against re-appending an id that is already listed: - * the connect paths re-run whenever an embedded reference is re-materialized. + * An explicit order replaces the default one, which is where additions otherwise + * appear, so every view the addition renders in must list it — not only the view + * it was made in. Guards against re-appending an id that is already listed: the + * connect paths re-run whenever an embedded reference is re-materialized. */ - private appendToExplicitOrder(state: StoredHasManyState, alias: string, itemId: string): void { - const view = state.views.get(alias)! - if (view.orderedIds !== null && !view.orderedIds.includes(itemId)) { - view.orderedIds = [...view.orderedIds, itemId] + private appendToRenderingExplicitOrders( + state: StoredHasManyState, + itemId: string, + addition: PlannedHasManyAddition, + ): void { + for (const [alias, view] of state.views) { + if (view.orderedIds === null || view.orderedIds.includes(itemId)) continue + if (additionRendersIn(state.views, alias, addition)) { + view.orderedIds = [...view.orderedIds, itemId] + } } } diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index e346494..16a0e89 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -277,6 +277,71 @@ describe('HasMany with Alias Support', () => { }) }) + // ==================== Explicit order of a sibling view ==================== + + /** + * A manual order replaces a view's default order, which is where additions otherwise + * appear. An addition must therefore reach the manual order of every view it renders + * in, not only of the view it was made in. + */ + describe('Explicit order of a sibling view', () => { + const orderedAlias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) + const activeAlias = generateHasManyAlias('tags', { filter: { active: true } }) + + beforeEach(() => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) + store.declareHasManyViewMembership('Article', 'tags', orderedAlias, 'total') + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2']) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], orderedAlias) + }) + + test('an add in one view shows in a sibling view the user reordered', () => { + store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, orderedAlias) + + store.addToHasMany('Article', 'a-1', 'tags', 'n-1') + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'n-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-2', 't-1', 'n-1']) + }) + + test('two views that only order each show the other one\'s add', () => { + // add() gives its own view an explicit order, so the second add meets one. + store.addToHasMany('Article', 'a-1', 'tags', 'n-1', orderedAlias) + store.addToHasMany('Article', 'a-1', 'tags', 'n-2') + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'n-1', 'n-2']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-1', 't-2', 'n-1', 'n-2']) + }) + + test('a connection made in a filtered view shows in a total view with a manual order', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) + store.addToHasMany('Article', 'a-1', 'tags', 'n-1') + + store.planHasManyConnection('Article', 'a-1', 'tags', 't-9', activeAlias) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'n-1', 't-9']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-1', 't-9']) + }) + + test('a confirmed add stays in a sibling view the user reordered', () => { + store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, orderedAlias) + store.addToHasMany('Article', 'a-1', 'tags', 'n-1') + + store.reconcileSentHasMany('Article', 'a-1', 'tags', { additions: [{ itemId: 'n-1', kind: 'created' }], removals: [] }) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-2', 't-1', 'n-1']) + }) + + test('a filtered sibling with a manual order does not gain the addition', () => { + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], activeAlias) + store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, activeAlias) + + store.addToHasMany('Article', 'a-1', 'tags', 'n-1') + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-1']) + }) + }) + // ==================== Handle with Alias ==================== describe('HasManyListHandle with Alias', () => { From c23a8ba23932d138b86b8a20e4c5c19e468d2d80 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:50:24 +0200 Subject: [PATCH 11/14] fix(bindx): undo an add() out of the view the add() created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An add() through a view nothing had read yet creates that view with a manual order. The undo image does not know the view, and restore kept the live order of every view it did not know, so undoing the add() left the created row listed there — a row for an entity the undo had just removed from the relation. A view missing from the image did not exist before the gesture, so it had no arranged order to restore; it now falls back to the default order, which derives from the restored writes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx/src/store/SnapshotStore.ts | 7 ++++--- tests/undo-journal.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index aa6ef59..3c756de 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1355,14 +1355,15 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { const live = this.relations.getHasManyState(img.key) // The editable layer (planned writes, manual ordering) comes from the image; // the server baseline always from the live state, which may have advanced - // since the gesture. A view the image does not know was created afterwards, - // by a non-journaled materialization, so it keeps its live ordering too. + // since the gesture. A view the image does not know did not exist before the + // gesture — the gesture itself may have created it — so it had no arranged + // order to restore and falls back to the default order of the restored writes. const views = new Map(live?.views ?? []) for (const [alias, view] of views) { const recorded = s.views.get(alias) views.set(alias, { serverIds: new Set(view.serverIds), - orderedIds: recorded ? (recorded.orderedIds ? [...recorded.orderedIds] : null) : view.orderedIds, + orderedIds: recorded?.orderedIds ? [...recorded.orderedIds] : null, membership: view.membership, }) } diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index 62e7d87..eda984d 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -9,6 +9,7 @@ import { disconnectRelation, moveInList, removeFromList, + addToList, connectToList, generateHasManyAlias, } from '@contember/bindx' @@ -318,6 +319,18 @@ describe('undo journal — deep coverage', () => { expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual(['s1']) expect(store.getHasManyPlannedConnections('Article', 'p', 'items')?.size ?? 0).toBe(0) }) + + test('an add through a view nothing had read yet is undone out of that view', () => { + const UNREAD = generateHasManyAlias('items', { filter: { pinned: true } }) + // The add itself creates the view, with an explicit order the journal never recorded. + dispatcher.dispatch(addToList('Article', 'p', 'items', 'Item', 'n1', UNREAD)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', UNREAD)).toEqual(['n1']) + + undo.undo() + + expect(store.getHasManyOrderedIds('Article', 'p', 'items', UNREAD)).toEqual([]) + expect(store.getHasManyCreatedEntities('Article', 'p', 'items')?.size ?? 0).toBe(0) + }) }) // ============================================================ From 7362bb6c46d933600b2e75860742facfa249a21a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:50:24 +0200 Subject: [PATCH 12/14] refactor(bindx): export the types StoredHasManyState is made of StoredHasManyState is exported for the undo/export paths, but its members were not: HasManyView, PlannedHasManyAddition and the two unions they carry, nor the projections SnapshotStore.getHasMany and getHasManyView return. A consumer could hold the state but not name what is inside it. Also documents a known limitation of the relation baseline: it is the union across views and views are never dropped, so a view nobody refetches any more can keep a row the server has since removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx/src/index.ts | 6 ++++++ packages/bindx/src/store/hasManyState.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 9134d66..c601b6d 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -250,6 +250,12 @@ export type { export type { StoredRelationState, StoredHasManyState, + HasManyView, + HasManyViewMembership, + PlannedHasManyAddition, + HasManyAdditionKind, + HasManyRelationProjection, + HasManyViewProjection, EntityMeta, } from './store/SnapshotStore.js' diff --git a/packages/bindx/src/store/hasManyState.ts b/packages/bindx/src/store/hasManyState.ts index 15bea31..938f95d 100644 --- a/packages/bindx/src/store/hasManyState.ts +++ b/packages/bindx/src/store/hasManyState.ts @@ -269,7 +269,11 @@ export function viewOrderedIds(state: StoredHasManyState, alias: string): string return explicit !== null && explicit !== undefined ? [...explicit] : computeViewOrderedIds(state, alias) } -/** The relation's server baseline: the union across views. */ +/** + * The relation's server baseline: the union across views. + * Known limitation: views are never dropped, so a view nobody refetches any more can + * keep a row the server has since removed in it. + */ export function relationServerIds(state: StoredHasManyState): Set { const ids = new Set() for (const view of state.views.values()) { From fab14359c12a6d15813454db59da5ee82fe8968f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:50:24 +0200 Subject: [PATCH 13/14] test(bindx): prove the filtered view sees the write it declines to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filtered-view case passed before the has-many state was shared: a connection made through another view never reached the filtered one at all, so "it does not show it" held for the wrong reason. The view now also reports the relation as dirty, which only holds when the write reached it — so the test fails on a store that keeps views apart and pins that the exclusion comes from the membership rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- .../hasMany/orderByViewMembership.test.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/react/relations/hasMany/orderByViewMembership.test.tsx b/tests/react/relations/hasMany/orderByViewMembership.test.tsx index 9a01db5..5ca85be 100644 --- a/tests/react/relations/hasMany/orderByViewMembership.test.tsx +++ b/tests/react/relations/hasMany/orderByViewMembership.test.tsx @@ -26,10 +26,15 @@ function Article({ params, publish }: Props) { publish(store) if (article.$status !== 'ready') return
Loading
- return {article.tags.length} + return ( + <> + {article.tags.length} + {String(article.tags.isDirty)} + + ) } -async function mount(params: object): Promise { +async function mount(params: object): Promise<{ store: SnapshotStore; container: HTMLElement }> { let store: SnapshotStore | null = null const { container } = render( @@ -37,12 +42,12 @@ async function mount(params: object): Promise { , ) await waitFor(() => expect(queryByTestId(container, 'tag-count')).not.toBeNull()) - return store! + return { store: store!, container } } describe('a has-many view is classified by its args, not by its alias', () => { test('an ordering-only view shows a member added elsewhere', async () => { - const store = await mount(orderByParams) + const { store } = await mount(orderByParams) const alias = generateHasManyAlias('tags', orderByParams) // A connection made through another view of the same relation. `orderBy` cannot @@ -55,8 +60,8 @@ describe('a has-many view is classified by its args, not by its alias', () => { .toEqual(['tag-1', 'tag-2', 'tag-9']) }) - test('a filtered view does not', async () => { - const store = await mount(filterParams) + test('a filtered view does not, although it sees the write', async () => { + const { store, container } = await mount(filterParams) const alias = generateHasManyAlias('tags', filterParams) const before = store.getHasManyOrderedIds('Article', 'article-1', 'tags', alias) @@ -64,7 +69,9 @@ describe('a has-many view is classified by its args, not by its alias', () => { store.planHasManyConnection('Article', 'article-1', 'tags', 'tag-9') }) - // The client cannot evaluate the filter, so it must not claim membership. + // The write reached this view's relation: the view reports it as pending... + expect(queryByTestId(container, 'tags-dirty')?.textContent).toBe('true') + // ...but the client cannot evaluate the filter, so it must not claim membership. expect(store.getHasManyOrderedIds('Article', 'article-1', 'tags', alias)).toEqual(before) }) }) From 9e3a690756b448c956e53209b2e9000daac4b414 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:59:14 +0200 Subject: [PATCH 14/14] fix(bindx): give a re-connected server row back to the view that listed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A removal drops the item from every manual order. Connecting it again appended it only to the manual orders of views the addition renders in, and a filtered view is not one of them unless the connect came from it — so a filtered view with a manual order lost a row its own server data returned, until a refetch. A view whose server rows hold the item now gets it back too. Also covers the rest of the manual-order behaviour across views: an addition stays out of views whose filter, limit or offset may exclude it, an item added through several views is listed once in each, a new row removed and connected again stays out of a filtered view, and undo/redo of add, connect, remove and move restore every view's manual order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx/src/store/HasManyStore.ts | 14 ++-- tests/undo-journal.test.ts | 95 ++++++++++++++++++++++++ tests/unit/handles/hasManyAlias.test.ts | 57 +++++++++++++- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 63641a8..47cf33b 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -320,23 +320,25 @@ export class HasManyStore { if (kind === 'created') addition.kind = 'created' state.plannedAdditions.set(itemId, addition) state.plannedRemovals.delete(itemId) - this.appendToRenderingExplicitOrders(state, itemId, addition) + this.appendToShowingExplicitOrders(state, itemId, addition) } /** * An explicit order replaces the default one, which is where additions otherwise - * appear, so every view the addition renders in must list it — not only the view - * it was made in. Guards against re-appending an id that is already listed: the - * connect paths re-run whenever an embedded reference is re-materialized. + * appear, so every view that shows the item must list it — not only the view it was + * made in. That includes a view whose own server rows hold it: a removal dropped it + * from every explicit order, and re-connecting it must give it back. Guards against + * re-appending an id that is already listed: the connect paths re-run whenever an + * embedded reference is re-materialized. */ - private appendToRenderingExplicitOrders( + private appendToShowingExplicitOrders( state: StoredHasManyState, itemId: string, addition: PlannedHasManyAddition, ): void { for (const [alias, view] of state.views) { if (view.orderedIds === null || view.orderedIds.includes(itemId)) continue - if (additionRendersIn(state.views, alias, addition)) { + if (view.serverIds.has(itemId) || additionRendersIn(state.views, alias, addition)) { view.orderedIds = [...view.orderedIds, itemId] } } diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index eda984d..cf41d69 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -333,6 +333,101 @@ describe('undo journal — deep coverage', () => { }) }) + describe('has-many undo across views with manual orders', () => { + // Every view carries a manual order, so what undo restores is the recorded + // order of each view, not a default recomputed from the restored writes. + const SORTED = generateHasManyAlias('items', { orderBy: [{ name: 'asc' }] }) + const FILTERED = generateHasManyAlias('items', { filter: { starred: true } }) + const LIMITED = generateHasManyAlias('items', { limit: 2 }) + + interface ViewOrders { + all: string[] + sorted: string[] + filtered: string[] + limited: string[] + } + + function orders(): ViewOrders { + return { + all: store.getHasManyOrderedIds('Article', 'p', 'items'), + sorted: store.getHasManyOrderedIds('Article', 'p', 'items', SORTED), + filtered: store.getHasManyOrderedIds('Article', 'p', 'items', FILTERED), + limited: store.getHasManyOrderedIds('Article', 'p', 'items', LIMITED), + } + } + + beforeEach(() => { + store.setEntityData('Article', 'p', { id: 'p' }, true) + store.declareHasManyViewMembership('Article', 'items', SORTED, 'total') + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2', 's3']) + store.setHasManyServerIds('Article', 'p', 'items', ['s3', 's1', 's2'], SORTED) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's3'], FILTERED) + store.setHasManyServerIds('Article', 'p', 'items', ['s1', 's2'], LIMITED) + for (const alias of [undefined, SORTED, FILTERED, LIMITED]) { + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 1, alias)) + } + }) + + test('an add is undone and redone in every view that shows it', () => { + const before = orders() + dispatcher.dispatch(addToList('Article', 'p', 'items', 'Item', 'n1', SORTED)) + const after = orders() + expect(after.all).toEqual([...before.all, 'n1']) + expect(after.sorted).toEqual([...before.sorted, 'n1']) + expect(after.filtered).toEqual(before.filtered) + + undo.undo() + expect(orders()).toEqual(before) + + undo.redo() + expect(orders()).toEqual(after) + }) + + test('a connect from a filtered view and a later add undo and redo one gesture at a time', () => { + const before = orders() + dispatcher.dispatch(connectToList('Article', 'p', 'items', 'x9', 'Item', FILTERED)) + const afterConnect = orders() + expect(afterConnect.filtered).toEqual([...before.filtered, 'x9']) + expect(afterConnect.all).toEqual([...before.all, 'x9']) + expect(afterConnect.sorted).toEqual([...before.sorted, 'x9']) + + dispatcher.dispatch(addToList('Article', 'p', 'items', 'Item', 'n1')) + const afterAdd = orders() + expect(afterAdd.filtered).toEqual(afterConnect.filtered) + + undo.undo() + expect(orders()).toEqual(afterConnect) + undo.undo() + expect(orders()).toEqual(before) + expect(store.getHasManyPlannedConnections('Article', 'p', 'items')?.size ?? 0).toBe(0) + + undo.redo() + undo.redo() + expect(orders()).toEqual(afterAdd) + }) + + test('a removal is undone back into the manual order of every view', () => { + const before = orders() + dispatcher.dispatch(removeFromList('Article', 'p', 'items', 's1', 'disconnect')) + expect(orders().filtered).not.toContain('s1') + + undo.undo() + + expect(orders()).toEqual(before) + }) + + test('undoing an add made in one view keeps a move made in another', () => { + dispatcher.dispatch(moveInList('Article', 'p', 'items', 0, 2, SORTED)) + const moved = orders().sorted + dispatcher.dispatch(addToList('Article', 'p', 'items', 'Item', 'n1')) + expect(orders().sorted).toEqual([...moved, 'n1']) + + undo.undo() + + expect(orders().sorted).toEqual(moved) + }) + }) + // ============================================================ // Multi-cell atomic gesture // ============================================================ diff --git a/tests/unit/handles/hasManyAlias.test.ts b/tests/unit/handles/hasManyAlias.test.ts index 16a0e89..4cec407 100644 --- a/tests/unit/handles/hasManyAlias.test.ts +++ b/tests/unit/handles/hasManyAlias.test.ts @@ -287,6 +287,13 @@ describe('HasMany with Alias Support', () => { describe('Explicit order of a sibling view', () => { const orderedAlias = generateHasManyAlias('tags', { orderBy: [{ name: 'asc' }] }) const activeAlias = generateHasManyAlias('tags', { filter: { active: true } }) + const limitedAlias = generateHasManyAlias('tags', { limit: 2 }) + const offsetAlias = generateHasManyAlias('tags', { offset: 1 }) + + function materializeReordered(alias: string, serverIds: string[]): void { + store.getOrCreateHasMany('Article', 'a-1', 'tags', serverIds, alias) + store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, alias) + } beforeEach(() => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) @@ -332,13 +339,57 @@ describe('HasMany with Alias Support', () => { expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-2', 't-1', 'n-1']) }) - test('a filtered sibling with a manual order does not gain the addition', () => { - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1', 't-2'], activeAlias) - store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, activeAlias) + test('a sibling whose args may exclude the addition does not gain it', () => { + materializeReordered(activeAlias, ['t-1', 't-2']) + materializeReordered(limitedAlias, ['t-1', 't-2']) + materializeReordered(offsetAlias, ['t-1', 't-2']) store.addToHasMany('Article', 'a-1', 'tags', 'n-1') + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9', orderedAlias) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', limitedAlias)).toEqual(['t-2', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', offsetAlias)).toEqual(['t-2', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'n-1', 'x-9']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-1', 't-2', 'n-1', 'x-9']) + }) + + test('an item added through several views is listed once in each', () => { + materializeReordered(activeAlias, ['t-1', 't-2']) + store.moveInHasMany('Article', 'a-1', 'tags', 0, 1, orderedAlias) + + store.addToHasMany('Article', 'a-1', 'tags', 'n-1', orderedAlias) + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9', orderedAlias) + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9', orderedAlias) + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9') + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9', activeAlias) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'n-1', 'x-9']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-2', 't-1', 'n-1', 'x-9']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-1', 'x-9']) + }) + + test('a server row removed and connected again returns to the filtered view that listed it', () => { + materializeReordered(activeAlias, ['t-1', 't-2']) + + store.planHasManyRemoval('Article', 'a-1', 'tags', 't-1', 'disconnect') + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2']) + + // Connected through the unparameterized view: the filtered view's own server rows hold it. + store.planHasManyConnection('Article', 'a-1', 'tags', 't-1') + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-1']) + }) + + test('a new row removed and connected again stays out of a filtered view', () => { + materializeReordered(activeAlias, ['t-1', 't-2']) + + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9') + store.removeFromHasMany('Article', 'a-1', 'tags', 'x-9', 'disconnect') + store.planHasManyConnection('Article', 'a-1', 'tags', 'x-9') expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-2', 't-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 'x-9']) }) })