fix(bindx): emit the entity persist lifecycle events (#28) - #74
Closed
matej21 wants to merge 2 commits into
Closed
Conversation
Both the EntityHandle.intercept('entity:persisting', ...) interceptor
and the useOnEntityEvent('entity:persisted', ...) hook are publicly
advertised (the hook's own JSDoc literally documents the persisted
example), but never fire at runtime. The events/eventFactory.ts
createBeforeEvent / createAfterEvent switch statements have no case
for SET_PERSISTING (the action BatchPersister dispatches around the
mutation), so the EventEmitter never receives a before/after event
to fan out.
The two new tests assert the canonical advertised usage and currently
fail with `Received length: 0`. The mutation itself succeeds — the
mock store reflects the new value — proving the gap is purely in the
event factory, not in the persist pipeline.
Same root cause exists for entity:persistFailed (failure path) and
entity:deleting / entity:deleted (DELETE_ENTITY action). Adding
failure injection to MockAdapter is out of scope here; mirroring the
fix once the persisting/persisted path lands is a one-line addition
in the BatchPersister catch branch and the eventFactory switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
entity:persisting, entity:persisted and entity:persistFailed were declared, registrable through EntityHandle.intercept / useOnEntityEvent, documented in the hook's own JSDoc — and never fired. BatchPersister dispatches SET_PERSISTING around each persist; that flows through ActionDispatcher into events/eventFactory.ts, whose switches have no case for it, so nothing was ever emitted. Consumers registering before-save normalisation or after-save invalidation silently did nothing. BlockEditor's orphaned-reference cleanup, registered via useEntityBeforePersist, is a victim inside this repo. The events are now emitted from BatchPersister rather than the action factory. persistedId is only known after the server response, and the persist lifecycle is multi-step — the factory mapping suits simple state changes, not this. eventFactory.ts is deliberately untouched, so there is no double emission. entity:persisting runs through the interceptor pipeline before mutations are built, so a hook's store writes land in the same save. Cancellation follows the ActionDispatcher precedent — a null from an interceptor vetoes that one entity, the rest of the batch proceeds, and the vetoed entity stays dirty and retryable, counted as skipped rather than failed. markInFlight now claims the batch before the interceptor await, so the re-entrancy guard still holds across the new suspension point. EventEmitter gains hasInterceptors() so a persist with no hooks registered skips the async pipeline entirely and keeps its original synchronous timing. Delete entries in a persist batch also get persisting/persisted. That is the persist lifecycle; entity:deleting / entity:deleted remain unemitted and are a separate unit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
matej21
force-pushed
the
fix/entity-persist-events
branch
from
August 20, 2026 09:34
5fc40f8 to
800884a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #28.
Problem
entity:persisting,entity:persistedandentity:persistFailedare declared in the event types, registrable viaEntityHandle.intercept(...)/useOnEntityEvent(...), and documented in the hook's own JSDoc — and never fired.BatchPersisterdispatchessetPersisting(...)around each persist; that flows throughActionDispatcherintoevents/eventFactory.ts, whosecreateBeforeEvent/createAfterEventswitches have no case forSET_PERSISTING, so they returnnulland nothing is emitted.It fails silently: registration succeeds, no warning, the side effect just never happens. There is a victim inside this repo —
packages/bindx-editor/src/components/BlockEditor.tsxregisters its orphaned-reference cleanup withuseEntityBeforePersist(...)→useInterceptEntity('entity:persisting', …), so removing a reference-backed block and saving leaves the reference row behind.Approach
Emitted from
BatchPersister, not from the action factory — option B in the issue.persistedIdis only known after the server response, and the persist lifecycle is multi-step, while the factory mapping suits simple state changes.eventFactory.tsis deliberately untouched, so there is no double emission.entity:persistingsortByDependencies+markInFlight, beforebuildMutationsentity:persisted/entity:persistFailedstore.isPersisting === falserather than mid-flight stateentity:persistFailed(throw path)catch, error rethrown unchangedentity:persistedcarries the server id for creates (persistedId ?? entityId) andisNew.Cancellation
A
nullfromrunInterceptorsvetoes that one entity; the rest of the batch proceeds. This follows the existing precedent inActionDispatcher.dispatch, where anullfrom an interceptor cancels that action. The vetoed entity is cleared from in-flight, never has its persisting flag set, stays dirty and retryable, and is counted inskippedCount(notfailedCount) with an explanatory result. Batchsuccessbecomesfalse, preservingsuccess === true ⇔ every result ok. Noentity:persisted/entity:persistFailedis emitted for it.Two structural changes worth calling out
markInFlightmoved earlier, before the interceptorawait. The hook pipeline introduces a suspension point intopersistScope, so the batch must be claimed before it or the concurrent-persist re-entrancy guard no longer holds.EventEmitter.hasInterceptors(...)added.runInterceptorsis async, so awaiting it unconditionally inserts a microtask before the adapter call — which brokededuplication.test.ts > should skip entities already in-flight when called concurrently, a test that relies on the transaction being invoked in the same synchronous turn. Rather than weaken that test, a persist with no hooks registered now skips the pipeline entirely and keeps its original timing byte for byte.Scope
entity:deleting/entity:deletedremain unemitted — the issue notes the same factory gap forDELETE_ENTITY, and it is a separate unit. Note that entities withchangeType: 'delete'inside a persist batch do getpersisting/persistedwithisNew: false; that is the persist lifecycle, not the delete lifecycle.Known gaps (not fixed here)
entity:persistingbut noentity:persisted. An entity created inline inside a parent's mutation is in the batch, but its standalone mutation is filtered out, so it has no entry in the transaction results. Closing this means emitting fromcommitNestedResults/commitUnresolvedNestedEntities, and the latter has no server id at all.entity:persisting, so a normalisation hook cannot clear a client error to unblock its own save.DirtyEntity.dirtyFields/dirtyRelationsare captured before the hooks run and not refreshed. Field and relation data still land correctly (collectors read the store fresh), butassertNoRelationChangesuses the stale list, so a hook that dirties a relation in a collector-less setup is silently dropped instead of throwing.Tests
The reporter's reproducer is cherry-picked with their authorship and left unmodified — it flips from failing to passing. Added: a hook whose store write lands in the same persist (the BlockEditor scenario, pinning the ordering requirement), an entity-scoped interceptor firing via the
useInterceptEntitypath,entity:persistedcarrying the server id withisNew: truefor a create,entity:persistFailedwith the error, and a cancelling interceptor excluding one entity while a sibling still persists.Verified:
tests/unit/persistence+tests/cases134 pass,tests/events.test.tsx16 pass, full CI suite 1558 pass / 0 fail, typecheck clean.Not verified
All tests use in-memory adapters and the sequential fallback path (
MockAdapterhas nopersistTransaction). The BlockEditor cleanup is inferred rather than observed — the browser tests that would exercise it need a live playground.🤖 Generated with Claude Code
https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee