Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 6 additions & 17 deletions packages/agent-client/src/domains/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,23 +171,12 @@ export default class Collection extends CollectionChart {
});
}

// Fetch a single record by its (possibly composite) id via the agent's by-id route. The id is
// serialized opaquely (serializeRecordId), so the agent — the only party that knows the primary
// key column order — does the matching. Returns null when the record does not exist (404, or a
// 200 with an empty payload, which some agents return for a missing composite-key record).
async getOne<Data = unknown>(id: RecordId, options?: SelectOptions): Promise<Data | null> {
try {
const record = await this.httpRequester.query<Data>({
method: 'get',
path: `/forest/${this.name}/${serializeRecordId(id)}`,
query: QuerySerializer.serialize(options, this.name),
});

return record && Object.keys(record as object).length > 0 ? record : null;
} catch (error) {
if ((this.httpRequester.constructor as typeof HttpRequester).is404Error(error)) return null;
throw error;
}
async getOne<Data = unknown>(id: RecordId, options?: SelectOptions): Promise<Data> {
return this.httpRequester.query<Data>({
method: 'get',
path: `/forest/${this.name}/${serializeRecordId(id)}`,
query: QuerySerializer.serialize(options, this.name),
});
}

private getActionInfo(
Expand Down
25 changes: 18 additions & 7 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { StepUser } from '../types/execution-context';
import type { RecordData } from '../types/validated/collection';
import type { ActionEndpointsByCollection } from '@forestadmin/agent-client';

import { createRemoteAgentClient } from '@forestadmin/agent-client';
import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client';
import jsonwebtoken from 'jsonwebtoken';

import {
Expand Down Expand Up @@ -61,13 +61,24 @@ export default class AgentClientAgentPort implements AgentPort {
const client = this.createClient(user);
// Fetch by id through the agent's by-id route (like update/delete): the recordId is an
// opaque ordered token and the agent — the only party that knows the primary key column
// order — does the matching. No buildPkFilter / primaryKeyFields ordering assumption here.
const record = await client
.collection(collection)
.getOne<Record<string, unknown>>(id, { ...(fields?.length && { fields }) });
// order — does the matching. No primaryKeyFields ordering assumption here.
let record: Record<string, unknown> | null;

try {
record = await client
.collection(collection)
.getOne<Record<string, unknown>>(id, { ...(fields?.length && { fields }) });
} catch (error) {
if (HttpRequester.is404Error(error)) {
throw new RecordNotFoundError(collection, id);
}

throw error;
}

if (!record) {
throw new RecordNotFoundError(collection, id.join('|'));
// Some agents answer a missing composite-key record with a 200 + empty body instead of 404.
if (!record || Object.keys(record).length === 0) {
throw new RecordNotFoundError(collection, id);
}

return {
Expand Down
6 changes: 4 additions & 2 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ export class MalformedToolCallError extends WorkflowExecutorError {
}

export class RecordNotFoundError extends WorkflowExecutorError {
constructor(collectionName: string, recordId: string) {
constructor(collectionName: string, recordId: string | Array<string | number>) {
const id = Array.isArray(recordId) ? recordId.join('|') : recordId;
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.

RecordId can't be a string it must be RecordId. The type is declared in collection.ts


super(
`Record not found: collection "${collectionName}", id "${recordId}"`,
`Record not found: collection "${collectionName}", id "${id}"`,
'The record no longer exists. It may have been deleted.',
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { StepUser } from '../../src/types/execution-context';

import { createRemoteAgentClient } from '@forestadmin/agent-client';
import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client';
import jsonwebtoken from 'jsonwebtoken';

import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port';
import { AgentProbeError, RecordNotFoundError } from '../../src/errors';
import { AgentPortError, AgentProbeError, RecordNotFoundError } from '../../src/errors';
import SchemaCache from '../../src/schema-cache';

jest.mock('@forestadmin/agent-client', () => ({
createRemoteAgentClient: jest.fn(),
HttpRequester: { is404Error: jest.fn() },
}));

const mockedCreateRemoteAgentClient = createRemoteAgentClient as jest.MockedFunction<
typeof createRemoteAgentClient
>;
const mockedIs404Error = HttpRequester.is404Error as jest.MockedFunction<
typeof HttpRequester.is404Error
>;

function createMockClient() {
const mockAction = { execute: jest.fn(), getFields: jest.fn().mockReturnValue([]) };
Expand Down Expand Up @@ -132,6 +136,33 @@ describe('AgentClientAgentPort', () => {
);
});

it('maps a 404 from the agent to RecordNotFoundError', async () => {
mockedIs404Error.mockReturnValue(true);
mockCollection.getOne.mockRejectedValue(new Error('not found'));

await expect(port.getRecord({ collection: 'users', id: [999] }, user)).rejects.toThrow(
RecordNotFoundError,
);
});

it('rethrows non-404 errors instead of masking them as RecordNotFoundError', async () => {
// A 500 / auth / network failure must surface as a real error, not "record not found".
mockedIs404Error.mockReturnValue(false);
mockCollection.getOne.mockRejectedValue(new Error('boom'));

await expect(port.getRecord({ collection: 'users', id: [1] }, user)).rejects.toThrow(
AgentPortError,
);
});

it('treats a 200 with an empty body as RecordNotFoundError (missing composite record)', async () => {
mockCollection.getOne.mockResolvedValue({});

await expect(port.getRecord({ collection: 'orders', id: [1, 2] }, user)).rejects.toThrow(
RecordNotFoundError,
);
});

it('should pass fields to getOne when fields is provided', async () => {
mockCollection.getOne.mockResolvedValue({ id: 42, name: 'Alice' });

Expand Down
Loading