fix: nested entity persistence, subscription propagation and temp ID rekey#20
Merged
fix: nested entity persistence, subscription propagation and temp ID rekey#20
Conversation
Reviews added via store.addToHasMany to a Round nested inside an Approval create are not included in the mutation because collectHasManyOperations uses processNestedData instead of collectCreateData for nested entities.
collectHasManyOperations was using processNestedData for created entities, which only processed raw snapshot data and ignored store-tracked hasMany relations. This caused deeply nested hasMany items (e.g. Round → Reviews) to be missing from mutations when the parent was itself nested in another create.
…ter persist - MutationCollector now materializes embedded hasMany/hasOne data into proper store entities during collectCreateData, enabling tracking and post-persist ID mapping - Added connectExistingToHasMany to store for connecting existing entities without marking them as created - Extended TransactionMutationResult with nestedResults for adapters to return server-assigned IDs for inline-created entities - BatchPersister now commits nested entities after successful persist and processes nestedResults for ID mapping
Build recursive GraphQL node selection from mutation data so Contember
returns server-assigned IDs for all inline-created entities. The
BatchPersister extracts these IDs from the node response and maps
temp IDs to persisted IDs via nestedResults.
- Add buildNodeSelectionFromMutationData() that traverses mutation
data structure to build node { id, relation { id, ... } } selection
- ContemberAdapter.persist() now includes node selection and returns
node data with nested entity IDs
- ContemberAdapter.create() enhanced with recursive node selection
- BatchPersister.extractNestedResultsFromNode() walks mutation data
and node response together to match temp IDs to server IDs
- Extend PersistResult with data field for node response
- Add test for sequential fallback path with node data extraction
…D rekey After persistAll() maps temp IDs to server-assigned UUIDs via mapTempIdToPersistedId, React components subscribed to the old temp key never received notifications — the entity data moved to the new key but subscribers stayed on the old one. SnapshotStore changes: - Add rekeyedEntities map so getEntityKey/getRelationKey transparently resolve temp IDs to persisted keys for all store operations - mapTempIdToPersistedId now rekeys entity snapshots, metadata, relations, errors, touched state, and subscriptions in one atomic operation SubscriptionManager changes: - Add rekeyedKeys map so unsubscribe closures (which capture the original key) can find migrated callbacks after rekey - rekey() now migrates relation subscribers by prefix in addition to entity subscribers - rekey() updates childToParents both as child entries and parent references, preserving the full propagation chain Sub-store rekey methods added to EntityMetaStore, EntitySnapshotStore, ErrorStore, RelationStore, and TouchedStore. Tests cover: 3-level deep parent-child propagation after rekey, useSyncExternalStore unsub/resub pattern, relation subscriber migration, re-registration via temp IDs, and reverse-order rekey.
Allows creating a new entity and connecting it to a hasOne relation in
one call instead of using the low-level store.createEntity + $connect
pattern.
// before
const id = store.createEntity('Author', { name: 'Jane' })
article.author.$connect(id)
// after
article.author.$create({ name: 'Jane' })
The method creates the entity, dispatches CONNECT_RELATION, registers
parent-child for change propagation, and returns the temp ID.
Added to HasOneRefInterface so it's available on both Ref and Accessor
types. JSX collector proxy updated with a noop stub.
registerParentChild (which sets up the subscription propagation chain) was previously only called from high-level handle methods. Anyone using the dispatcher directly or the low-level store API would not get change propagation from child to parent entities. Now ActionDispatcher.execute registers parent-child for: - ADD_TO_LIST — new entity added to hasMany - CONNECT_TO_LIST — existing entity connected to hasMany - CONNECT_RELATION — entity connected to hasOne Added targetType to ConnectRelationAction and ConnectToListAction interfaces so the dispatcher knows the child entity type. Removed redundant registerParentChild calls from HasManyListHandle.add(), HasManyListHandle.connect(), and HasOneHandle.create() since the dispatcher now handles it.
Use bracket notation for index signature access and add non-null assertions for array element access to satisfy noUncheckedIndexedAccess.
When extracting nested entity IDs from mutation node responses, the matching between create operations and response items relies on positional order. This is not formally guaranteed by the Contember API — if the server returns fewer items (partial failure, ACL filtering) or reorders them, position-based matching produces wrong temp→persisted ID mappings. Now we only match when counts are equal. When they disagree, we skip matching entirely — unmatched entities are still committed via commitUnresolvedNestedEntities, they just keep their temp IDs until the next fetch.
Verifies that when the adapter returns fewer new items than create operations (e.g. ACL filtering), position-based ID matching is skipped. Entities are still committed via commitUnresolvedNestedEntities but keep their temp IDs — no wrong mappings are produced.
The rekey() method collapses redirect chains (A→C instead of A→B→C), so resolveKey should always resolve in one hop. The loop with a depth limit is a safety net — if chain collapsing ever has a bug, this prevents an infinite loop instead of silently hanging.
Replace position-based matching of nested hasMany create operations with content-based matching, following the legacy binding's TreeAugmenter.isEntityMatching approach. The Contember API does not guarantee hasMany item ordering in the mutation node response. Position-based matching could produce wrong temp→persisted ID mappings when items are reordered. Changes: - buildNodeSelectionFromMutationData now requests scalar fields from create data in the node selection (not just id), providing the data needed for content comparison - extractNestedResultsFromNode matches create operations to response items by comparing scalar values and hasOne relation IDs - Partial matches work correctly: when some creates are missing from the response, the matched ones get ID mapping and the rest keep their temp IDs
- Replace inline dynamic imports with proper type import of GraphQlSelectionSet - Merge buildSelectionFromHasOneOp/buildSelectionFromHasManyOps into buildSelectionFromCreateOrUpdate/buildSelectionFromOps - Extract makeResult helper in extractNestedResultsFromNode to reduce duplication between hasMany and hasOne branches - Simplify isCreateDataMatchingNode by collapsing branches
5 tasks
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.
Summary
Fixes a set of related bugs where creating nested entities (e.g. Program → Approval → Round → Review) via inline creates, persisting them, and then modifying them would either generate incorrect mutations or fail to update the UI.
The changes span three areas: mutation generation for deeply nested entities, temp-to-persisted ID remapping after persist, and subscription/parent-child propagation so React components re-render correctly.
Changes
1. Nested hasMany mutation generation
Problem:
MutationCollectorusedprocessNestedDatafor hasMany items nested inside a parent create, which only looked at raw embedded data in the entity snapshot. Items added viastore.addToHasMany(tracked inRelationStore) were ignored — the generated mutation was missing nested entities.MutationCollectornow usescollectCreateDatafor nested hasMany entities, which properly checks both embedded data and store-managed hasMany state.reviews: [{ reviewType: 'expert' }]) is materialized into tracked store entities with temp IDs during mutation building viamaterializeEmbeddedHasMany/materializeEmbeddedHasOne, enabling proper post-persist ID mapping and commit.2. Nested entity ID mapping from persist responses
Problem: After
persistAll(), inline-created nested entities got server-assigned UUIDs, but the store had no way to map the temp IDs to persisted IDs. Subsequent modifications would generate mutations with temp IDs that the server didn't recognize.BatchPersister.commitNestedResultsprocessesnestedResultsfrom the adapter response, committing nested entities and callingmapTempIdToPersistedIdfor each.ContemberAdapterextracts nested entity IDs from GraphQL mutation responsenodedata.3. Subscription and parent-child migration on temp→persisted ID rekey
Problem: After
mapTempIdToPersistedId, entity data moved from keyEntityType:__temp_xxxtoEntityType:real-uuid, but React components (viauseSyncExternalStore) were still subscribed to the old key. Notifications on the new key reached nobody — UI appeared frozen until page refresh.SnapshotStoreaddsrekeyedEntitiesmap sogetEntityKey/getRelationKeytransparently resolve temp IDs to persisted keys for all store operations (subscribe, read, write).mapTempIdToPersistedIdnow atomically rekeys entity snapshots, metadata, relations, errors, touched state, and subscriptions.SubscriptionManageraddsrekeyedKeysmap so unsubscribe closures (which capture the original key at subscribe time) correctly find and remove migrated callbacks. Without this,useSyncExternalStore's unsub/resub cycle would leak the old callback.SubscriptionManager.rekey()migrates entity subscribers, relation subscribers (by prefix), andchildToParentsentries (both as child and parent references), preserving the full propagation chain across arbitrary nesting depths.rekey()methods added toEntityMetaStore,EntitySnapshotStore,ErrorStore,RelationStore,TouchedStore.4.
$create()method on HasOne handleProblem: Creating and connecting a new entity to a hasOne relation required the low-level
store.createEntity()+$connect()pattern.HasOneHandle.$create(data?)creates a new entity of the target type and connects it in one call, returning the temp ID. Consistent withHasManyListHandle.add().5. Parent-child registration in ActionDispatcher
Problem:
registerParentChild(which sets up subscription propagation from child to parent) was only called from high-level handle methods. Dispatching actions directly or using the low-level store API would not set up the propagation chain.ActionDispatcher.executenow callsregisterParentChildforADD_TO_LIST,CONNECT_TO_LIST, andCONNECT_RELATIONactions.targetTypefield toConnectRelationActionandConnectToListActioninterfaces.registerParentChildcalls fromHasManyListHandle.add(),HasManyListHandle.connect(), andHasOneHandle.$create().Test plan
bun test— 1412 pass, 9 pre-existing failures (8 browser tests needing running server, 1 unrelated formRelations test)bun run typecheck— cleannestedHasManyCreate.test.ts,subscriptionRekey.test.ts,actionDispatcher.test.ts,hasOneHandle.test.ts