From 64bec4114cca07829fccc1e110522f03be93cadb Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 24 Aug 2026 18:22:39 +0200 Subject: [PATCH] fix(bindx): echo nested create IDs from MockAdapter `PersistResult.data` is documented as "the entity node data after mutation ... contains nested entity IDs for inline creates", and since "reconcile immutable persistence executions" the persister enforces it: a create whose response does not carry the server IDs of its nested creates now fails with "Missing or ambiguous server ID for nested create ..." instead of quietly succeeding with leaked temp IDs (#70). MockAdapter never honoured that contract. `persist` returned a bare `{ ok: true }` with no node at all, and `create` echoed the raw payload back with relation operations left unmaterialised, so a nested `{ create: ... }` reached the persister as an operation object rather than a row with an ID. Any consumer testing a nested create against the shipped test double therefore started failing. Materialize relation operations recursively when creating a related entity, build the created node the same way, and echo the mutated node from `persist`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqgPtZvAfpvWKymfFZCEX --- packages/bindx/src/adapter/MockAdapter.ts | 20 +++- .../adapter/mockAdapterNestedCreates.test.ts | 98 +++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/unit/adapter/mockAdapterNestedCreates.test.ts diff --git a/packages/bindx/src/adapter/MockAdapter.ts b/packages/bindx/src/adapter/MockAdapter.ts index b7d7f195..c7323cc5 100644 --- a/packages/bindx/src/adapter/MockAdapter.ts +++ b/packages/bindx/src/adapter/MockAdapter.ts @@ -194,7 +194,9 @@ export class MockAdapter implements BackendAdapter { this.applyChanges(entity, changes) this.log('persist result', entity) - return { ok: true } + // Echo the mutated node — PersistResult.data is what the persister reads + // nested create IDs out of. + return { ok: true, data: entity } } /** @@ -335,11 +337,15 @@ export class MockAdapter implements BackendAdapter { } /** - * Creates a related entity with a generated ID. + * Creates a related entity with a generated ID, materializing its own nested + * operations. The persister reconciles a create by reading server IDs out of + * the echoed node, so a nested create left as a raw `{ create: … }` operation + * reads as an unresolved ID and fails the whole persist. */ private createRelatedEntity(data: Record): Record { - const id = this.generateId() - return { id, ...data } + const entity: Record = { id: this.generateId() } + this.applyChanges(entity, data) + return entity } /** @@ -368,7 +374,11 @@ export class MockAdapter implements BackendAdapter { // Generate ID if not provided const id = (data['id'] as string) ?? this.generateId() - const entity = { ...data, id } + // Materialize relation operations so the echoed node carries the server IDs + // of every nested create — the persister reconciles against exactly that. + const entity: Record = { id } + this.applyChanges(entity, data) + entity['id'] = id this.store[entityType]![id] = entity this.log('create result', entity) diff --git a/tests/unit/adapter/mockAdapterNestedCreates.test.ts b/tests/unit/adapter/mockAdapterNestedCreates.test.ts new file mode 100644 index 00000000..de0a46ef --- /dev/null +++ b/tests/unit/adapter/mockAdapterNestedCreates.test.ts @@ -0,0 +1,98 @@ +// Regression test for the shipped MockAdapter's node-echo contract. +// +// Since "reconcile immutable persistence executions", a persist whose response +// does not carry the server IDs of its nested creates fails instead of quietly +// succeeding with leaked temp IDs. MockAdapter echoed the raw create payload +// back as the node — relation operations included, unmaterialised — so every +// consumer testing a nested create against it started failing with +// "Missing or ambiguous server ID for nested create …". +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + MutationCollector, + ContemberSchemaMutationAdapter, + ActionDispatcher, + BatchPersister, + MockAdapter, + type MockDataStore, + type SchemaNames, +} from '@contember/bindx' + +// File → variants (hasMany) → asset (hasOne). Mirrors an upload dialog that +// creates a file, its format rows, and the stored asset behind each of them in +// one scoped persist. +const schema: SchemaNames = { + entities: { + File: { + name: 'File', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + variants: { type: 'many', entity: 'Variant' }, + }, + }, + Variant: { + name: 'Variant', + scalars: ['id', 'format'], + fields: { + id: { type: 'column' }, + format: { type: 'column' }, + asset: { type: 'one', entity: 'Asset', nullable: true }, + }, + }, + Asset: { + name: 'Asset', + scalars: ['id', 'url'], + fields: { id: { type: 'column' }, url: { type: 'column' } }, + }, + }, + enums: {}, +} + +describe('MockAdapter — nested creates', () => { + let store: SnapshotStore + let persister: BatchPersister + let fileId: string + let variantId: string + let assetId: string + + beforeEach(() => { + store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + const mutationCollector = new MutationCollector(store, schemaAdapter) + const data: MockDataStore = { File: {}, Variant: {}, Asset: {} } + persister = new BatchPersister(new MockAdapter(data, { delay: 0 }), store, dispatcher, { + mutationCollector, + schema: schemaAdapter as never, + }) + + fileId = store.createEntity('File', { title: 'zprava.pdf' }) + variantId = store.createEntity('Variant', { format: 'pdf' }) + assetId = store.createEntity('Asset', { url: 'https://cdn.example/zprava.pdf' }) + store.getOrCreateRelation('Variant', variantId, 'asset', { + currentId: assetId, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.getOrCreateHasMany('File', fileId, 'variants', []) + store.addToHasMany('File', fileId, 'variants', variantId) + }) + + test('a scoped persist of a create with nested creates succeeds', async () => { + const result = await persister.persist('File', fileId) + expect(result.error?.message).toBeUndefined() + expect(result.success).toBe(true) + }) + + test('the persist reports the server ID the root was created under', async () => { + const result = await persister.persist('File', fileId) + expect(result.persistedId).toBeDefined() + expect(result.persistedId).not.toStartWith('__temp_') + }) + + test('every nested create is rekeyed to its server ID', async () => { + await persister.persist('File', fileId) + expect(store.getPersistedId('Variant', variantId)).not.toBeNull() + expect(store.getPersistedId('Asset', assetId)).not.toBeNull() + }) +})