Summary
When several sibling create operations sit in one hasMany mutation and carry different nested relation shapes, the mutation's node selection is built from only the last sibling's nested shape, so the response cannot be content-matched back to the other siblings. Those nested creates keep their __temp_… IDs, yet commitUnresolvedNestedEntities still marks them existsOnServer = true — so the next edit is emitted as an update keyed by a temp ID, which the API rejects (Expected type "UUID", found "__temp_…"). The store is stuck in an unsaveable state until a full reload.
Environment
Reproduction
Schema: Page → blocks (hasMany) → button (hasOne) → link (hasOne). Two sibling blocks are created in one persist; their button creates carry different fields (modalTitle on one, a nested link on the other) — exactly what "duplicate this section" produces in a page builder, where unset fields are simply absent from create data.
// sibling 1 — button with a modal title, no link
const blockA = store.createEntity('Block', { order: 1, type: 'button' })
const buttonA = store.createEntity('Button', { label: 'Kontaktujte nás', modalTitle: 'Kontakt' })
// sibling 2 — plain link button, no modalTitle
const blockB = store.createEntity('Block', { order: 2, type: 'button' })
const buttonB = store.createEntity('Button', { label: 'Napište nám' })
const link = store.createEntity('Link', { type: 'external', externalTarget: 'mailto:info@example.com' })
// …relations wired, both blocks added to page-1.blocks…
await persister.persistAll() // ok: true
store.getPersistedId('Button', buttonA) // → null (expected: a server UUID)
store.existsOnServer('Button', buttonA) // → true
store.updateEntityFields('Button', buttonA, { label: 'Napište nám hned' })
await persister.persistAll()
// adapter receives: persist('Button', '__temp_5845872f-…', { label: 'Napište nám hned' })
The mock adapter in the test echoes exactly the fields the mutation node selection requested — the contract a real Contember API honours.
Expected behavior
- Every nested create in a successful mutation is reconciled to its server ID, regardless of how its siblings' nested payloads are shaped (the existing
content matching: reversed server response order maps IDs correctly test in tests/nestedHasManyCreate.test.ts establishes that reconciliation is meant to be robust).
- Failing that, an entity whose temp ID could not be resolved must never be usable as the target of an update. Emitting
persist(entityType, '__temp_…', …) cannot succeed against any backend — the store should keep the entity uncommitted (or invalidate it so a refetch replaces it) rather than commit it into a permanently unsaveable state.
Actual behavior
-
store.getPersistedId('Button', buttonA) returns null after a fully successful persist.
-
store.existsOnServer('Button', buttonA) returns true.
-
The next persist calls adapter.persist('Button', '__temp_5845872f-bba1-456c-b894-a8caa95fb53f', { label: … }). Against a real project this is updateButton(by: {id: "__temp_…"}) and the API answers HTTP 400:
Variable "$by" got invalid value "__temp_5845872f-bba1-456c-b894-a8caa95fb53f";
Expected type "UUID". Not a valid UUID
Every subsequent save of the whole page fails the same way until the page is reloaded.
Suspected root cause
Two separate defects compound.
1. The node selection drops nested relation shapes of all but the last sibling. packages/bindx-client/src/graphql/mutationFragments.ts → buildSelectionFromOps unions scalar fields across sibling ops but keeps nested relations in a Map keyed by field name:
for (const [key, value] of Object.entries(innerData)) {
if (value === null || value === undefined) continue
if (typeof value === 'object') {
nestedFields.set(key, value as Record<string, unknown>) // last sibling wins
} else {
scalarFields.add(key)
}
}
With sibling A's button.create = { label, modalTitle } and sibling B's button.create = { label, link: { create } }, only B's shape reaches buildSelectionFromCreateOrUpdate, so the emitted selection is button { id label link { id type externalTarget } } — no modalTitle.
2. Content matching then fails, and the failure is committed as success. packages/bindx/src/persistence/BatchPersister.ts → isCreateDataMatchingNode (line ~1135) compares every non-null value of the create payload against the node:
if (typeof value !== 'object') {
if (nodeItem[key] !== value) return false
}
modalTitle: 'Kontakt' vs. undefined → no match, so extractNestedResultsFromNode (line ~1102) skips sibling A entirely (if (idx < 0) continue) — and with it every nested create below A. commitUnresolvedNestedEntities (line ~986) then walks the collector's nested-entity map and does:
this.dispatcher.dispatch(commitEntity(entityType, tempId))
this.store.commitAllRelations(entityType, tempId)
this.store.setExistsOnServer(entityType, tempId, true)
without a paired mapTempIdToPersistedId, so the entity is flagged as server-resident while still keyed by its temp ID.
Suggested fix
- In
buildSelectionFromOps, merge nested relation shapes across sibling ops instead of overwriting them (deep-union the selections, or collect a list of nested payloads per field and union the selections they produce). This makes the response self-sufficient for content matching.
- In
commitUnresolvedNestedEntities, do not set existsOnServer for an entity whose temp ID was not mapped — or, if committing is required to keep the tree consistent, record the entity as needing a refetch and make the mutation collector refuse to emit an update for a __temp_ key (fail loudly at collection time rather than at the API). Emitting a mutation keyed by a temp ID is never correct.
We are not certain which of the two you want as the primary fix; the second one alone would already turn a silent, unrecoverable state into something a downstream app can detect.
Workaround shipped downstream
We applied a temporary workaround in our project, marked TODO [BindX] (<this-issue-url>): …. The workaround force-refetches the page-builder editor subtree after any persist that included a section duplication, so the store re-reads real UUIDs instead of keeping unresolved temp IDs; we will remove it once this issue is resolved.
Summary
When several sibling
createoperations sit in one hasMany mutation and carry different nested relation shapes, the mutation'snodeselection is built from only the last sibling's nested shape, so the response cannot be content-matched back to the other siblings. Those nested creates keep their__temp_…IDs, yetcommitUnresolvedNestedEntitiesstill marks themexistsOnServer = true— so the next edit is emitted as an update keyed by a temp ID, which the API rejects (Expected type "UUID", found "__temp_…"). The store is stuck in an unsaveable state until a full reload.Environment
@contember/bindx@0.1.46(version installed in the reporting project)contember/bindx@mainas of3c2fd0dtests/unit/persistence/nestedCreateTempIdLeak.test.tsbug/nested-create-temp-id-leaks-into-next-updateReproduction
Schema:
Page → blocks (hasMany) → button (hasOne) → link (hasOne). Two sibling blocks are created in one persist; theirbuttoncreates carry different fields (modalTitleon one, a nestedlinkon the other) — exactly what "duplicate this section" produces in a page builder, where unset fields are simply absent from create data.The mock adapter in the test echoes exactly the fields the mutation node selection requested — the contract a real Contember API honours.
Expected behavior
content matching: reversed server response order maps IDs correctlytest intests/nestedHasManyCreate.test.tsestablishes that reconciliation is meant to be robust).persist(entityType, '__temp_…', …)cannot succeed against any backend — the store should keep the entity uncommitted (or invalidate it so a refetch replaces it) rather than commit it into a permanently unsaveable state.Actual behavior
store.getPersistedId('Button', buttonA)returnsnullafter a fully successful persist.store.existsOnServer('Button', buttonA)returnstrue.The next persist calls
adapter.persist('Button', '__temp_5845872f-bba1-456c-b894-a8caa95fb53f', { label: … }). Against a real project this isupdateButton(by: {id: "__temp_…"})and the API answers HTTP 400:Every subsequent save of the whole page fails the same way until the page is reloaded.
Suspected root cause
Two separate defects compound.
1. The node selection drops nested relation shapes of all but the last sibling.
packages/bindx-client/src/graphql/mutationFragments.ts→buildSelectionFromOpsunions scalar fields across sibling ops but keeps nested relations in aMapkeyed by field name:With sibling A's
button.create = { label, modalTitle }and sibling B'sbutton.create = { label, link: { create } }, only B's shape reachesbuildSelectionFromCreateOrUpdate, so the emitted selection isbutton { id label link { id type externalTarget } }— nomodalTitle.2. Content matching then fails, and the failure is committed as success.
packages/bindx/src/persistence/BatchPersister.ts→isCreateDataMatchingNode(line ~1135) compares every non-null value of the create payload against the node:modalTitle: 'Kontakt'vs.undefined→ no match, soextractNestedResultsFromNode(line ~1102) skips sibling A entirely (if (idx < 0) continue) — and with it every nested create below A.commitUnresolvedNestedEntities(line ~986) then walks the collector's nested-entity map and does:without a paired
mapTempIdToPersistedId, so the entity is flagged as server-resident while still keyed by its temp ID.Suggested fix
buildSelectionFromOps, merge nested relation shapes across sibling ops instead of overwriting them (deep-union the selections, or collect a list of nested payloads per field and union the selections they produce). This makes the response self-sufficient for content matching.commitUnresolvedNestedEntities, do not setexistsOnServerfor an entity whose temp ID was not mapped — or, if committing is required to keep the tree consistent, record the entity as needing a refetch and make the mutation collector refuse to emit an update for a__temp_key (fail loudly at collection time rather than at the API). Emitting a mutation keyed by a temp ID is never correct.We are not certain which of the two you want as the primary fix; the second one alone would already turn a silent, unrecoverable state into something a downstream app can detect.
Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): …. The workaround force-refetches the page-builder editor subtree after any persist that included a section duplication, so the store re-reads real UUIDs instead of keeping unresolved temp IDs; we will remove it once this issue is resolved.