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() + }) +})