Skip to content

Commit 64e7bf7

Browse files
committed
fix(agent-bff): derive a primary key when the schema declares none
1 parent 77f255d commit 64e7bf7

3 files changed

Lines changed: 61 additions & 2 deletions

File tree

packages/agent-bff/src/context/build-context.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ function toContextValidations(validations: unknown[] | null | undefined): Contex
113113
function toContextField(
114114
field: FieldWithWireEnums,
115115
ambiguousKeys: ReadonlySet<string>,
116+
derivedPrimaryKeys: ReadonlySet<string>,
116117
): ContextField {
117118
const serialized: ContextField = { field: field.field, type: field.type };
118119

@@ -126,7 +127,10 @@ function toContextField(
126127
const polymorphicTargets = toArray(field.polymorphicReferencedModels);
127128
if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets];
128129

129-
if (field.isPrimaryKey) serialized.isPrimaryKey = true;
130+
// The read-model derives a key when the schema declares none, and the BFF builds record
131+
// identifiers from it. Publishing only the schema's flag would leave a client unable to name the
132+
// key the BFF is actually using.
133+
if (field.isPrimaryKey || derivedPrimaryKeys.has(field.field)) serialized.isPrimaryKey = true;
130134
if (field.isRequired) serialized.isRequired = true;
131135
if (field.isReadOnly) serialized.isReadOnly = true;
132136

@@ -168,10 +172,13 @@ function toContextCollection(
168172
field => typeof field === 'object' && field !== null,
169173
);
170174
const ambiguousKeys = ambiguousRecordKeys(fields);
175+
const derivedPrimaryKeys = new Set(
176+
readModel.getPrimaryKeys(collection.name).map(key => key.name),
177+
);
171178

172179
return {
173180
name: collection.name,
174-
fields: fields.map(field => toContextField(field, ambiguousKeys)),
181+
fields: fields.map(field => toContextField(field, ambiguousKeys, derivedPrimaryKeys)),
175182
actions: toArray(collection.actions)
176183
.filter(action => {
177184
const allowed = allowedActions[action?.name];

packages/agent-bff/src/read-model/read-model.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,33 @@ export default class ReadModel {
131131
}
132132
}
133133

134+
/**
135+
* A schema that declares no primary key at all gets one derived from its `id` field.
136+
*
137+
* `forest_liana` only started publishing `isPrimaryKey` in 9.17.6 (2026-06-04), and an older one
138+
* leaves every collection without a key — which makes `unpackPrimaryKey` reject every record the
139+
* agent returns, so a plain list answers `500 mapping_error`. The record id is there regardless:
140+
* the agent serialises it as the JSON:API `id`, and 257 of the 269 collections in the schema this
141+
* was measured against carry a field literally named `id`.
142+
*
143+
* Keyed on the shape of the schema, not on the liana: a collection that declares a key keeps it,
144+
* and every v2 agent declares one, so this only fires where the alternative is a 500. `String`
145+
* when no `id` field is declared, because the packed id survives a string round-trip untouched
146+
* while a wrong numeric cast would not.
147+
*/
134148
private buildPrimaryKeys(collection: ForestSchemaCollection): void {
135149
const keys: PrimaryKeyField[] = [];
136150

137151
for (const field of collection.fields ?? []) {
138152
if (field.isPrimaryKey) keys.push({ name: field.field, type: field.type });
139153
}
140154

155+
if (keys.length === 0) {
156+
const declaredId = (collection.fields ?? []).find(field => field.field === 'id');
157+
158+
keys.push({ name: 'id', type: declaredId?.type ?? 'String' });
159+
}
160+
141161
this.primaryKeys.set(collection.name, keys);
142162
}
143163

packages/agent-bff/test/read-model/read-model.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,5 +313,37 @@ describe('ReadModel', () => {
313313

314314
expect(model.getPrimaryKeys('ghost')).toEqual([]);
315315
});
316+
317+
// A forest_liana older than 9.17.6 publishes no isPrimaryKey anywhere, which used to leave the
318+
// collection keyless and make every list answer 500 mapping_error.
319+
describe('when the schema declares no key', () => {
320+
it('should fall back to the id field with the type the schema gives it', () => {
321+
const model = new ReadModel([
322+
collection('users', [
323+
{ ...column('id'), type: 'Number', isPrimaryKey: false },
324+
column('email'),
325+
]),
326+
]);
327+
328+
expect(model.getPrimaryKeys('users')).toEqual([{ name: 'id', type: 'Number' }]);
329+
});
330+
331+
it('should fall back to a string id when the schema declares no id field either', () => {
332+
const model = new ReadModel([collection('audits', [column('label')])]);
333+
334+
expect(model.getPrimaryKeys('audits')).toEqual([{ name: 'id', type: 'String' }]);
335+
});
336+
337+
it('should leave a declared key alone, so a v2 agent is untouched', () => {
338+
const model = new ReadModel([
339+
collection('users', [
340+
{ ...column('reference'), type: 'String', isPrimaryKey: true },
341+
{ ...column('id'), type: 'Number', isPrimaryKey: false },
342+
]),
343+
]);
344+
345+
expect(model.getPrimaryKeys('users')).toEqual([{ name: 'reference', type: 'String' }]);
346+
});
347+
});
316348
});
317349
});

0 commit comments

Comments
 (0)