Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
987bc5a
feat(bindx): reachability-based create detection (replaces eager orph…
matej21 Jun 22, 2026
e43a0e8
test(bindx): reachability create detection + adapt suite to new model
matej21 Jun 22, 2026
dfc1966
fix(bindx): address review findings — pessimistic partial-failure dat…
matej21 Jun 22, 2026
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
98b0e5e
fix(bindx): handle null has-one refetch presentation
matej21 Jul 1, 2026
98dfd5a
refactor(bindx): rebuild undo/redo as a write-journal over the decomp…
matej21 Jun 24, 2026
3abef4b
fix(bindx): isolate undo journal transactions
matej21 Jul 1, 2026
e236ded
fix(bindx): close undo journal entries over detached created subtrees
matej21 Jul 2, 2026
f5be781
fix(bindx): drop undo history when the store is cleared
matej21 Jul 2, 2026
698ef62
feat(bindx): guard undo journal completeness with editable-write coun…
matej21 Jul 2, 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
20 changes: 20 additions & 0 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,26 @@ export function useEntityList(
[store],
)

// Discard never-persisted drafts when the list unmounts. List-level creates are
// top-level roots (nothing else anchors them); dropping the root and running the
// reachability-aware sweep frees a truly-orphaned draft while preserving one that
// was meanwhile connected into another live parent (the diamond / shared-create
// case). A persisted draft (rekeyed to a server id) is left untouched.
useEffect(() => {
return () => {
let unrooted = false
for (const item of listStateRef.current.items) {
if (isTempId(item.id) && !store.getPersistedId(entityType, item.id)) {
store.unregisterRootEntity(entityType, item.id)
unrooted = true
}
}
if (unrooted) {
store.sweepUnreachableCreated()
}
}
}, [store, entityType])

// --- Snapshot ---
const getSnapshot = useCallback((): UseEntityListResult<any> => {
const state = listStateRef.current
Expand Down
1 change: 0 additions & 1 deletion packages/bindx-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ export type {
// Undo types
UndoManagerConfig,
UndoState,
UndoEntry,
// Event types
BindxEvent,
FieldScopedEvent,
Expand Down
22 changes: 22 additions & 0 deletions packages/bindx-react/src/jsx/components/Entity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,28 @@ function EntityCreateMode({
return id
}, [entityType, store])

// Lifecycle of the draft entity:
// - Re-seed it under the SAME temp id if a previous unmount's cleanup removed
// it. This keeps the form bound across a React StrictMode mount→cleanup→mount
// cycle (effects run twice in dev), where the memoized id survives but the
// snapshot would otherwise be gone. No-op on first mount and after the entity
// was persisted (rekeyed to a server id).
// - On unmount, discard a never-persisted draft. Drop its root registration and
// let the reachability-aware sweep reclaim it ONLY if nothing else references
// it — a draft connected into another live parent stays reachable and survives
// (the diamond / shared-create case). A persisted entity is left untouched.
useEffect(() => {
if (!store.getPersistedId(entityType, tempId) && !store.hasEntity(entityType, tempId)) {
store.createEntity(entityType, { id: tempId })
}
return () => {
if (!store.getPersistedId(entityType, tempId)) {
store.unregisterRootEntity(entityType, tempId)
store.sweepUnreachableCreated()
}
}
}, [store, entityType, tempId])

// Subscribe to store changes for this entity
const subscribe = useCallback(
(callback: () => void) => store.subscribeToEntity(entityType, tempId, callback),
Expand Down
150 changes: 93 additions & 57 deletions packages/bindx/src/core/ActionDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,39 +42,46 @@ export class ActionDispatcher {
* Async interceptors are skipped with a warning — use dispatchAsync() for those.
*/
dispatch(action: Action): void {
// Run through middlewares first
for (const middleware of this.middlewares) {
const result = middleware(action, this.store)
if (result === false) {
// Middleware can cancel the action
return
// One dispatch = one undoable gesture. The store records every primary write
// made between begin/commit into a single journal entry (no-op without undo).
this.store.beginTransaction()
try {
// Run through middlewares first
for (const middleware of this.middlewares) {
const result = middleware(action, this.store)
if (result === false) {
// Middleware can cancel the action
return
}
}
}

// Create and run before event through interceptors (synchronously)
const beforeEvent = createBeforeEvent(action, this.store)
if (beforeEvent) {
const result = this.eventEmitter.runInterceptorsSync(beforeEvent)
if (result === null) {
// Interceptor cancelled the action
return
}
// If event was modified, update action accordingly
if (result !== beforeEvent) {
action = this.updateActionFromEvent(action, result)
// Create and run before event through interceptors (synchronously)
const beforeEvent = createBeforeEvent(action, this.store)
if (beforeEvent) {
const result = this.eventEmitter.runInterceptorsSync(beforeEvent)
if (result === null) {
// Interceptor cancelled the action
return
}
// If event was modified, update action accordingly
if (result !== beforeEvent) {
action = this.updateActionFromEvent(action, result)
}
}
}

// Capture state before execution (for after events)
const stateBefore = captureStateBeforeAction(action, this.store)
// Capture state before execution (for after events)
const stateBefore = captureStateBeforeAction(action, this.store)

// Execute the action
this.execute(action)
// Execute the action
this.execute(action)

// Emit after event
const afterEvent = createAfterEvent(action, stateBefore, this.store)
if (afterEvent) {
this.eventEmitter.emit(afterEvent)
// Emit after event
const afterEvent = createAfterEvent(action, stateBefore, this.store)
if (afterEvent) {
this.eventEmitter.emit(afterEvent)
}
} finally {
this.store.commitTransaction()
}
}

Expand Down Expand Up @@ -108,16 +115,21 @@ export class ActionDispatcher {
// Capture state before execution (for after events)
const stateBefore = captureStateBeforeAction(action, this.store)

// Execute the action
this.execute(action)
this.store.beginTransaction()
try {
// Execute the action
this.execute(action)

// Emit after event
const afterEvent = createAfterEvent(action, stateBefore, this.store)
if (afterEvent) {
this.eventEmitter.emit(afterEvent)
}
// Emit after event
const afterEvent = createAfterEvent(action, stateBefore, this.store)
if (afterEvent) {
this.eventEmitter.emit(afterEvent)
}

return true
return true
} finally {
this.store.commitTransaction()
}
}

/**
Expand Down Expand Up @@ -174,9 +186,26 @@ export class ActionDispatcher {
const index = this.middlewares.indexOf(middleware)
if (index !== -1) {
this.middlewares.splice(index, 1)
if (!this.middlewares.includes(middleware)) {
middleware.dispose?.()
}
}
}

/**
* Reverts a has-one relation to the disconnected state. Shared by
* DISCONNECT_RELATION and the never-persisted arm of DELETE_RELATION (deleting a
* never-created target just cancels its creation — there is no server row to
* delete), so the "disconnected" semantics live in one place.
*/
private disconnectRelation(entityType: string, entityId: string, fieldName: string): void {
this.store.setRelation(entityType, entityId, fieldName, {
currentId: null,
state: 'disconnected',
placeholderData: {},
})
}

/**
* Executes an action, updating the store.
*/
Expand Down Expand Up @@ -216,7 +245,7 @@ export class ActionDispatcher {
)
break

case 'CONNECT_RELATION':
case 'CONNECT_RELATION': {
this.store.setRelation(
action.entityType,
action.entityId,
Expand All @@ -228,30 +257,33 @@ export class ActionDispatcher {
)
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.targetId)
break
}

case 'DISCONNECT_RELATION':
this.store.setRelation(
action.entityType,
action.entityId,
action.fieldName,
{
currentId: null,
state: 'disconnected',
placeholderData: {},
},
)
case 'DISCONNECT_RELATION': {
this.disconnectRelation(action.entityType, action.entityId, action.fieldName)
break
}

case 'DELETE_RELATION':
this.store.setRelation(
action.entityType,
action.entityId,
action.fieldName,
{
state: 'deleted',
},
)
case 'DELETE_RELATION': {
const deletedId = this.store.getRelation(action.entityType, action.entityId, action.fieldName)?.currentId
// Deleting a never-persisted target just cancels its creation — there is
// no server row to delete. Revert the relation to 'disconnected' so the
// parent is not spuriously dirtied by a 'deleted' state; reachability then
// drops the orphaned create. A server target gets the normal 'deleted'.
if (deletedId && this.store.isNeverPersisted(deletedId)) {
this.disconnectRelation(action.entityType, action.entityId, action.fieldName)
} else {
this.store.setRelation(
action.entityType,
action.entityId,
action.fieldName,
{
state: 'deleted',
},
)
}
break
}

case 'RESET_RELATION':
this.store.resetRelation(
Expand Down Expand Up @@ -356,6 +388,7 @@ export class ActionDispatcher {
action.entityType,
action.entityId,
action.isPersisting,
action.pessimistic ?? false,
)
break

Expand Down Expand Up @@ -438,7 +471,10 @@ export class ActionDispatcher {
* Can inspect/modify actions before execution.
* Return false to cancel the action.
*/
export type ActionMiddleware = (action: Action, store: SnapshotStore) => boolean | void
export interface ActionMiddleware {
(action: Action, store: SnapshotStore): boolean | void
dispose?: () => void
}

/**
* Creates a logging middleware for debugging.
Expand Down
Loading