fix: load related record#1610
Conversation
| this.context.user, | ||
| ); | ||
|
|
||
| const relation = parent.values[target.name] as Record<string, unknown> | null | undefined; |
There was a problem hiding this comment.
🟠 High executors/load-related-record-step-executor.ts:306
In fetchXToOneCandidate, parent.values[target.name] uses the original snake_case field name (e.g., customer_order), but getRecord returns camelCase keys. This causes relation to be undefined for any relation field with underscores, triggering a spurious RelatedRecordNotFoundError even when the related record exists. Consider calling restoreFieldNames on the record values before accessing by field name, similar to how fetchRelatedData handles this.
- const relation = parent.values[target.name] as Record<string, unknown> | null | undefined;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/executors/load-related-record-step-executor.ts around line 306:
In `fetchXToOneCandidate`, `parent.values[target.name]` uses the original snake_case field name (e.g., `customer_order`), but `getRecord` returns camelCase keys. This causes `relation` to be `undefined` for any relation field with underscores, triggering a spurious `RelatedRecordNotFoundError` even when the related record exists. Consider calling `restoreFieldNames` on the record values before accessing by field name, similar to how `fetchRelatedData` handles this.
Evidence trail:
packages/workflow-executor/src/executors/load-related-record-step-executor.ts lines 291, 297-304, 306 (fields constructed with @@@, getRecord called, values accessed by target.name)
packages/workflow-executor/src/adapters/agent-client-agent-port.ts lines 25-27 (comment explaining camelCase issue), 28-41 (restoreFieldNames implementation), 73-93 (getRecord passes @@@-format fields to restoreFieldNames at line 90)
packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts lines 75-87 (mock bypasses actual JSON:API deserialization, uses snake_case relation name directly)
packages/workflow-executor/src/executors/load-related-record-step-executor.ts lines 474-478 (fetchRelatedData correctly uses restoreFieldNames with schema field names)
There was a problem hiding this comment.
I'm queuing the fix now. It will restore field names from camelCase before accessing the relation, preventing the error for fields with underscores.
There was a problem hiding this comment.
In fetchXToOneCandidate, added a call to restoreFieldNames after getting the parent record from agentPort.getRecord. This converts camelCase keys (returned by the agent-client) back to their original format (e.g., snake_case), ensuring relations with underscores in their names (e.g., customer_order) are correctly accessed and don't trigger spurious RelatedRecordNotFoundError.
Commit eb2e978 pushed to #1611. When checks pass, it will merge automatically into this PR.
There was a problem hiding this comment.
I couldn't automatically merge #1611 because the merge status is unstable.
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (3)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
|
Claude has added a lot of comments, but your code is readable as is, so in my opinion they aren't necessary. I'd recommend to keep them to a minimum. |
|
|
||
| return { | ||
| recordId: packedId.split('|'), | ||
| referenceFieldValue: referenceField |
There was a problem hiding this comment.
[Claude Opus 4.8] — Should fix
extractReferenceFieldValue(relation, referenceField) reads the reference field off the nested relation object, but that object is the raw deserialized relationship — its attribute keys are camelCased (full_name → fullName) and are never passed through restoreFieldNames. So a snake_case referenceField always yields undefined → null, and the human-readable label silently never renders on the xToOne path.
The comment at lines 212-215 ("fetchRelatedData has already restored field names … so reading r.values[field] works") holds for the to-many path but not for this one. Camel-case referenceField before reading, or run the nested object through restoreFieldNames. Same root cause as the relation-key Must-fix above. The test at line ~1594 hand-builds the getRecord result already in restored form, so it can't catch this.
| : pendingData.suggestedField; | ||
|
|
||
| // Re-derive relatedCollectionName and displayName because the user may have swapped the relation. | ||
| if (!relationRef) { |
There was a problem hiding this comment.
[Claude Opus 4.8] — Should fix (coverage)
This reachable error path is untested. The validator refine in pending-data-validators.ts accepts { userConfirmed: true, fieldDisplayName: <stale/renamed>, selectedRecordId } without checking the name is a real entry in availableFields, so a stale relation from the frontend lands here and throws StepStateError. The preview twin is tested ("returns error when the previewed relation does not exist") but this confirm-with-override branch is not — it's the one genuinely reachable uncovered line in the file. Per CLAUDE.md (changed files ≥90% covered; cover error edge cases), add a test asserting status === 'error' and that saveStepExecution is not called.
| // (configured but value missing) collapse to the same "fall back to id" rendering. | ||
| export interface LoadRelatedRecordCandidate { | ||
| recordId: Array<string | number>; | ||
| referenceFieldValue?: string | null; |
There was a problem hiding this comment.
[Claude Opus 4.8] — Preferential
referenceFieldValue?: string | null is a three-state type, but every producer (toCandidate, fetchXToOneCandidate) and extractReferenceFieldValue only ever emit string | null — undefined is never produced, and the doc comment says null/undefined collapse to the same rendering anyway. Tightening to a required referenceFieldValue: string | null removes a representable-but-never-produced state with zero behavior change.
| name: z.string().min(1).optional(), | ||
| // Sent as the displayName; the executor re-derives the technical fieldName and | ||
| // relatedCollectionName from the live schema when processing the confirmation. | ||
| fieldDisplayName: z.string().min(1).optional(), |
There was a problem hiding this comment.
Why don't send directly the technical name?
There was a problem hiding this comment.
The frontend needs to display the displayName, we don't really need the tech name for anything
| getRelatedData(query: GetRelatedDataQuery, user: StepUser): Promise<RecordData[]>; | ||
| // Returns raw rows from the agent (camelCase keys, no PK extraction). The caller is | ||
| // responsible for resolving the related collection's schema and mapping rows → RecordData. | ||
| getRelatedData(query: GetRelatedDataQuery, user: StepUser): Promise<Record<string, unknown>[]>; |
There was a problem hiding this comment.
Why it's not RecordData[] to return ?
| throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); | ||
| } | ||
| return rows.map(row => { | ||
| const restored = restoreFieldNames( |
There was a problem hiding this comment.
Don't use something from adapter inside business domain. If want to format data, you must to do it inside adapter/port. It's not the job of the step business :)
| // lookup, so the extra fetch only pays once per related collection per run. | ||
| const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName); | ||
| const referenceField = relatedSchema.referenceField ?? null; | ||
| const fields = [`${target.name}@@@${target.relatedPrimaryKey}`]; |
There was a problem hiding this comment.
Same than my previous comment, this syntaxe is related to HTTP transport. It must be move to the port.
| { collection, id, relation, limit, fields }: GetRelatedDataQuery, | ||
| user: StepUser, | ||
| ): Promise<RecordData[]> { | ||
| ): Promise<Record<string, unknown>[]> { |
There was a problem hiding this comment.
It returns RecordData[], why it moved to an other type ?
| // Branch A-preview -- user switched relation without confirming: re-list candidates | ||
| // for the new relation, refresh pendingData, stay awaiting-input. Detected by a | ||
| // patch carrying `fieldDisplayName` but no `userConfirmed`. | ||
| const conf = pending.userConfirmation; |
There was a problem hiding this comment.
This preview-vs-confirm discrimination duplicates the refine in http/pending-data-validators.ts (loadRelatedRecordPatchSchema) — the two encode the same rule and can
drift if it changes; consider a single source of truth (tagged union at parse time, or a shared predicate).

Definition of Done
General
Security
Note
Fix load-related-record step to resolve xToOne relations via parent projection
LoadRelatedRecordStepExecutorto resolve BelongsTo/HasOne (xToOne) relations by projecting<relation>@@@<pk>on the parent record viagetRecord, rather than callinggetRelatedData.LoadRelatedRecordPendingDatashape: replacessuggestedFields/selectedRecordIdwithavailableFields,suggestedField,availableRecordIds(with optionalreferenceFieldValue), andsuggestedRecord.fieldDisplayName(nouserConfirmed), the step refreshes candidates for that relation and stays awaiting-input.AgentClientAgentPort.getRelatedDatato return raw rows from agent-client; field-name restoration andRecordDatamapping are now handled insidefetchRelatedDatain the executor.FieldSchemaSchemawith optionalrelatedPrimaryKeyandCollectionSchemaSchemawith optionalreferenceField; xToOne relations requirerelatedPrimaryKeyto be present or the step fails fast.getRelatedDatanow returnsRecord<string, unknown>[](raw camelCase rows) instead ofRecordData[]; callers must map toRecordDatathemselves.Macroscope summarized 48995ff.