Skip to content
5 changes: 3 additions & 2 deletions packages/bindx-dataview/src/HasManyDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ function HasManyDataGridImpl<TEntity extends object>({
return
}

const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data }))
store.indexServerResponse(targetEntityType, relation.rows, setup.selection, schemaRegistry)
const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data: store.resolveServerOccurrence(data) }))
store.batchNotifications(() => {
for (const item of items) {
dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true))
Expand All @@ -211,7 +212,7 @@ function HasManyDataGridImpl<TEntity extends object>({
return () => {
abortController.abort()
}
}, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store])
}, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store, schemaRegistry])

// ---- Build items from state ----
const items = useMemo((): EntityAccessor<TEntity>[] => {
Expand Down
5 changes: 3 additions & 2 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ export function useEntity(
if (result.type === 'get' && result.data === null) {
dispatcher.dispatch(setLoadState(entityType, id, 'not_found'))
} else if (result.type === 'get' && result.data) {
const data = result.data
store.indexServerResponse(entityType, result.data, selectionMeta, schemaRegistry)
const data = store.resolveServerOccurrence(result.data)
// Revalidation: advance the server baseline but keep local dirty
// edits intact (see EntitySnapshotStore.refreshServerData).
store.batchNotifications(() => {
Expand Down Expand Up @@ -323,7 +324,7 @@ export function useEntity(
fetchingRef.current = null
}
}
}, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta])
}, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta, schemaRegistry])

// --- EntityHandle ---
// The handle keeps a stable identity across data changes — it is a stateless live view over the
Expand Down
5 changes: 3 additions & 2 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,8 @@ export function useEntityList(
throw new Error('Unexpected query result type')
}

const items = result.data.map(data => ({ id: getRowId(entityType, data), data }))
store.indexServerResponse(entityType, result.data, selectionMeta, schemaRegistry)
const items = result.data.map(data => ({ id: getRowId(entityType, data), data: store.resolveServerOccurrence(data) }))
store.batchNotifications(() => {
for (const item of items) {
// Revalidation preserves local edits while advancing the server baseline.
Expand Down Expand Up @@ -504,7 +505,7 @@ export function useEntityList(
return () => {
abortController.abort()
}
}, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta])
}, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta, schemaRegistry])

return accessor
}
7 changes: 7 additions & 0 deletions packages/bindx/src/core/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export interface LoadEntityListOptions<TEntity = unknown> {
/**
* Non-React service for loading entities.
* Can be used in any JavaScript environment.
*
* It writes each response as it arrives, without uniting the occurrences of one
* entity within it (see `SnapshotStore.indexServerResponse`): that needs the
* selection and the schema, and the loader has only a `QuerySpec`. A query that
* reaches one entity through two paths with different sub-selections can
* therefore lose the wider one's fields, as described in
* https://github.com/contember/bindx/issues/123. The React hooks do not go through it.
*/
export class EntityLoader {
constructor(
Expand Down
3 changes: 2 additions & 1 deletion packages/bindx/src/handles/HasManyListHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
* Only called when parent's embedded data has changed (re-fetch detected).
*/
private ensureItemSnapshots(listData: Array<Record<string, unknown>>): void {
for (const itemData of listData) {
for (const embeddedItem of listData) {
const itemData = this.store.resolveServerOccurrence(embeddedItem)
const itemId = itemData['id'] as string
if (!itemId) continue

Expand Down
6 changes: 4 additions & 2 deletions packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,13 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
return
}

const occurrence = this.store.resolveServerOccurrence(embeddedData as Record<string, unknown>)

// Skip if embedded data values match existing serverData — avoids overwriting
// unpersisted local mutations when a re-fetch returns the same server data
// (e.g. polling). A new reference with identical values means no actual change.
const existing = this.store.getEntitySnapshot(this.targetType, id)
if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record<string, unknown>, existing.serverData as Record<string, unknown>)) {
if (existing?.serverData && embeddedDataMatchesSnapshot(occurrence, existing.serverData as Record<string, unknown>)) {
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
return
}
Expand All @@ -435,7 +437,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
this.store.refreshServerData(
this.targetType,
id,
embeddedData as Record<string, unknown>,
occurrence,
true, // skipNotify - called during render, data already exists embedded in parent
)
this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
Expand Down
224 changes: 224 additions & 0 deletions packages/bindx/src/store/ResponseOccurrenceIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import type { SelectionMeta } from '../selection/types.js'

/** The schema knowledge the index needs: where a relation points and whether it is a list. */
export interface RelationTargetResolver {
getRelationTarget(entityType: string, fieldName: string): string | undefined
isHasMany(entityType: string, fieldName: string): boolean
}

type EntityRecord = Record<string, unknown>

interface PendingVisit {
readonly value: unknown
readonly entityType: string
readonly selection: SelectionMeta
readonly isList: boolean
}

/**
* Unites the occurrences of one entity within a single server response.
*
* One query can reach the same entity through several paths, each with its own
* sub-selection (`article.attachments` next to `article.sections.article.attachments`).
* Every occurrence is later written into the entity's snapshot, so without this
* the narrower one could replace fields the wider one carried. Occurrences of
* one response are equally fresh, so they are merged without any recency rule:
* each maps to the union of all of them, keyed by entity type and id. A scalar two
* occurrences disagree on (which one server read should not produce) takes the
* value of the occurrence visited last. The response is walked breadth first,
* relations in selection order and list items in list order, so the outcome
* follows from the response and the selection alone.
*
* The union is shallow. A relation value in it is one occurrence's raw value, and
* the related entities inside it resolve to their own unions when they are
* written, so relations inside relations unite level by level without rewriting
* the response.
*
* Reads never merge across responses: a later read replaces what an earlier one
* stored, as before.
*/
export class ResponseOccurrenceIndex {
private readonly unions = new WeakMap<object, Readonly<EntityRecord>>()

/**
* Indexes a response read for `entityType` with `selection`. The type of every
* nested occurrence comes from the schema through the selection's field names;
* an occurrence whose type cannot be resolved is left out rather than matched
* by id alone.
*
* Occurrences of one type at one selection node carry the same fields, so
* their union changes nothing. Only types the selection reaches at two or more
* nodes are collected, only the relations leading to them are walked, and a
* selection with none skips the walk entirely (see {@link planOccurrenceWalk}).
*/
index(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void {
const plan = planOccurrenceWalk(entityType, selection, schema)
if (plan.repeatedTypes.size === 0) {
return
}
const root: PendingVisit = { value: data, entityType, selection, isList: Array.isArray(data) }
const occurrencesByType = collectOccurrences(root, plan, schema)
for (const occurrencesById of occurrencesByType.values()) {
for (const occurrences of occurrencesById.values()) {
if (Array.isArray(occurrences)) {
this.unite(occurrences)
}
}
}
}

private unite(occurrences: readonly EntityRecord[]): void {
const union: EntityRecord = {}
for (const occurrence of occurrences) {
Object.assign(union, occurrence)
}
Object.freeze(union)
for (const occurrence of occurrences) {
this.unions.set(occurrence, union)
}
}

/** The union of every occurrence of this entity in its response, or the occurrence itself when it had no sibling. */
resolve(occurrence: EntityRecord): EntityRecord {
return this.unions.get(occurrence) ?? occurrence
}
}

/** Occurrences by entity type and id. A lone occurrence is kept as is; an array only appears once an id repeats. */
type OccurrencesByType = Map<string, Map<unknown, EntityRecord | EntityRecord[]>>

function collectOccurrences(root: PendingVisit, plan: OccurrenceWalkPlan, schema: RelationTargetResolver): OccurrencesByType {
const occurrencesByType: OccurrencesByType = new Map()
const pending: PendingVisit[] = [root]
for (let next = 0; next < pending.length; next++) {
const visit = pending[next]!
for (const entity of entitiesOf(visit.value, visit.isList)) {
if (plan.repeatedTypes.has(visit.entityType)) {
addOccurrence(occurrencesByType, visit.entityType, entity)
}
for (const fieldMeta of visit.selection.fields.values()) {
if (!fieldMeta.nested || !plan.nodesToWalk.has(fieldMeta.nested) || entity[fieldMeta.alias] == null) continue
const targetType = schema.getRelationTarget(visit.entityType, fieldMeta.fieldName)
if (targetType === undefined) continue
pending.push({
value: entity[fieldMeta.alias],
entityType: targetType,
selection: fieldMeta.nested,
isList: schema.isHasMany(visit.entityType, fieldMeta.fieldName),
})
}
}
}
return occurrencesByType
}

/** What walking a response needs from its selection. */
export interface OccurrenceWalkPlan {
/** Entity types the selection reaches at two or more nodes, such as `article` and `article.sections.article`. Only these are united. */
readonly repeatedTypes: ReadonlySet<string>
/** Selection nodes on a path to a repeated type. The walk descends into no other. */
readonly nodesToWalk: ReadonlySet<SelectionMeta>
}

/**
* Plans the walk of one response. It depends only on the schema, the selection
* and its root type, and costs one pass over the selection tree, not over the
* response. It is computed per response rather than cached, because a selection
* can still grow after its first fetch (`mergeSelections` extends nested
* selections in place, and a fragment's selection is shared by every place
* that uses it).
*/
export function planOccurrenceWalk(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): OccurrenceWalkPlan {
const nodes = listSelectionNodes(entityType, selection, schema)
const repeatedTypes = findRepeatedTypes(nodes)
return { repeatedTypes, nodesToWalk: findNodesToWalk(nodes, repeatedTypes) }
}

interface SelectionNode {
readonly entityType: string
readonly selection: SelectionMeta
readonly parent: number | null
}

/** Every position in the selection tree, breadth first, each with the index of its parent. */
function listSelectionNodes(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): readonly SelectionNode[] {
const nodes: SelectionNode[] = [{ entityType, selection, parent: null }]
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]!
for (const fieldMeta of node.selection.fields.values()) {
if (!fieldMeta.isRelation || !fieldMeta.nested) continue
const targetType = schema.getRelationTarget(node.entityType, fieldMeta.fieldName)
if (targetType !== undefined) {
nodes.push({ entityType: targetType, selection: fieldMeta.nested, parent: index })
}
}
}
return nodes
}

function findRepeatedTypes(nodes: readonly SelectionNode[]): ReadonlySet<string> {
const seenTypes = new Set<string>()
const repeatedTypes = new Set<string>()
for (const node of nodes) {
if (seenTypes.has(node.entityType)) {
repeatedTypes.add(node.entityType)
}
seenTypes.add(node.entityType)
}
return repeatedTypes
}

/**
* The selections on a path from the root to a repeated type. The climb tracks
* tree positions, not selection objects: a reused fragment is one selection
* object at several positions, and each position has ancestors of its own.
*/
function findNodesToWalk(nodes: readonly SelectionNode[], repeatedTypes: ReadonlySet<string>): ReadonlySet<SelectionMeta> {
const nodesToWalk = new Set<SelectionMeta>()
const climbed = new Set<number>()
nodes.forEach((node, index) => {
if (!repeatedTypes.has(node.entityType)) return
for (let position: number | null = index; position !== null && !climbed.has(position); position = nodes[position]!.parent) {
climbed.add(position)
nodesToWalk.add(nodes[position]!.selection)
}
})
return nodesToWalk
}

function addOccurrence(occurrencesByType: OccurrencesByType, entityType: string, entity: EntityRecord): void {
const id = entity['id']
if (typeof id !== 'string' && typeof id !== 'number') return
let occurrencesById = occurrencesByType.get(entityType)
if (!occurrencesById) {
occurrencesById = new Map()
occurrencesByType.set(entityType, occurrencesById)
}
const known = occurrencesById.get(id)
if (known === undefined) {
occurrencesById.set(id, entity)
} else if (Array.isArray(known)) {
known.push(entity)
} else if (known !== entity) {
occurrencesById.set(id, [known, entity])
}
}

/** The entity records a relation value holds: a has-one object, or a has-many array or connection (`{ edges: [{ node }] }`). */
function entitiesOf(value: unknown, isList: boolean): readonly EntityRecord[] {
if (Array.isArray(value)) {
return value.filter(isRecord)
}
if (!isRecord(value)) {
return []
}
if (!isList) {
return [value]
}
const edges = value['edges']
return Array.isArray(edges) ? edges.map(edge => (isRecord(edge) ? edge['node'] : undefined)).filter(isRecord) : []
}

function isRecord(value: unknown): value is EntityRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
21 changes: 21 additions & 0 deletions packages/bindx/src/store/SnapshotStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EntitySnapshot, LoadStatus } from './snapshots.js'
import { createEntitySnapshot } from './snapshots.js'
import type { FieldError, FieldErrorFilter } from '../errors/types.js'
import type { SelectionMeta } from '../selection/types.js'
import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult } from './SubscriptionManager.js'
import { ErrorStore } from './ErrorStore.js'
import {
Expand All @@ -21,6 +22,7 @@ import { generateTempId } from './entityId.js'
import { DirtyTracker } from './DirtyTracker.js'
import { EntitySnapshotStore } from './EntitySnapshotStore.js'
import { RootRegistry } from './RootRegistry.js'
import { ResponseOccurrenceIndex, type RelationTargetResolver } from './ResponseOccurrenceIndex.js'
import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js'
import { RekeyOrchestrator } from './RekeyOrchestrator.js'
import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
Expand Down Expand Up @@ -92,6 +94,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
*/
private readonly lastPropagatedData = new Map<string, unknown>()

private readonly responseOccurrences = new ResponseOccurrenceIndex()

/**
* Optional write-journal. When set (by an attached UndoManager), mutating
* methods record editable-layer pre-images of the cells they touch so a gesture
Expand Down Expand Up @@ -318,6 +322,23 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
return newSnapshot as EntitySnapshot<T>
}

/**
* Unites the occurrences of each entity within one server response, so the
* narrower of two occurrences cannot replace fields the wider one carried.
* Call it before any of the response is written. See {@link ResponseOccurrenceIndex}.
*/
indexServerResponse(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void {
this.responseOccurrences.index(entityType, data, selection, schema)
}

/**
* What to write for an entity occurrence of a server response: the union of
* its occurrences in that response, or the occurrence itself.
*/
resolveServerOccurrence(occurrence: Record<string, unknown>): Record<string, unknown> {
return this.responseOccurrences.resolve(occurrence)
}

/**
* Refreshes server data from a revalidation read while preserving the user's
* local dirty edits. See {@link EntitySnapshotStore.refreshServerData}.
Expand Down
Loading
Loading