From fa5906931db950ff084b808aa0236d6fc5c03f94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:16:57 +0000 Subject: [PATCH 1/2] fix(runtime): forward a record-less aggregate api action that declares recordIdParam A record-less dispatch (no recordId) reaches buildApiActionRequest with the subject load's empty {} record. The recordIdParam branch read that {} as a row, found no id and refused 400, so every execution: 'aggregate' api action that declares recordIdParam failed for the whole bulk selection and its target never ran. The branch now seeds recordIdParam only when the dispatch names a record, the same meaning of record-less the path half already uses; the #8018 refusals for a named record whose row lacks the field or holds null are unchanged. Claude-Session: https://claude.ai/code/session_01ADHkmNtfGcifoTscZGVzuo Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 10 +- .../src/domains/api-action-forward.test.ts | 95 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index eba998277c1..b59e007c087 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1493,8 +1493,16 @@ export function buildApiActionRequest( // left out. Without a row (a record-less dispatch) only `id` can be seeded, // from the routed `recordId`; anything else has no source and the request // goes without it, exactly as a console click from a record-less location. + // + // [#29173] "Record-less" has ONE meaning here, the one the path half above + // uses: the dispatch names no record (`routedRecordId` is empty). The + // subject load hands a record-less dispatch an EMPTY object as its + // `ctx.record`, and that `{}` is not a row: read as one, every + // `execution: 'aggregate'` api action declaring `recordIdParam` was refused + // for lacking `id`, and its target never ran. A dispatch that names a record + // keeps both #8018 refusals for a row that lacks the field or holds null. const recordIdParam: string | undefined = typeof action?.recordIdParam === 'string' && action.recordIdParam ? action.recordIdParam : undefined; - if (recordIdParam) { + if (recordIdParam && routedRecordId) { const field: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id'; const row: Record | undefined = input.record && typeof input.record === 'object' ? input.record diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index fec7e6da54e..c2e0d896acb 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -99,6 +99,18 @@ const FLAG_BY_EMAIL = { ai: { exposed: true, description: 'Flag the lead by email.' }, }; +/** + * [#29173] The showcase's "Recalculate Selection" shape: the recalc target run + * ONCE for a whole bulk selection (`execution: 'aggregate'`), with every + * selected id in `params._selectedIds` and no `recordId` — and the same + * `recordIdParam` a per-record run seeds from its row. + */ +const RECALC_SELECTION = { + name: 'recalc_selection', label: 'Recalculate Selection', objectName: OBJECT, type: 'api', + execution: 'aggregate', target: '/api/v1/apps/showcase/recalc', recordIdParam: 'recordId', + ai: { exposed: true, description: 'Recalculate the selected leads.' }, +}; + /** An api action whose `target` carries no record token. */ const PING_LOCAL = { name: 'ping_local', label: 'Ping', objectName: OBJECT, type: 'api', method: 'POST', @@ -112,6 +124,8 @@ function boot(opts: { /** What the target answers: status, JSON body, extra response headers. */ answer?: { status?: number; body?: unknown; text?: string; headers?: Array<[string, string]> }; noServer?: boolean; + /** Actions declared on the object beside the shared roster above. */ + extraActions?: unknown[]; } = {}) { const disabled = new Set(opts.disabled ?? []); const injected: Injected[] = []; @@ -128,7 +142,7 @@ function boot(opts: { }); const server = { getRawApp: () => ({ request }) }; const executeAction = vi.fn(async () => ({ ran: 'script' })); - const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD, RECALC_LEAD, APPROVE_LEAD, NOTE_OWNER, FLAG_BY_EMAIL, PING_LOCAL] }; + const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD, RECALC_LEAD, APPROVE_LEAD, NOTE_OWNER, FLAG_BY_EMAIL, PING_LOCAL, ...(opts.extraActions ?? [])] }; const ql: any = { executeAction, getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), @@ -1449,3 +1463,82 @@ describe('an api action whose target names its record, called with no record id, expect((rowless as any).path).toBe('/api/v1/approvals/requests/areq%201/approve'); }); }); + +describe('[#29173] a record-less aggregate api action declaring `recordIdParam` is forwarded, and invents no record', () => { + const SELECTED = ['u_target', 'lead 7']; + const runViaMcp = async (h: ReturnType, name: string, input: any = {}) => { + const deps: any = { + resolveService: async (_ctx: unknown, service: string) => + service === 'metadata' ? h.metadata + : service === 'automation' ? null + : service === 'http.server' ? { getRawApp: () => ({ request: h.request }) } + : h.ql, + getObjectQL: async () => h.ql, + }; + return invokeBusinessAction(deps, CTX() as any, name, input, { + driver: h.ql, + ec: { userId: 'u_agent', tenantId: 'org_1', systemPermissions: [] }, + getMeta: () => h.metadata, + callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }), + }); + }; + + it('REST: the bulk bar\'s one aggregate request reaches the target with the selection, and no `recordId` key', async () => { + const { dispatcher, injected } = boot({ extraActions: [RECALC_SELECTION], answer: { body: { success: true, data: { selected: 2, acted: 2 } } } }); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${RECALC_SELECTION.name}`, 'POST', { params: { _selectedIds: SELECTED } }, CTX(), + ); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(injected).toHaveLength(1); + expect(injected[0].url).toBe('http://app.example.test/api/v1/apps/showcase/recalc'); + const body = JSON.parse(String(injected[0].init.body)); + expect(body).toEqual({ _selectedIds: SELECTED }); + expect(body).not.toHaveProperty('recordId'); + expect(res?.response?.body?.data).toEqual({ selected: 2, acted: 2 }); + }); + + it('MCP `run_action`: the same record-less run is forwarded, and no `recordId` key is invented', async () => { + const h = boot({ extraActions: [RECALC_SELECTION] }); + + const result: any = await runViaMcp(h, RECALC_SELECTION.name, { params: { _selectedIds: SELECTED } }); + + expect(result.ok).toBe(true); + expect(h.injected).toHaveLength(1); + const body = JSON.parse(String(h.injected[0].init.body)); + expect(body).toEqual({ _selectedIds: SELECTED }); + expect(body).not.toHaveProperty('recordId'); + }); + + it('REST, the per-record control: the same action run on one record still seeds `recordIdParam` from that row', async () => { + for (const recordId of SELECTED) { + const { dispatcher, injected } = boot({ extraActions: [RECALC_SELECTION] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${RECALC_SELECTION.name}`, 'POST', { recordId }, CTX()); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(JSON.parse(String(injected[0].init.body))).toEqual({ recordId }); + } + }); + + it('buildApiActionRequest: the empty subject record of a record-less dispatch is no row; a dispatch naming a record keeps both #8018 refusals', () => { + const ec = { userId: 'u1', tenantId: 'org_9' }; + for (const record of [{}, null, undefined]) { + const built = buildApiActionRequest(RECALC_SELECTION, { record, params: { _selectedIds: SELECTED }, ec }); + expect(built, JSON.stringify(record)).toEqual({ method: 'POST', path: '/api/v1/apps/showcase/recalc', body: { _selectedIds: SELECTED } }); + } + // An empty id is no id, as the path half reads it. + expect((buildApiActionRequest(RECALC_SELECTION, { recordId: '', record: {}, params: {}, ec }) as any).body).toEqual({}); + + const byToken = { ...RECALC_SELECTION, recordIdParam: 'token', recordIdField: 'token' }; + const absent = buildApiActionRequest(byToken, { recordId: 's1', record: { id: 's1' }, params: {}, ec }); + expect((absent as any).status).toBe(400); + expect(absent).not.toHaveProperty('path'); + expect((absent as any).message).toContain('"token"'); + const nulled = buildApiActionRequest(byToken, { recordId: 's1', record: { id: 's1', token: null }, params: {}, ec }); + expect((nulled as any).status).toBe(400); + expect(nulled).not.toHaveProperty('path'); + expect((nulled as any).message).toContain('"token"'); + }); +}); From c4d9657f918a4e93217350e91f59380af354d0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:22:53 +0000 Subject: [PATCH 2/2] chore(changeset): patch @objectstack/runtime for the aggregate recordIdParam forward Claude-Session: https://claude.ai/code/session_01ADHkmNtfGcifoTscZGVzuo Co-authored-by: Claude --- .../29173-aggregate-api-action-record-id-param.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/29173-aggregate-api-action-record-id-param.md diff --git a/.changeset/29173-aggregate-api-action-record-id-param.md b/.changeset/29173-aggregate-api-action-record-id-param.md new file mode 100644 index 00000000000..782949d2559 --- /dev/null +++ b/.changeset/29173-aggregate-api-action-record-id-param.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a record-less `execution: 'aggregate'` api action that declares `recordIdParam` is forwarded to its target instead of refused 400 + +Clause-②: no +Metadata-protocol: none + +A bulk action declared `type: 'api'`, `execution: 'aggregate'` and `recordIdParam` runs once for the whole selection: the console sends one request to the action door carrying every selected id in `params._selectedIds` and no `recordId`. The door's api request builder read the empty subject record of that record-less dispatch as a row, found no `id` on it and answered `400 VALIDATION_ERROR` ("identifies its record by "id", but that field is not present on the record"), so the target was never called and the bulk dialog reported every row failed. + +The builder now seeds `recordIdParam` only when the dispatch names a record, the same meaning of "record-less" its path interpolation already uses. A record-less run is forwarded with its params as written (`_selectedIds` included) and no `recordIdParam` key; a run on one record still seeds it from that row, and a run on a record whose row lacks the `recordIdField` or holds null for it is still refused. Both action doors (REST `POST /api/v1/actions/OBJECT/ACTION` and the MCP `run_action` tool) share the builder.