Skip to content

fix: nested entity persistence, subscription propagation and temp ID rekey#20

Merged
vparys merged 14 commits intomainfrom
fix/store-subscription-propagation
Apr 13, 2026
Merged

fix: nested entity persistence, subscription propagation and temp ID rekey#20
vparys merged 14 commits intomainfrom
fix/store-subscription-propagation

Conversation

@vparys
Copy link
Copy Markdown
Member

@vparys vparys commented Apr 13, 2026

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: MutationCollector used processNestedData for hasMany items nested inside a parent create, which only looked at raw embedded data in the entity snapshot. Items added via store.addToHasMany (tracked in RelationStore) were ignored — the generated mutation was missing nested entities.

  • MutationCollector now uses collectCreateData for nested hasMany entities, which properly checks both embedded data and store-managed hasMany state.
  • Embedded relation data (e.g. reviews: [{ reviewType: 'expert' }]) is materialized into tracked store entities with temp IDs during mutation building via materializeEmbeddedHasMany/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.commitNestedResults processes nestedResults from the adapter response, committing nested entities and calling mapTempIdToPersistedId for each.
  • ContemberAdapter extracts nested entity IDs from GraphQL mutation response node data.
  • Sequential persist fallback path also extracts nested IDs from response data.

3. Subscription and parent-child migration on temp→persisted ID rekey

Problem: After mapTempIdToPersistedId, entity data moved from key EntityType:__temp_xxx to EntityType:real-uuid, but React components (via useSyncExternalStore) were still subscribed to the old key. Notifications on the new key reached nobody — UI appeared frozen until page refresh.

  • SnapshotStore adds rekeyedEntities map so getEntityKey/getRelationKey transparently resolve temp IDs to persisted keys for all store operations (subscribe, read, write).
  • mapTempIdToPersistedId now atomically rekeys entity snapshots, metadata, relations, errors, touched state, and subscriptions.
  • SubscriptionManager adds rekeyedKeys map 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), and childToParents entries (both as child and parent references), preserving the full propagation chain across arbitrary nesting depths.
  • Sub-store rekey() methods added to EntityMetaStore, EntitySnapshotStore, ErrorStore, RelationStore, TouchedStore.

4. $create() method on HasOne handle

Problem: 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 with HasManyListHandle.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.execute now calls registerParentChild for ADD_TO_LIST, CONNECT_TO_LIST, and CONNECT_RELATION actions.
  • Added targetType field to ConnectRelationAction and ConnectToListAction interfaces.
  • Removed redundant registerParentChild calls from HasManyListHandle.add(), HasManyListHandle.connect(), and HasOneHandle.$create().

Test plan

  • bun test — 1412 pass, 9 pre-existing failures (8 browser tests needing running server, 1 unrelated formRelations test)
  • bun run typecheck — clean
  • 25+ new tests across nestedHasManyCreate.test.ts, subscriptionRekey.test.ts, actionDispatcher.test.ts, hasOneHandle.test.ts
  • Manual verification: create nested entities via UI → persist → modify → persist again → UI updates without page refresh

vparys added 14 commits April 13, 2026 11:58
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
@vparys vparys merged commit 0cb96a8 into main Apr 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant