fix: load related record#1610
Conversation
|
Coverage Impact Unable to calculate total coverage change because base branch coverage was not found. Modified Files with Diff Coverage (4)
🤖 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
There was a problem hiding this comment.
Prefer to use fieldName, it's the unique key. DisplayName can be not unique. Also it can change during the process. This field is used to display nicely the field, it's a cosmetic field
| 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 ?
There was a problem hiding this comment.
Because I wanted to avoid having to pass the schema to the agent-port, but it's ok, I change
| 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.
There was a problem hiding this comment.
I don't see how to move this logic to the agentPort. Here we are fetching some fields in the relation from the parent record, to get the id and the referenceField. At this step we don't have the id so we can't fetch the child record directly.
This logic is heavily related to the loadRelatedRecord step so I think it's ok to leave it there
There was a problem hiding this comment.
Yes that why adapter and port exists ! We don't want to be linked to the agent format. Adapter and port encapsulate how we communicate with the outside. This logic must be in the adapter :/
| { 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's moved to an other type ?
There was a problem hiding this comment.
It wasn't, the restoreFieldNames was working only for the already fetched collection schema, which wasn't the case for relationships
There was a problem hiding this comment.
I fixed it, & restored the RecordData[] 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).
There was a problem hiding this comment.
I don't understand, nothing seems repeated, we simply do different things according to the user input
| relatedCollectionName: string; | ||
| // Primary key field name on the related collection — supplied by the orchestrator's | ||
| // schema. Required for the xToOne projection syntax ('<relation>@@@<pk>'). | ||
| relatedPrimaryKey?: string; |
23d4ee8 to
a47f15f
Compare
| relatedCollectionName: string; | ||
| // Primary key field name on the related collection — supplied by the orchestrator's | ||
| // schema. Required for the xToOne projection syntax ('<relation>@@@<pk>'). | ||
| computedKey?: string; |
There was a problem hiding this comment.
change the name: Type is recordId I think
9d4e394 to
6224c76
Compare
| // Preview patch (no confirm): fieldName alone is sufficient. | ||
| if (data.userConfirmed === undefined) return data.fieldName !== undefined; | ||
| // Confirm patch with relation override: selectedRecordId required. | ||
| if (data.fieldName !== undefined) return data.selectedRecordId !== undefined; |
There was a problem hiding this comment.
🟡 Medium http/pending-data-validators.ts:56
The .refine() validation on line 56 requires selectedRecordId whenever fieldName is provided, even when userConfirmed is false. This means sending { userConfirmed: false, fieldName: "someField" } incorrectly fails validation with a message about "confirming with a relation override" — but declining a step is not confirming. The check should include data.userConfirmed === true to match the documented intent.
| if (data.fieldName !== undefined) return data.selectedRecordId !== undefined; | |
| if (data.userConfirmed === true && data.fieldName !== undefined) return data.selectedRecordId !== undefined; |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/src/http/pending-data-validators.ts around line 56:
The `.refine()` validation on line 56 requires `selectedRecordId` whenever `fieldName` is provided, even when `userConfirmed` is `false`. This means sending `{ userConfirmed: false, fieldName: "someField" }` incorrectly fails validation with a message about "confirming with a relation override" — but declining a step is not confirming. The check should include `data.userConfirmed === true` to match the documented intent.
Evidence trail:
packages/workflow-executor/src/http/pending-data-validators.ts lines 34-65 at REVIEWED_COMMIT. Line 54 only catches `userConfirmed === undefined`, line 56 applies to any defined `userConfirmed` (including `false`). The inline comment on line 55 says 'Confirm patch' but code doesn't check `userConfirmed === true`.
| // Field names in the schema and in GetRecordQuery.fields use the original format (e.g. snake_case). | ||
| // This function restores the original field names so callers can look up values by schema fieldName. | ||
| function restoreFieldNames( | ||
| export function restoreFieldNames( |
| availableFields: RelationRef[]; | ||
| // AI-selected relation from `availableFields`. | ||
| suggestedField: RelationRef; | ||
| // First 50 records of `suggestedField` paired with their reference-field value. |
There was a problem hiding this comment.
If we change to 40 or whatever, the comment will be outdated. Remove it plz :)
| suggestedFields?: string[]; | ||
| // AI-selected initially; frontend can override via userConfirmation.selectedRecordId. | ||
| selectedRecordId: Array<string | number>; | ||
| // One row of `availableRecordIds`. `referenceFieldValue` is the layout-level reference |
| return relatedData.map(r => this.toRecordRef(r)); | ||
| } | ||
|
|
||
| private async fetchRelatedData( |
There was a problem hiding this comment.
I think we can remove this method. It does nothing
There was a problem hiding this comment.
it is used at 2 places, it's not big but it simplifies a little the call to the agentPort
Scra3
left a comment
There was a problem hiding this comment.
Found a blocking bug in the xToOne relation projection (getSingleRelatedData).
| ): Promise<RecordData | null> { | ||
| return this.callAgent('getSingleRelatedData', async () => { | ||
| const projectedFields = Array.from( | ||
| new Set([...relatedSchema.primaryKeyFields, ...(fields ?? [])]), |
There was a problem hiding this comment.
issue (blocking): projecting the PK and the reference field on one relation produces fields[<relation>]=id,reference, which the agent's parseProjection can't split into two sub-fields — it 400s with Invalid projection: The '<relation>.id,name' field was not found; project a single sub-field instead (the linkage id is returned by the JSON:API serializer regardless of projection).

Definition of Done
General
Security
Note
Fix load-related-record step executor to support x-to-one relations and richer pending data
getSingleRelatedDatatoAgentPortandAgentClientAgentPortfor BelongsTo/HasOne relations, fetching a single related record via parent projections instead of a list query.pendingDatashape (displayName,selectedRecordId) with a structured shape:availableFields,suggestedField,availableRecordIds(candidate objects), andsuggestedRecord.awaiting-input.relatedSchemato allgetRelatedDatacalls so field name restoration and ID extraction are schema-driven rather than inferred from the parent relation.LoadRelatedRecordPendingDatashape is a breaking change for any client or persisted state relying on the oldselectedRecordId/suggestedFieldsfields.Changes since #1610 opened
Macroscope summarized 6224c76.