Skip to content

fix: load related record#1610

Open
EnkiP wants to merge 5 commits into
feat/prd-214-server-step-mapperfrom
fix/load-related-record
Open

fix: load related record#1610
EnkiP wants to merge 5 commits into
feat/prd-214-server-step-mapperfrom
fix/load-related-record

Conversation

@EnkiP
Copy link
Copy Markdown
Member

@EnkiP EnkiP commented May 29, 2026

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

Note

Fix load-related-record step to resolve xToOne relations via parent projection

  • Rewrites the LoadRelatedRecordStepExecutor to resolve BelongsTo/HasOne (xToOne) relations by projecting <relation>@@@<pk> on the parent record via getRecord, rather than calling getRelatedData.
  • Redesigns LoadRelatedRecordPendingData shape: replaces suggestedFields/selectedRecordId with availableFields, suggestedField, availableRecordIds (with optional referenceFieldValue), and suggestedRecord.
  • Adds a field-preview PATCH path: when a pending execution receives only fieldDisplayName (no userConfirmed), the step refreshes candidates for that relation and stays awaiting-input.
  • Simplifies AgentClientAgentPort.getRelatedData to return raw rows from agent-client; field-name restoration and RecordData mapping are now handled inside fetchRelatedData in the executor.
  • Extends FieldSchemaSchema with optional relatedPrimaryKey and CollectionSchemaSchema with optional referenceField; xToOne relations require relatedPrimaryKey to be present or the step fails fast.
  • Behavioral Change: getRelatedData now returns Record<string, unknown>[] (raw camelCase rows) instead of RecordData[]; callers must map to RecordData themselves.

Macroscope summarized 48995ff.

@EnkiP EnkiP changed the base branch from main to feat/prd-214-server-step-mapper May 29, 2026 07:29
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 29, 2026

All good ✅

this.context.user,
);

const relation = parent.values[target.name] as Record<string, unknown> | null | undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix it for me

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm queuing the fix now. It will restore field names from camelCase before accessing the relation, preventing the error for fields with underscores.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't automatically merge #1611 because the merge status is unstable.

Comment thread packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Comment thread packages/workflow-executor/src/adapters/agent-client-agent-port.ts
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 29, 2026

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts98.5%338
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/http/pending-data-validators.ts100.0%
Coverage rating: A Coverage rating: A
...ages/workflow-executor/src/adapters/agent-client-agent-port.ts100.0%
Total98.6%
🤖 Increase coverage with AI coding...
In the `fix/load-related-record` branch, add test coverage for this new code:

- `packages/workflow-executor/src/executors/load-related-record-step-executor.ts` -- Line 338

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Enki Pontvianne added 2 commits May 29, 2026 11:22
@hercemer42
Copy link
Copy Markdown

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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_namefullName) and are never passed through restoreFieldNames. So a snake_case referenceField always yields undefinednull, 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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 4.8] — Preferential

referenceFieldValue?: string | null is a three-state type, but every producer (toCandidate, fetchXToOneCandidate) and extractReferenceFieldValue only ever emit string | nullundefined 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(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't send directly the technical name?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>[]>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why it's not RecordData[] to return ?

throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name);
}
return rows.map(row => {
const restored = restoreFieldNames(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>[]> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member

@Scra3 Scra3 May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants