Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b433c80
perf(bindx): memoize reachability walk via sub-store mutation counters
matej21 Jun 22, 2026
80016d4
test(bindx): cover reachability memoization cache hit/invalidation
matej21 Jun 22, 2026
f3a1939
refactor(bindx): centralize temp-id rekey in RekeyOrchestrator
matej21 Jun 22, 2026
010c7b7
test(bindx): cover RekeyOrchestrator resolution and ordering contract
matej21 Jun 22, 2026
ea009e0
feat(bindx): add pessimistic presentation primitive (inert)
matej21 Jun 22, 2026
1fe2a66
test(bindx): cover the pessimistic presentation primitive
matej21 Jun 22, 2026
241eb4a
refactor(bindx): route handle display reads through presentation
matej21 Jun 22, 2026
311d56c
refactor(bindx): delete pessimistic mutate-restore dance
matej21 Jun 22, 2026
feb12f6
test(bindx): pin parent-notification behavior before childToParents r…
matej21 Jun 22, 2026
53f23e2
refactor(bindx): make RelationStore authoritative for has-one
matej21 Jun 22, 2026
6ec3260
refactor(bindx): derive parent notification from relation edges; remo…
matej21 Jun 22, 2026
b205841
refactor(bindx): collapse has-many planned-addition sets into one map
matej21 Jun 22, 2026
b667a18
refactor(bindx): decompose RelationStore into composed HasOneStore + …
matej21 Jun 22, 2026
d1b837a
fix(bindx): make has-one refetch baseline advance non-notifying
matej21 Jun 23, 2026
3827de0
fix(bindx): guard connectExistingToHasMany against duplicate ordered id
matej21 Jun 23, 2026
74c501b
test(bindx): pin reachability cache invalidation for has-one edges
matej21 Jun 23, 2026
544ba20
docs(bindx): document bare-id contract of getParentKeysForChild
matej21 Jun 23, 2026
8ec7582
perf(bindx): back relation queries with a bidirectional edge index
matej21 Jun 23, 2026
0c662bb
test(bindx): cover the relation edge index
matej21 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bindx/src/core/ActionDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ export class ActionDispatcher {
action.entityType,
action.entityId,
action.isPersisting,
action.pessimistic ?? false,
)
break

Expand Down
9 changes: 8 additions & 1 deletion packages/bindx/src/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ export interface SetPersistingAction {
readonly entityType: string
readonly entityId: string
readonly isPersisting: boolean
/**
* Whether this persist is pessimistic. While a pessimistic persist is
* in-flight, handles present the server baseline instead of the (still dirty)
* local data. Ignored when isPersisting is false.
*/
readonly pessimistic?: boolean
}

// ==================== Error Actions ====================
Expand Down Expand Up @@ -428,8 +434,9 @@ export function setPersisting(
entityType: string,
entityId: string,
isPersisting: boolean,
pessimistic: boolean = false,
): SetPersistingAction {
return { type: 'SET_PERSISTING', entityType, entityId, isPersisting }
return { type: 'SET_PERSISTING', entityType, entityId, isPersisting, pessimistic }
}

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/bindx/src/handles/BaseHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,25 @@ export abstract class EntityRelatedHandle extends BaseHandle {
}

/**
* Get entity data.
* Get the CANONICAL entity data — the live, possibly-dirty values. Use this
* for dirty tracking and any logic that must reflect the user's edits, even
* while a pessimistic persist is in-flight. For values to DISPLAY, use
* {@link getPresentationData}.
*/
protected getEntityData(): Record<string, unknown> | undefined {
const snapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
return snapshot?.data as Record<string, unknown> | undefined
}

/**
* Get the entity data a consumer should DISPLAY. Equals {@link getEntityData}
* except while the entity is pessimistically in-flight, when it returns the
* server baseline (the canonical data stays dirty underneath).
*/
protected getPresentationData(): Record<string, unknown> | undefined {
return this.store.getPresentationSnapshot<Record<string, unknown>>(this.entityType, this.entityId)?.data
}

/**
* Get entity server data.
*/
Expand Down
7 changes: 5 additions & 2 deletions packages/bindx/src/handles/EntityHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,14 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
}

/**
* Gets the current entity data.
* Gets the current entity data to DISPLAY.
* Returns selected fields subset as specified by TSelected type parameter.
* While a pessimistic persist is in-flight this is the server baseline; the
* canonical snapshot (see {@link getSnapshot}, used for dirty tracking) stays
* dirty underneath.
*/
get data(): TSelected | null {
const snapshot = this.getSnapshot()
const snapshot = this.store.getPresentationSnapshot<T>(this.entityType, this.entityId)
return (snapshot?.data ?? null) as TSelected | null
}

Expand Down
13 changes: 9 additions & 4 deletions packages/bindx/src/handles/FieldHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
}

/**
* Gets the current field value.
* Gets the current field value to DISPLAY. While a pessimistic persist is
* in-flight this is the server baseline; otherwise it is the live value.
*/
get value(): T | null {
const data = this.getEntityData()
const data = this.getPresentationData()
if (!data) return null
return getNestedValue(data, this.fieldPath) as T | null
}
Expand All @@ -105,10 +106,14 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
}

/**
* Checks if the field has been modified.
* Checks if the field has been modified. Compares the CANONICAL value against
* the server value — never the presented value, so a field stays correctly
* dirty even while its display shows the server baseline mid-pessimistic-persist.
*/
get isDirty(): boolean {
return !deepEqual(this.value, this.serverValue)
const data = this.getEntityData()
const value = data ? (getNestedValue(data, this.fieldPath) as T | null) : null
return !deepEqual(value, this.serverValue)
}

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/bindx/src/handles/HasManyListHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt

return (
state.plannedRemovals.size > 0 ||
state.plannedConnections.size > 0 ||
state.createdEntities.size > 0 ||
state.plannedAdditions.size > 0 ||
state.orderedIds !== null
)
}
Expand Down
147 changes: 117 additions & 30 deletions packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js'
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
import type { SnapshotStore } from '../store/SnapshotStore.js'
import type { StoredRelationState } from '../store/RelationStore.js'
import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
import type { SelectionMeta } from '../selection/types.js'
import {
Expand All @@ -17,6 +18,7 @@ import {
type Unsubscribe,
type EntityAccessor,
type HasOneAccessor,
type HasOneRelationState,
} from './types.js'
import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js'
import type {
Expand Down Expand Up @@ -137,51 +139,129 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>

/**
* Gets the relation state.
* Falls back to snapshot data when no explicit relation state exists,
* so server-loaded has-one relations report 'connected' without
* requiring a prior RelationStore entry.
* Materializes the RelationStore entry from embedded snapshot data first, so a
* server-loaded has-one reports 'connected' without a prior explicit entry.
*/
get state(): 'connected' | 'disconnected' | 'deleted' | 'creating' {
get state(): HasOneRelationState {
this.ensureEntry()
const relation = this.store.getRelation(
this.entityType,
this.entityId,
this.fieldName,
)
if (relation) {
return relation.state
}
// No explicit relation state — check if snapshot has embedded data
if (this.relatedId !== null) {
return 'connected'
}
return 'disconnected'
return relation?.state ?? 'disconnected'
}

/**
* Gets the current related entity ID.
* Falls back to entity data if relation state is not initialized.
* Gets the current related entity ID — read exclusively from the RelationStore
* after materializing the entry from embedded snapshot data.
*/
get relatedId(): string | null {
// First check relation state (for manual changes like connect/disconnect)
this.ensureEntry()
const relation = this.store.getRelation(
this.entityType,
this.entityId,
this.fieldName,
)
if (relation) {
return relation.currentId
return relation?.currentId ?? null
}

/**
* Materializes this has-one's RelationStore entry from the parent's embedded
* snapshot data, so the store is the single source of truth (symmetric with
* {@link HasManyListHandle.materializeEmbeddedItems}).
*
* Only the relation entry is touched here — child-snapshot propagation stays in
* {@link ensureRelatedEntitySnapshot}, which owns the per-relation propagation
* slot so the two paths never double-consume it.
*
* - No entry yet + the parent embeds a related object with an id → create a
* `connected` entry (non-notifying) whose server baseline comes from the
* parent's serverData, so a freshly loaded relation is not dirty
* (currentId === serverId, state === serverState).
* - Existing entry + parent re-fetch (embedded reference changed) that is NOT
* locally dirty → advance the server baseline to the new related id.
* - A local connect/disconnect, a placeholder, or a `creating` entry is left
* untouched — it is detected as a locally-dirty relation.
* - No embedded data and no entry → the relation stays unmaterialized (null).
*/
private ensureEntry(): void {
const existing = this.store.getRelation(this.entityType, this.entityId, this.fieldName)
const embeddedId = this.readEmbeddedRelatedId()

if (!existing) {
if (embeddedId === null) return
const serverId = this.readServerRelatedId()
this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, {
currentId: embeddedId,
serverId,
state: 'connected',
serverState: serverId !== null ? 'connected' : 'disconnected',
placeholderData: {},
})
return
}

// Fallback to entity snapshot data (for server-loaded data)
const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
if (parentSnapshot?.data) {
const relatedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
return (relatedData as { id: string }).id
}
this.advanceServerBaselineOnRefetch(existing, embeddedId)
}

/**
* On a parent re-fetch (embedded reference changed) advances the relation's
* server baseline to the embedded related id, but only when the relation is
* not locally dirty — a local connect/disconnect/create must survive a re-fetch.
*
* Does NOT consume the propagation slot — {@link ensureRelatedEntitySnapshot}
* owns it. The same reference-change signal drives both, so both run within the
* one render that observes a new parent reference.
*
* The baseline write is NON-NOTIFYING: it runs during a render-phase read, and
* the parent re-fetch that produced the new embedded reference already notified
* subscribers. Notifying again here would mutate the store and synchronously call
* subscribers mid-render, violating the external-store contract (cf.
* {@link ensureRelatedEntitySnapshot}, which refreshes server data with skipNotify
* for the same reason).
*/
private advanceServerBaselineOnRefetch(
existing: StoredRelationState,
embeddedId: string | null,
): void {
if (embeddedId === null || existing.serverId === embeddedId) return
if (this.isLocallyDirty(existing)) return

const embeddedData = this.readEmbeddedRelatedData()
if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) {
return
}

return null
this.store.setRelation(this.entityType, this.entityId, this.fieldName, {
currentId: embeddedId,
serverId: embeddedId,
state: 'connected',
serverState: 'connected',
}, true)
}

private isLocallyDirty(relation: StoredRelationState): boolean {
return (
relation.currentId !== relation.serverId ||
relation.state !== relation.serverState ||
Object.keys(relation.placeholderData).length > 0
)
}

/** Reads the embedded related object from the parent's canonical current data. */
private readEmbeddedRelatedData(): unknown {
return this.getEntityData()?.[this.fieldName]
}

/** Extracts the related id from the parent's embedded current data, or null. */
private readEmbeddedRelatedId(): string | null {
return extractRelatedId(this.readEmbeddedRelatedData())
}

/** Extracts the related id from the parent's embedded server data, or null. */
private readServerRelatedId(): string | null {
return extractRelatedId(this.getServerData()?.[this.fieldName])
}

/**
Expand Down Expand Up @@ -354,18 +434,15 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
* Checks if the relation is dirty.
*/
get isDirty(): boolean {
this.ensureEntry()
const relation = this.store.getRelation(
this.entityType,
this.entityId,
this.fieldName,
)
if (!relation) return false

return (
relation.currentId !== relation.serverId ||
relation.state !== relation.serverState ||
Object.keys(relation.placeholderData).length > 0
)
return this.isLocallyDirty(relation)
}

/**
Expand Down Expand Up @@ -593,3 +670,13 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
}

}

/**
* Extracts the related entity id from an embedded has-one object, or null when
* the value is absent / not an object with a string id.
*/
function extractRelatedId(embedded: unknown): string | null {
if (!embedded || typeof embedded !== 'object' || !('id' in embedded)) return null
const id = embedded.id
return typeof id === 'string' ? id : null
}
Loading