Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions packages/bindx/src/adapter/MockAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

/**
Expand Down Expand Up @@ -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<string, unknown>): Record<string, unknown> {
const id = this.generateId()
return { id, ...data }
const entity: Record<string, unknown> = { id: this.generateId() }
this.applyChanges(entity, data)
return entity
}

/**
Expand Down Expand Up @@ -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<string, unknown> = { id }
this.applyChanges(entity, data)
entity['id'] = id

this.store[entityType]![id] = entity
this.log('create result', entity)
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/adapter/mockAdapterNestedCreates.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})