Skip to content

fix: load related record#1610

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

fix: load related record#1610
EnkiP wants to merge 11 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 executor to support x-to-one relations and richer pending data

  • Adds getSingleRelatedData to AgentPort and AgentClientAgentPort for BelongsTo/HasOne relations, fetching a single related record via parent projections instead of a list query.
  • Replaces the flat pendingData shape (displayName, selectedRecordId) with a structured shape: availableFields, suggestedField, availableRecordIds (candidate objects), and suggestedRecord.
  • Adds support for preview-only PATCH requests that change the selected relation field without confirming, triggering candidate refresh and returning awaiting-input.
  • Propagates relatedSchema to all getRelatedData calls so field name restoration and ID extraction are schema-driven rather than inferred from the parent relation.
  • Risk: LoadRelatedRecordPendingData shape is a breaking change for any client or persisted state relying on the old selectedRecordId/suggestedFields fields.

Changes since #1610 opened

  • Removed inline documentation comments from workflow-executor type definitions [386cce1]
  • Added test case to verify error handling when user-confirmed relation field is not present in available fields [1db2471]
  • Removed export modifier from utility function to make it module-internal [1db2471]
  • Changed AgentClientAgentPort.getSingleRelatedData method to project at most one sub-field for relations [675c13b]
  • Updated AgentClientAgentPort test suite to verify single-field projection behavior [675c13b]
  • Normalized relatedCollectionName field values in collection schemas [83da43a]
  • Updated error message for workflow port failures [83da43a]

Macroscope summarized 6224c76.

@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 ✅

Comment thread packages/workflow-executor/src/executors/load-related-record-step-executor.ts Outdated
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

Unable to calculate total coverage change because base branch coverage was not found.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
New Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts98.1%285
New Coverage rating: A
packages/workflow-executor/src/executors/record-step-executor.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/http/pending-data-validators.ts100.0%
New 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 285

🚦 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

Copy link
Copy Markdown
Member

@Scra3 Scra3 Jun 1, 2026

Choose a reason for hiding this comment

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

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>[]>;
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 ?

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.

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

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.

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

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.

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>[]> {
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.

It returns RecordData[], why it's moved to an other type ?

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.

It wasn't, the restoreFieldNames was working only for the already fetched collection schema, which wasn't the case for relationships

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.

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

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.

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

Is it possible to be computedKey?

@EnkiP EnkiP force-pushed the fix/load-related-record branch from 23d4ee8 to a47f15f Compare June 1, 2026 10:16
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;
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.

change the name: Type is recordId I think

@EnkiP EnkiP force-pushed the fix/load-related-record branch from 9d4e394 to 6224c76 Compare June 1, 2026 11:27
// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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(
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.

Export to remove

availableFields: RelationRef[];
// AI-selected relation from `availableFields`.
suggestedField: RelationRef;
// First 50 records of `suggestedField` paired with their reference-field value.
Copy link
Copy Markdown
Member

@Scra3 Scra3 Jun 1, 2026

Choose a reason for hiding this comment

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

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

@Scra3 Scra3 Jun 1, 2026

Choose a reason for hiding this comment

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

We can remove all the comment.

return relatedData.map(r => this.toRecordRef(r));
}

private async fetchRelatedData(
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.

I think we can remove this method. It does nothing

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.

it is used at 2 places, it's not big but it simplifies a little the call to the agentPort

Copy link
Copy Markdown
Member

@Scra3 Scra3 left a comment

Choose a reason for hiding this comment

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

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 ?? [])]),
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.

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

alban bertolini and others added 3 commits June 2, 2026 14:15
…drop unused export

- Add a test for resolveFromSelection when the confirmed relation is not in
  availableFields (stale/renamed) — asserts the step errors and neither
  getRelatedData nor saveStepExecution is called.
- restoreFieldNames is only used inside the agent-client adapter now; drop its
  unused export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getSingleRelatedData projected both the PK and the reference field on the
relation (fields[store]=id,name), which the agent's parseProjection can't split
into two sub-fields — it 400s with "Invalid projection: '<relation>.id,name'".

The JSON:API serializer fills the linkage `id` regardless of projection, so we
only need ONE sub-field: the requested reference field (for display), else a
single PK field just to pull the relation into the response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lainer port error

- The orchestrator sends relatedCollectionName as Forest's `collection.targetKey`
  reference (e.g. "store.id"), which made getCollectionSchema("store.id") 404 and
  broke xToOne relation loading. Strip it to a plain collection name in the
  forest-server-workflow-port adapter (transport concern stays in the adapter; the
  zod schema stays a clean domain contract). The target key lives in relatedPrimaryKey.
- WorkflowPortError.userMessage no longer leaks internal jargon ("orchestrator") nor
  claims a connection cause (it also wraps 4xx like "collection not found"):
  "This step couldn't be completed. Please try again, and contact your administrator
  if the problem continues."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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