diff --git a/packages/bindx-client/src/index.ts b/packages/bindx-client/src/index.ts index 707ad740..470b44eb 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 7bf31962..13394af9 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/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 7945d6ce..f1674f45 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 d48da813..5d226a65 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/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 2fdd86c1..a9b1289b 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,16 @@ 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( + this.entityType, + schemaFieldName, + fieldMeta.alias, + canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total', + ) + } return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 1f8984aa..a74a4db1 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -406,23 +406,18 @@ export class HasManyListHandle 0 || - state.plannedAdditions.size > 0 || - state.orderedIds !== null + this.store.hasPendingHasManyWrites(this.entityType, this.entityId, this.fieldName) + || this.store.hasExplicitHasManyOrder(this.entityType, this.entityId, this.fieldName, this.alias) ) } @@ -452,7 +447,7 @@ export class HasManyListHandle(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, - } -} +import { + additionFoldTargets, + additionRendersIn, + arraysEqual, + cloneHasManyState, + computeViewOrderedIds, + createHasManyView, + emptyHasManyState, + liveHasManyChildIds, + plannedDeleteChildIds, + reconcileHasManyState, + setsEqual, + toRelationProjection, + toViewProjection, + viewOrderedIds, + type HasManyRelationProjection, + type HasManyRemovalType, + type HasManyViewMembership, + type HasManyViewProjection, + type PlannedHasManyAddition, + type ReconciliationResult, + type SentHasManyDelta, + type StoredHasManyState, +} from './hasManyState.js' /** - * Computes the default ordered IDs for a has-many relation. - * Order is: serverIds (minus removals) + plannedAdditions - */ -export function computeDefaultOrderedIds(state: StoredHasManyState): string[] { - const result: string[] = [] - - for (const id of state.serverIds) { - if (!state.plannedRemovals.has(id)) { - result.push(id) - } - } - - for (const id of state.plannedAdditions.keys()) { - if (!result.includes(id)) { - result.push(id) - } - } - - return result -} - -/** - * Owns has-many list state ("parentType:parentId:fieldName" → {@link StoredHasManyState}). + * Owns has-many relation state ("parentType:parentId:fieldName" → {@link StoredHasManyState}). + * + * The key's last segment is always the SCHEMA FIELD NAME; an args-view's alias + * addresses a view INSIDE the state, never a key of its own. See the module doc of + * `hasManyState.ts` for why reads are per view and writes are not. * * Has its own monotonic {@link mutationVersion} bumped on every actual write * (funnelled through {@link writeHasMany} plus the delete/clear paths); the * facade sums this with the has-one counter for {@link ReachabilityAnalyzer}. */ export class HasManyStore { - /** Has-many list states keyed by "parentType:parentId:fieldName" */ + /** Has-many relation states keyed by "parentType:parentId:fieldName" */ private readonly hasManyStates = new Map() private readonly owners = new RelationOwnerIndex() @@ -191,178 +121,154 @@ export class HasManyStore { } /** - * Gets or creates has-many list state. + * 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 + * it hands out a handle, which is strictly before any path can materialize the view. */ - getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { - const existing = this.hasManyStates.get(key) - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(serverIds ?? []), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: 0, - }) - } else if (serverIds !== undefined) { - const newServerIds = new Set(serverIds) - if (!setsEqual(existing.serverIds, newServerIds)) { - this.writeHasMany(key, { - ...existing, - serverIds: newServerIds, - orderedIds: null, - version: existing.version + 1, - }) - } - } + private readonly declaredMembership = new Map() - return cloneHasManyState(this.hasManyStates.get(key)!) + declareViewMembership( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.declaredMembership.set(`${entityType}:${fieldName}:${alias}`, membership) } /** - * Gets 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. 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. */ - getHasMany(key: string): StoredHasManyState | undefined { - const state = this.hasManyStates.get(key) - return state ? cloneHasManyState(state) : undefined + private viewMembership(key: string, alias: string): HasManyViewMembership { + const fieldName = fieldFromRelationKey(key) + if (alias === fieldName) return 'total' + return this.declaredMembership.get(`${entityTypeFromRelationKey(key)}:${fieldName}:${alias}`) ?? 'partial' } /** - * Sets server IDs for a has-many relation. + * 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. */ - setHasManyServerIds(key: string, serverIds: string[]): void { + 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 { - this.writeHasMany(key, { - ...existing, - serverIds: new Set(serverIds), - 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 } - /** - * Plans a removal for a has-many item. - */ - planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void { + private commitEdit(key: string, state: StoredHasManyState): void { const existing = this.hasManyStates.get(key) - - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map([[itemId, type]]), - plannedAdditions: new Map(), - version: 0, - }) - } else { - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.set(itemId, type) - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedRemovals: newPlannedRemovals, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - }) - } - this.editableWriteVersion++ + state.version = existing ? existing.version + 1 : 0 + this.writeHasMany(key, state) } /** - * Plans a connection for a has-many item. + * Gets or creates the relation state and the addressed view, refreshing that + * view's server baseline when one is supplied. */ - planHasManyConnection(key: string, itemId: string): void { - const existing = this.hasManyStates.get(key) + 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 + } - if (!existing) { - this.writeHasMany(key, { - serverIds: new Set(), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map([[itemId, 'connected']]), - version: 0, - }) - } else { - const newPlannedAdditions = new Map(existing.plannedAdditions) - // Do not downgrade an existing 'created' addition to 'connected'. - if (newPlannedAdditions.get(itemId) !== 'created') { - newPlannedAdditions.set(itemId, 'connected') - } - const newPlannedRemovals = new Map(existing.plannedRemovals) - newPlannedRemovals.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { - newOrderedIds = [...newOrderedIds, itemId] - } - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) + 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.editableWriteVersion++ + this.commitEdit(key, state) } - /** - * Commits has-many state after successful persist. - */ - commitHasMany(key: string, newServerIds: string[]): void { - const existing = this.hasManyStates.get(key) + /** 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 + } - this.writeHasMany(key, { - serverIds: new Set(newServerIds), - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - version: (existing?.version ?? 0) + 1, - }) + /** What one mounted has-many handle sees. */ + getViewProjection(key: string, alias: string): HasManyViewProjection { + 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 } /** - * Advances the server baseline by the confirmed sent delta and rebases local - * edits made after the request started onto that new baseline. + * 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. */ - reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult { - const existing = this.hasManyStates.get(key) - if (!existing) return 'conflict' + 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 + } - const next = reconcileHasManyState(existing, delta) - this.writeHasMany(key, next.state) - return next.result + /** 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) } /** - * Resets has-many state to server state (clears planned operations). + * Plans a removal. Relation-level: the item stops being a member of the relation, + * so it leaves every view's ordering too. */ - resetHasMany(key: string): void { - const existing = this.hasManyStates.get(key) - if (!existing) return + 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++ + } - this.writeHasMany(key, { - serverIds: existing.serverIds, - orderedIds: null, - plannedRemovals: new Map(), - plannedAdditions: new Map(), - 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.commitEdit(key, state) this.editableWriteVersion++ } @@ -370,29 +276,14 @@ export class HasManyStore { * 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, - }) - } + 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++ } @@ -401,45 +292,92 @@ export class HasManyStore { * 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) + 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] + } + this.commitEdit(key, state) + this.editableWriteVersion++ + } - 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') + /** + * 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. + */ + private recordAddition( + state: StoredHasManyState, + alias: string, + itemId: string, + kind: 'created' | 'connected', + ): void { + const existing = state.plannedAdditions.get(itemId) + 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.appendToShowingExplicitOrders(state, itemId, addition) + } + + /** + * An explicit order replaces the default one, which is where additions otherwise + * 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 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 (view.serverIds.has(itemId) || additionRendersIn(state.views, alias, addition)) { + view.orderedIds = [...view.orderedIds, itemId] } - // Only touch an explicit order — the default order already derives from - // plannedAdditions (see computeDefaultOrderedIds). Guard against - // re-appending an id that is already listed: this path re-runs whenever an - // embedded connect reference is re-materialized, and an unconditional - // append would surface the same item twice (mirrors planHasManyConnection). - // 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] + } + } + + 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) } - this.writeHasMany(key, { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - plannedRemovals: newPlannedRemovals, - version: existing.version + 1, - }) } + } + + /** + * Advances the server baseline by the confirmed sent delta and rebases local + * edits made after the request started onto that new baseline. + */ + reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult { + const existing = this.hasManyStates.get(key) + if (!existing) return 'conflict' + + const next = reconcileHasManyState(existing, delta, fieldFromRelationKey(key)) + this.writeHasMany(key, next.state) + return next.result + } + + /** + * 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 { + if (!this.hasManyStates.has(key)) return + + 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++ } @@ -453,52 +391,40 @@ export class HasManyStore { const existing = this.hasManyStates.get(key) if (!existing) return false - const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created' - - if (isCreatedEntity) { - const newPlannedAdditions = new Map(existing.plannedAdditions) - newPlannedAdditions.delete(itemId) - let newOrderedIds = existing.orderedIds - if (newOrderedIds !== null) { - newOrderedIds = newOrderedIds.filter(id => id !== itemId) - } + if (existing.plannedAdditions.get(itemId)?.kind !== 'created') { + // planHasManyRemoval bumps editableWriteVersion itself. + this.planHasManyRemoval(key, itemId, removalType) + return true + } - const newState: StoredHasManyState = { - ...existing, - orderedIds: newOrderedIds, - plannedAdditions: newPlannedAdditions, - version: existing.version + 1, - } + // 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) - 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 @@ -509,26 +435,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) } /** @@ -583,38 +502,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) } } @@ -664,13 +584,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 @@ -678,58 +594,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) } } } @@ -763,84 +668,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 6dde4e47..fe56d7d2 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -5,27 +5,33 @@ import { type SentHasOneTransition, type StoredRelationState, } from './HasOneStore.js' +import { HasManyStore } from './HasManyStore.js' import { - HasManyStore, - computeDefaultOrderedIds, type HasManyAdditionKind, + type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewMembership, + type HasManyViewProjection, 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' export type { SentHasOneTransition } from './HasOneStore.js' export type { HasManyAdditionKind, + HasManyRelationProjection, HasManyRemovalType, + HasManyView, + HasManyViewMembership, + HasManyViewProjection, + PlannedHasManyAddition, SentHasManyAddition, SentHasManyDelta, SentHasManyRemoval, StoredHasManyState, -} from './HasManyStore.js' -export { computeDefaultOrderedIds } from './HasManyStore.js' +} from './hasManyState.js' export type RelationReconciliationResult = 'applied' | 'conflict' @@ -103,28 +109,58 @@ export class RelationStore implements Rekeyable { // ==================== Has-Many Relations ==================== - getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState { - return this.hasMany.getOrCreateHasMany(key, serverIds) + declareHasManyViewMembership( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.hasMany.declareViewMembership(entityType, fieldName, alias, membership) } - getHasMany(key: string): StoredHasManyState | undefined { + getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void { + this.hasMany.getOrCreateHasMany(key, alias, serverIds) + } + + /** + * 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) } - 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) + } + + 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) } - planHasManyConnection(key: string, itemId: string): void { - this.hasMany.planHasManyConnection(key, itemId) + setHasManyServerIds(key: string, alias: string, serverIds: string[]): void { + this.hasMany.setHasManyServerIds(key, alias, serverIds) + } + + 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 +174,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 b5a2ca9a..3c756ded 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -5,7 +5,10 @@ import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult import { ErrorStore } from './ErrorStore.js' import { RelationStore, + type HasManyRelationProjection, type HasManyRemovalType, + type HasManyViewMembership, + type HasManyViewProjection, type RelationReconciliationResult, type SentHasManyDelta, type SentHasOneTransition, @@ -32,7 +35,13 @@ import type { } from '../undo/UndoJournal.js' export type { + HasManyAdditionKind, + HasManyRelationProjection, HasManyRemovalType, + HasManyView, + HasManyViewMembership, + HasManyViewProjection, + PlannedHasManyAddition, RelationReconciliationResult, SentHasManyAddition, SentHasManyDelta, @@ -530,25 +539,74 @@ 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( + entityType: string, + fieldName: string, + alias: string, + membership: HasManyViewMembership, + ): void { + this.relations.declareHasManyViewMembership(entityType, fieldName, alias, membership) + } + + /** + * 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) + } + + /** 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, - ): StoredHasManyState | undefined { - const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) - return this.relations.getHasMany(key) + ): 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, + parentId: string, + fieldName: string, + alias?: string, + ): HasManyViewProjection { + const key = this.getRelationKey(parentType, parentId, fieldName) + return this.relations.getHasManyView(key, alias ?? fieldName) } setHasManyServerIds( @@ -558,8 +616,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 +627,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 +638,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 +659,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 +682,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 +693,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 +707,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) } @@ -679,7 +721,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { ): void { const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordHasMany(key) - this.relations.connectExistingToHasMany(key, itemId) + this.relations.connectExistingToHasMany(key, fieldName, itemId) this.notifyRelationSubscribers(key) } @@ -689,9 +731,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 +750,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 +762,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 +772,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 +786,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,25 +1199,19 @@ 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 { - const state = this.relations.getHasMany(relationKey) - return new Set(state?.serverIds ?? []) + getLiveHasManyServerIds(relationKey: string): ReadonlyMap> { + return this.relations.collectHasManyViewServerIds(relationKey) } 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', 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 +1250,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) } } @@ -1318,10 +1352,26 @@ 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 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?.orderedIds ? [...recorded.orderedIds] : null, + 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 new file mode 100644 index 00000000..938f95d9 --- /dev/null +++ b/packages/bindx/src/store/hasManyState.ts @@ -0,0 +1,408 @@ +/** + * 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. + * + * ## 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' + +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' + +/** + * 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 +} + +export interface SentHasManyRemoval { + itemId: string + type: HasManyRemovalType +} + +export interface SentHasManyDelta { + additions: readonly SentHasManyAddition[] + removals: readonly SentHasManyRemoval[] +} + +/** + * Has-many relation state stored in SnapshotStore, keyed by + * "parentType:parentId:fieldName" — the schema field name, never an alias. + */ +export interface StoredHasManyState { + /** 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 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 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 { + serverIds: Set + orderedIds: string[] | null +} + +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 { + views: cloneViews(state.views), + plannedRemovals: new Map(state.plannedRemovals), + 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 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, + addition: PlannedHasManyAddition, +): string[] { + const targets: string[] = [] + for (const alias of state.views.keys()) { + if (additionRendersIn(state.views, alias, addition)) targets.push(alias) + } + return targets +} + +/** + * Default order of one view: its server rows minus the relation's planned removals, + * then the planned additions that render there. + */ +export function computeViewOrderedIds(state: StoredHasManyState, alias: string): string[] { + const result: string[] = [] + const view = state.views.get(alias) + + if (view) { + for (const id of view.serverIds) { + if (!state.plannedRemovals.has(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. + * 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()) { + 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 { + const view = state?.views.get(alias) + return { + serverIds: new Set(view?.serverIds), + orderedIds: view?.orderedIds ? [...view.orderedIds] : null, + } +} + +/** + * The single liveness predicate for has-many membership: effective members are + * (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 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) + } + 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 +} + +/** + * 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, + fieldName: string, +): HasManyReconciliation { + const currentLive = liveHasManyChildIds(existing) + 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) { + // 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 = renderTargets(views, fieldName, planned ? additionFoldTargets(existing, planned) : []) + 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)) { + plannedRemovals.set( + addition.itemId, + addition.kind === 'created' ? 'delete' : 'disconnect', + ) + } + } + + for (const removal of delta.removals) { + // 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) + + 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)) { + // 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(renderTargets(views, fieldName, shownIn)), + }) + } + } + + return { + 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 f3c2cd51..80e9fa2e 100644 --- a/packages/bindx/src/store/relationKey.ts +++ b/packages/bindx/src/store/relationKey.ts @@ -9,6 +9,23 @@ 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 + * 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 3f407e1b..4f74af2b 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 7797f813..8c60d539 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/packages/bindx/src/utils/aliasGenerator.ts b/packages/bindx/src/utils/aliasGenerator.ts index 48dad970..c8c802ea 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/addWithArgsPersistsNested.test.tsx b/tests/react/relations/hasMany/addWithArgsPersistsNested.test.tsx new file mode 100644 index 00000000..aaac46ea --- /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/react/relations/hasMany/orderByViewMembership.test.tsx b/tests/react/relations/hasMany/orderByViewMembership.test.tsx new file mode 100644 index 00000000..5ca85bea --- /dev/null +++ b/tests/react/relations/hasMany/orderByViewMembership.test.tsx @@ -0,0 +1,77 @@ +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} + {String(article.tags.isDirty)} + + ) +} + +async function mount(params: object): Promise<{ store: SnapshotStore; container: HTMLElement }> { + let store: SnapshotStore | null = null + const { container } = render( + +
{ store = next }} /> + , + ) + await waitFor(() => expect(queryByTestId(container, 'tag-count')).not.toBeNull()) + 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 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, 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) + + act(() => { + store.planHasManyConnection('Article', 'article-1', 'tags', 'tag-9') + }) + + // 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) + }) +}) diff --git a/tests/undo-journal.test.ts b/tests/undo-journal.test.ts index db70ee13..cf41d695 100644 --- a/tests/undo-journal.test.ts +++ b/tests/undo-journal.test.ts @@ -9,6 +9,9 @@ import { disconnectRelation, moveInList, removeFromList, + addToList, + connectToList, + generateHasManyAlias, } from '@contember/bindx' /** @@ -85,7 +88,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 +119,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 +154,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') @@ -267,6 +270,164 @@ 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 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'], 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', WIDE)).toEqual(['s2']) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', NARROW)).toEqual([]) + + undo.undo() + + 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, WIDE)) + expect(store.getHasManyOrderedIds('Article', 'p', 'items', WIDE)).toEqual(['s2', 's1']) + + undo.undo() + + 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', 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', WIDE)).toEqual(['s1', 's2']) + + undo.undo() + + 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) + }) + }) + + 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 36c4b50d..4cec4074 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,243 @@ 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 } }) + 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) - const handle1 = createHasManyHandle(alias1) - const handle2 = createHasManyHandle(alias2) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', activeAlias)).toEqual(['t-1']) + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', inactiveAlias)).toEqual(['t-2']) + }) - // 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('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) - // 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.getHasMany('Article', 'a-1', 'tags')?.serverIds).toEqual(new Set(['t-1', '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('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) - 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) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) + + // What the next persist will send — addressed by the schema field name. + expect(store.getHasManyPlannedConnections('Article', 'a-1', 'tags')).toEqual(new Set(['t-3'])) }) - 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) + 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) - const alias1 = generateHasManyAlias('tags', { filter: { active: true } }) - const alias2 = generateHasManyAlias('tags', { filter: { active: false } }) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) - const handle1 = createHasManyHandle(alias1) - const handle2 = createHasManyHandle(alias2) + // 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('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']) - // Initialize both - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-2'], alias2) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) - // Both should be clean initially - expect(handle1.isDirty).toBe(false) - expect(handle2.isDirty).toBe(false) + // The unparameterized view has no filter to violate. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags')).toEqual(['t-1', 't-2', 't-3']) + }) - // Make changes only to handle1 - handle1.connect('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('Article', 'tags', orderedAlias, 'total') + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], orderedAlias) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['t-1'], activeAlias) - // Only handle1 should be dirty - expect(handle1.isDirty).toBe(true) - expect(handle2.isDirty).toBe(false) + store.planHasManyConnection('Article', 'a-1', 'tags', 't-3', activeAlias) + + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', orderedAlias)).toEqual(['t-1', 't-3']) }) - 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 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) - 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 safe direction: never claim membership the client cannot prove. + expect(store.getHasManyOrderedIds('Article', 'a-1', 'tags', unknownAlias)).toEqual(['t-1']) + }) - // 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) + 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) - const alias1 = generateHasManyAlias('tags', { filter: { active: true } }) - const alias2 = generateHasManyAlias('tags', { filter: { active: false } }) + store.moveInHasMany('Article', 'a-1', 'tags', 0, 2, activeAlias) + + 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']) + }) + + 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']) + }) - // Initialize both - store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias1) - store.getOrCreateHasMany('Article', 'a-1', 'tags', [], alias2) + 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) - // Make changes to both - store.planHasManyConnection('Article', 'a-1', 'tags', 't-1', alias1) - store.planHasManyConnection('Article', 'a-1', 'tags', 't-2', alias2) + store.resetHasMany('Article', 'a-1', 'tags') - // Reset only alias1 - store.resetHasMany('Article', 'a-1', 'tags', alias1) + expect(store.getHasMany('Article', 'a-1', 'tags')?.plannedAdditions.size).toBe(0) + }) + }) + + // ==================== 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 } }) + 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) + 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']) + }) - // 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) + 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') - expect(state1?.plannedAdditions.size).toBe(0) - expect(state2?.plannedAdditions.size).toBe(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 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']) }) }) // ==================== 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 +406,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 +427,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 +441,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 +487,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 +504,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 +515,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 0ea8caec..a3e2227b 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 c43453c9..536dd584 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 { @@ -15,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 968fa1ff..d9717552 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 319c29d1..64887450 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.getHasManyState(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.getHasManyState(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.getHasManyState(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.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, 'persisted-1') - expect(relations.getHasMany(KEY)?.plannedAdditions.get('persisted-1')).toBe('connected') + relations.connectExistingToHasMany(KEY, 'tags', 'persisted-1') + expect(relations.getHasManyState(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.getHasManyState(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/persistedRelationBaselineReconciliation.test.ts b/tests/unit/store/persistedRelationBaselineReconciliation.test.ts index c5f5b375..f6ac3ab0 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([]) + }) +}) diff --git a/tests/unit/store/reachabilityMemoization.test.ts b/tests/unit/store/reachabilityMemoization.test.ts index 2d7bec12..4b2fb249 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 37c4c8e2..92cb41f2 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 e83adcf4..5b5a768d 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() }) })