From 7409e7ee2e89bf3040f2376ee078018082517244 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 25 Dec 2025 18:30:33 +0700 Subject: [PATCH 01/15] Implement mapFailure --- .../concurrency/__tests__/concurrency.test.ts | 1 + .../__tests__/api.response.all_in_one.test.ts | 9 +- .../__tests__/api.response.extract.test.ts | 5 +- .../fetch/__tests__/json.failed.data.test.ts | 11 +- .../__tests__/json.response.data.test.ts | 9 +- .../core/src/fetch/__tests__/request.test.ts | 17 +- packages/core/src/fetch/api.ts | 51 ++-- packages/core/src/fetch/request.ts | 32 ++- .../src/mutation/create_headless_mutation.ts | 2 + ...te_json_query.response.map_failure.test.ts | 248 ++++++++++++++++++ .../core/src/query/create_headless_query.ts | 19 +- packages/core/src/query/create_json_query.ts | 51 ++++ packages/core/src/query/create_query.ts | 3 + .../__test__/create_remote_operation.test.ts | 2 + .../create_remote_operation.ts | 76 +++++- .../src/retry/__tests__/retry.query.test.ts | 1 + 16 files changed, 467 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts diff --git a/packages/core/src/concurrency/__tests__/concurrency.test.ts b/packages/core/src/concurrency/__tests__/concurrency.test.ts index af5e81563..bda4c188f 100644 --- a/packages/core/src/concurrency/__tests__/concurrency.test.ts +++ b/packages/core/src/concurrency/__tests__/concurrency.test.ts @@ -80,6 +80,7 @@ describe('concurrency', async () => { "explanation": "Request was cancelled due to concurrency policy", }, "meta": { + "responseMeta": undefined, "stale": false, "stopErrorPropagation": false, }, diff --git a/packages/core/src/fetch/__tests__/api.response.all_in_one.test.ts b/packages/core/src/fetch/__tests__/api.response.all_in_one.test.ts index 24d244a6a..82952df18 100644 --- a/packages/core/src/fetch/__tests__/api.response.all_in_one.test.ts +++ b/packages/core/src/fetch/__tests__/api.response.all_in_one.test.ts @@ -62,9 +62,12 @@ describe('fetch/api.response.all_in_one', () => { }); expect(watcher.listeners.onFailData).toBeCalledWith( - preparationError({ - response: 'This is not JSON', - reason: 'Unexpected token T in JSON at position 0', + expect.objectContaining({ + error: preparationError({ + response: 'This is not JSON', + reason: 'Unexpected token T in JSON at position 0', + }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), }) ); }); diff --git a/packages/core/src/fetch/__tests__/api.response.extract.test.ts b/packages/core/src/fetch/__tests__/api.response.extract.test.ts index ddb742bc3..88d691bd4 100644 --- a/packages/core/src/fetch/__tests__/api.response.extract.test.ts +++ b/packages/core/src/fetch/__tests__/api.response.extract.test.ts @@ -82,7 +82,10 @@ describe('fetch/api.response.exceptions', () => { }); expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith( - preparationError({ response: 'ok', reason: 'oops' }) + expect.objectContaining({ + error: preparationError({ response: 'ok', reason: 'oops' }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), + }) ); } ); diff --git a/packages/core/src/fetch/__tests__/json.failed.data.test.ts b/packages/core/src/fetch/__tests__/json.failed.data.test.ts index 3ddb6c08c..5a0ec3616 100644 --- a/packages/core/src/fetch/__tests__/json.failed.data.test.ts +++ b/packages/core/src/fetch/__tests__/json.failed.data.test.ts @@ -34,10 +34,13 @@ describe('createJsonApi', () => { }); expect(watcher.listeners.onFailData).toBeCalledWith( - httpError({ - status: 500, - statusText: '', - response: { customError: true }, + expect.objectContaining({ + error: httpError({ + status: 500, + statusText: '', + response: { customError: true }, + }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), }) ); }); diff --git a/packages/core/src/fetch/__tests__/json.response.data.test.ts b/packages/core/src/fetch/__tests__/json.response.data.test.ts index c53d8709b..1f2009e64 100644 --- a/packages/core/src/fetch/__tests__/json.response.data.test.ts +++ b/packages/core/src/fetch/__tests__/json.response.data.test.ts @@ -29,9 +29,12 @@ describe('fetch/json.response.data', () => { }); expect(watcher.listeners.onFailData).toBeCalledWith( - preparationError({ - response: 'It is not JSON', - reason: 'Unexpected token I in JSON at position 0', + expect.objectContaining({ + error: preparationError({ + response: 'It is not JSON', + reason: 'Unexpected token I in JSON at position 0', + }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), }) ); }); diff --git a/packages/core/src/fetch/__tests__/request.test.ts b/packages/core/src/fetch/__tests__/request.test.ts index 7654b8102..ec2b6801b 100644 --- a/packages/core/src/fetch/__tests__/request.test.ts +++ b/packages/core/src/fetch/__tests__/request.test.ts @@ -52,13 +52,14 @@ describe('fetch/request', () => { params: new Request('https://api.salo.com'), }); - expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith( - httpError({ + expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith({ + error: httpError({ status: code, statusText: 'Request cannot', response: '', - }) - ); + }), + responseMeta: { headers: FAILED_RESPONSE.headers }, + }); }); }); @@ -74,10 +75,10 @@ describe('fetch/request', () => { params: new Request('https://api.salo.com'), }); - expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith( - expect.objectContaining({ + expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith({ + error: expect.objectContaining({ cause, - }) - ); + }), + }); }); }); diff --git a/packages/core/src/fetch/api.ts b/packages/core/src/fetch/api.ts index 26d8f57a6..5dba0bd6b 100644 --- a/packages/core/src/fetch/api.ts +++ b/packages/core/src/fetch/api.ts @@ -18,7 +18,7 @@ import { type FetchApiRecord, mergeQueryStrings, } from './lib'; -import { requestFx } from './request'; +import { requestFx, type RequestError } from './request'; export type HttpMethod = | 'HEAD' @@ -108,6 +108,11 @@ export type ApiRequestError = export type JsonApiRequestError = ApiRequestError | InvalidDataError; +export type ApiRequestErrorWithMeta = { + error: ApiRequestError; + responseMeta?: { headers: Headers }; +}; + export function createApiRequest< R extends CreationRequestConfig, P, @@ -124,7 +129,7 @@ export function createApiRequest< method: HttpMethod; }, { result: ApiRequestResult; meta: { headers: Headers } }, - ApiRequestError + ApiRequestErrorWithMeta >( async ({ url, @@ -145,22 +150,27 @@ export function createApiRequest< signal: abortController?.signal, }); - const response = await requestFx(request).catch((cause) => { - if (config.response.transformError) { - throw config.response.transformError(cause); - } + const response = await requestFx(request).catch((cause: RequestError) => { + // cause is { error, responseMeta? } + const transformedError = + config.response.transformError?.(cause.error) ?? cause.error; - throw cause; + // Re-throw with responseMeta preserved + throw { error: transformedError, responseMeta: cause.responseMeta }; }); // We cannot read body of the response twice (prepareFx and throw preparationError) const clonedResponse = response.clone(); + const responseHeaders = response.headers; const prepared = await prepareFx(response).catch(async (cause) => { - throw preparationError({ - response: await clonedResponse.text(), - reason: cause?.message ?? null, - }); + throw { + error: preparationError({ + response: await clonedResponse.text(), + reason: cause?.message ?? null, + }), + responseMeta: { headers: responseHeaders }, + }; }); if (config.response.status) { @@ -169,14 +179,17 @@ export function createApiRequest< : [config.response.status.expected]; if (!expected.includes(response.status)) { - throw invalidDataError({ - validationErrors: [ - `Expected response status has to be one of [${expected.join( - ', ' - )}], got ${response.status}`, - ], - response: prepared, - }); + throw { + error: invalidDataError({ + validationErrors: [ + `Expected response status has to be one of [${expected.join( + ', ' + )}], got ${response.status}`, + ], + response: prepared, + }), + responseMeta: { headers: responseHeaders }, + }; } } diff --git a/packages/core/src/fetch/request.ts b/packages/core/src/fetch/request.ts index ca2968a4d..e640e1841 100644 --- a/packages/core/src/fetch/request.ts +++ b/packages/core/src/fetch/request.ts @@ -4,30 +4,34 @@ import { HttpError, NetworkError } from '../errors/type'; import { httpError, networkError } from '../errors/create_error'; import { fetchFx } from './fetch'; +export type RequestError = { + error: NetworkError | HttpError; + responseMeta?: { headers: Headers }; +}; + /** * Basic request effect around fetchFx, with some additional features: * + it throws error if response status is 4XX/5XX * + it throws serializable NetworkError instead of TypeError + * + it includes responseMeta with headers for HTTP errors */ -export const requestFx = createEffect< - Request, - Response, - NetworkError | HttpError ->({ +export const requestFx = createEffect({ handler: async (request) => { const response = await fetchFx(request).catch((cause) => { - throw networkError({ - reason: cause?.message ?? null, - cause, - }); + // Network error - no response, no responseMeta + throw { error: networkError({ reason: cause?.message ?? null, cause }) }; }); if (!response.ok) { - throw httpError({ - status: response.status, - statusText: response.statusText, - response: (await response.text().catch(() => null)) ?? null, - }); + // HTTP error - include responseMeta with headers + throw { + error: httpError({ + status: response.status, + statusText: response.statusText, + response: (await response.text().catch(() => null)) ?? null, + }), + responseMeta: { headers: response.headers }, + }; } return response; diff --git a/packages/core/src/mutation/create_headless_mutation.ts b/packages/core/src/mutation/create_headless_mutation.ts index cd843ed16..f25ba43f3 100644 --- a/packages/core/src/mutation/create_headless_mutation.ts +++ b/packages/core/src/mutation/create_headless_mutation.ts @@ -45,8 +45,10 @@ export function createHeadlessMutation< ContractData, MappedData, Error, + Error | InvalidDataError, null, MapDataSource, + void, ValidationSource >({ name: name ?? getFactoryName(), diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts new file mode 100644 index 000000000..bf4ad6c21 --- /dev/null +++ b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts @@ -0,0 +1,248 @@ +import { allSettled, createStore, fork } from 'effector'; +import { describe, test, expect, vi } from 'vitest'; + +import { unknownContract } from '../../contract/unknown_contract'; +import { createJsonQuery } from '../create_json_query'; +import { declareParams } from '../../remote_operation/params'; +import { fetchFx } from '../../fetch/fetch'; +import { isHttpError, isNetworkError } from '../../errors/guards'; + +describe('remote_data/query/json.response.map_failure', () => { + // Does not matter + const request = { + url: 'http://api.salo.com', + method: 'GET' as const, + }; + + test('transform error with simple callback', async () => { + const originalError = { message: 'Original error' }; + const transformedError = { message: 'Transformed error' }; + + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + mapFailure: ({ error }) => { + expect(error).toEqual(originalError); + return transformedError; + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + + const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toEqual(transformedError); + }); + + test('transform error with sourced callback', async () => { + const originalError = { message: 'Original error' }; + const $suffix = createStore('_suffix'); + + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + mapFailure: { + source: $suffix, + fn: ({ error }, suffix) => { + return { message: (error as any).message + suffix }; + }, + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + + const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toEqual({ + message: 'Original error_suffix', + }); + }); + + test('receives params in mapFailure', async () => { + const query = createJsonQuery({ + params: declareParams(), + request: { + url: 'http://api.salo.com', + method: 'GET' as const, + }, + response: { + contract: unknownContract, + mapFailure: ({ error, params }) => { + expect(params).toBe('test_params'); + return { ...error, params }; + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject({ message: 'error' })); + + const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + + await allSettled(query.start, { scope, params: 'test_params' }); + + expect(scope.getState(query.$error)).toMatchObject({ params: 'test_params' }); + }); + + describe('headers in mapFailure', () => { + test('HTTP 4xx error has headers', async () => { + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + mapFailure: ({ error, headers }) => { + expect(isHttpError({ error })).toBe(true); + expect(headers?.get('X-Error-Code')).toBe('CUSTOM_ERROR'); + return { error, hasHeaders: !!headers }; + }, + }, + }); + + // Mock at transport level to get proper headers flow + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ error: 'Not Found' }), { + status: 404, + statusText: 'Not Found', + headers: { 'X-Error-Code': 'CUSTOM_ERROR' }, + }), + ], + ], + }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toMatchObject({ hasHeaders: true }); + }); + + test('HTTP 5xx error has headers', async () => { + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + mapFailure: ({ error, headers }) => { + expect(isHttpError({ error })).toBe(true); + expect(headers?.get('X-Server-Error')).toBe('DB_DOWN'); + return { error, serverHeader: headers?.get('X-Server-Error') }; + }, + }, + }); + + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ error: 'Internal Server Error' }), { + status: 500, + statusText: 'Internal Server Error', + headers: { 'X-Server-Error': 'DB_DOWN' }, + }), + ], + ], + }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toMatchObject({ + serverHeader: 'DB_DOWN', + }); + }); + + test('network error has no headers', async () => { + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + mapFailure: ({ error, headers }) => { + expect(isNetworkError({ error })).toBe(true); + expect(headers).toBeUndefined(); + return { error, hasHeaders: !!headers }; + }, + }, + }); + + const scope = fork({ + handlers: [ + [ + fetchFx, + () => Promise.reject(new TypeError('Network error')), + ], + ], + }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toMatchObject({ hasHeaders: false }); + }); + + test('contract error has headers from successful response', async () => { + const failingContract = { + isData: (raw: unknown): raw is never => false, + getErrorMessages: () => ['Contract validation failed'], + }; + + const query = createJsonQuery({ + request, + response: { + contract: failingContract, + mapFailure: ({ error, headers }) => { + // Contract errors occur after successful HTTP response + expect(headers?.get('X-Request-Id')).toBe('req-123'); + return { error, requestId: headers?.get('X-Request-Id') }; + }, + }, + }); + + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ data: 'invalid' }), { + status: 200, + headers: { 'X-Request-Id': 'req-123' }, + }), + ], + ], + }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toMatchObject({ + requestId: 'req-123', + }); + }); + }); + + test('without mapFailure, error passes through unchanged', async () => { + const originalError = { message: 'Original error' }; + + const query = createJsonQuery({ + request, + response: { + contract: unknownContract, + // No mapFailure provided + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + + const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + + await allSettled(query.start, { scope }); + + expect(scope.getState(query.$error)).toEqual(originalError); + }); +}); + diff --git a/packages/core/src/query/create_headless_query.ts b/packages/core/src/query/create_headless_query.ts index f6ed74580..904c30cdf 100644 --- a/packages/core/src/query/create_headless_query.ts +++ b/packages/core/src/query/create_headless_query.ts @@ -47,8 +47,10 @@ export function createHeadlessQuery< Error, ContractData extends Response, MappedData, - MapDataSource, - ValidationSource, + MappedError = Error | InvalidDataError, + MapDataSource = void, + MapFailureSource = void, + ValidationSource = void, Initial = null, >( config: { @@ -59,15 +61,21 @@ export function createHeadlessQuery< MappedData, MapDataSource >; + mapFailure?: DynamicallySourcedField< + { error: Error | InvalidDataError; params: Params; headers?: Headers }, + MappedError, + MapFailureSource + >; validate?: Validator; sourced?: SourcedField[]; paramsAreMeaningless?: boolean; } & SharedQueryFactoryConfig -): Query { +): Query { const { initialData: initialDataRaw, contract, mapData, + mapFailure, enabled, validate, name, @@ -83,8 +91,10 @@ export function createHeadlessQuery< ContractData, MappedData, Error, + MappedError, QueryMeta, MapDataSource, + MapFailureSource, ValidationSource >({ name: name ?? getFactoryName(), @@ -99,6 +109,7 @@ export function createHeadlessQuery< contract, validate, mapData, + mapFailure, sourced, paramsAreMeaningless, }); @@ -112,7 +123,7 @@ export function createHeadlessQuery< serialize, skipVoid: false, }); - const $error = createStore(null, { + const $error = createStore(null, { sid: `ff.${operation.__.meta.name}.$error`, name: `${operation.__.meta.name}.$error`, serialize: serializationForSideStore(serialize), diff --git a/packages/core/src/query/create_json_query.ts b/packages/core/src/query/create_json_query.ts index 65c897bfd..36995e36c 100644 --- a/packages/core/src/query/create_json_query.ts +++ b/packages/core/src/query/create_json_query.ts @@ -102,6 +102,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigWithParams< @@ -119,6 +120,11 @@ export function createJsonQuery< TransformedData, DataSource >; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -133,6 +139,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigWithParams< @@ -151,6 +158,11 @@ export function createJsonQuery< TransformedData, DataSource >; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -164,6 +176,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigWithParams< @@ -176,6 +189,11 @@ export function createJsonQuery< > & { response: { contract: Contract; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -188,6 +206,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigWithParams< @@ -201,6 +220,11 @@ export function createJsonQuery< initialData?: Data; response: { contract: Contract; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -215,6 +239,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigNoParams< @@ -231,6 +256,11 @@ export function createJsonQuery< TransformedData, DataSource >; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -244,6 +274,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigNoParams< @@ -261,6 +292,11 @@ export function createJsonQuery< TransformedData, DataSource >; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -273,6 +309,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigNoParams< @@ -284,6 +321,11 @@ export function createJsonQuery< > & { response: { contract: Contract; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -295,6 +337,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonQueryConfigNoParams< @@ -307,6 +350,11 @@ export function createJsonQuery< initialData?: Data; response: { contract: Contract; + mapFailure?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; }; } @@ -333,11 +381,14 @@ export function createJsonQuery(config: any) { any, any, any, + any, + any, any >({ initialData: config.initialData, contract: config.response.contract ?? unknownContract, mapData: config.response.mapData ?? (({ result }) => result), + mapFailure: config.response.mapFailure, validate: config.response.validate, enabled: config.enabled, name: config.name, diff --git a/packages/core/src/query/create_query.ts b/packages/core/src/query/create_query.ts index b69fb7455..93bbb0f63 100644 --- a/packages/core/src/query/create_query.ts +++ b/packages/core/src/query/create_query.ts @@ -196,13 +196,16 @@ export function createQuery< Error, ContractData, MappedData, + any, MapDataSource, + any, ValidationSource, MappedData >({ initialData: config.initialData ?? null, contract: config.contract ?? unknownContract, mapData: config.mapData ?? (({ result }) => result), + mapFailure: config.mapFailure, enabled: config.enabled, validate: config.validate, name: config.name, diff --git a/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts b/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts index d9631f5a3..b88c3f64b 100644 --- a/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts +++ b/packages/core/src/remote_operation/__test__/create_remote_operation.test.ts @@ -476,6 +476,7 @@ describe('RemoteOperation and onAbort callback', () => { ], }, "meta": { + "responseMeta": undefined, "stale": false, "stopErrorPropagation": false, }, @@ -529,6 +530,7 @@ describe('RemoteOperation and onAbort callback', () => { ], }, "meta": { + "responseMeta": undefined, "stale": false, "stopErrorPropagation": false, }, diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts index c8396107f..ab6c56357 100644 --- a/packages/core/src/remote_operation/create_remote_operation.ts +++ b/packages/core/src/remote_operation/create_remote_operation.ts @@ -38,8 +38,10 @@ export function createRemoteOperation< ContractData extends Data, MappedData, Error, - Meta, + MappedError = Error | InvalidDataError, + Meta = unknown, MapDataSource = void, + MapFailureSource = void, ValidationSource = void, >({ name: ownName, @@ -50,6 +52,7 @@ export function createRemoteOperation< contract, validate, mapData, + mapFailure, sourced, paramsAreMeaningless, }: { @@ -65,14 +68,22 @@ export function createRemoteOperation< MappedData, MapDataSource >; + mapFailure?: DynamicallySourcedField< + { error: Error | InvalidDataError; params: Params; headers?: Headers }, + MappedError, + MapFailureSource + >; sourced?: SourcedField[]; paramsAreMeaningless?: boolean; -}): RemoteOperation { +}): RemoteOperation { const revalidate = createEvent<{ params: Params; refresh: boolean }>(); const pushData = createEvent(); - const pushError = createEvent(); + const pushError = createEvent(); const startWithMeta = createEvent<{ params: Params; meta: ExecutionMeta }>(); + // Default mapFailure to identity function + const effectiveMapFailure = mapFailure ?? (({ error }) => error as MappedError); + const applyContractFx = createContractApplier( contract ); @@ -142,7 +153,7 @@ export function createRemoteOperation< }>(), failure: createEvent<{ params: Params; - error: Error | InvalidDataError; + error: MappedError; meta: ExecutionMeta; }>(), skip: createEvent<{ params: Params; meta: ExecutionMeta }>(), @@ -152,16 +163,22 @@ export function createRemoteOperation< status: 'done'; result: MappedData; } - | { status: 'fail'; error: Error | InvalidDataError } + | { status: 'fail'; error: MappedError } | { status: 'skip' } ) >(), }; - const failedNoFilters = createEvent<{ + // Intermediate event before mapFailure is applied + const failedBeforeMap = createEvent<{ params: Params; error: Error | InvalidDataError; meta: ExecutionMeta; }>(); + const failedNoFilters = createEvent<{ + params: Params; + error: MappedError; + meta: ExecutionMeta; + }>(); const failedIgnoreSuppression = createEvent<{ params: Params; error: Error | InvalidDataError; @@ -172,6 +189,26 @@ export function createRemoteOperation< meta: ExecutionMeta; }>(); + // Apply mapFailure transformation + sample({ + clock: failedBeforeMap, + source: { + partialMapper: normalizeSourced({ + field: effectiveMapFailure, + }), + }, + fn: ({ partialMapper }, { error, params, meta }) => ({ + error: partialMapper({ + error, + params, + ...(metaHasResponseMeta(meta) ? meta.responseMeta : {}), + }), + params, + meta, + }), + target: failedNoFilters, + }); + split({ source: failedNoFilters, match: { @@ -315,9 +352,13 @@ export function createRemoteOperation< fn: (_, { error, params }) => ({ error: error.error as any, params: params.params, - meta: { stopErrorPropagation: error.stopErrorPropagation, stale: false }, + meta: { + stopErrorPropagation: error.stopErrorPropagation, + stale: false, + responseMeta: error.responseMeta, + }, }), - target: failedNoFilters, + target: failedBeforeMap, }); const { validDataRecieved, __: invalidDataRecieved } = split( @@ -393,7 +434,7 @@ export function createRemoteOperation< params: params.params, meta: params.meta, }), - target: failedNoFilters, + target: failedBeforeMap, }); sample({ @@ -418,7 +459,7 @@ export function createRemoteOperation< }), meta, }), - target: failedNoFilters, + target: failedBeforeMap, }); sample({ @@ -510,7 +551,9 @@ export function createRemoteOperation< pushData, startWithMeta, callObjectCreated, - failedIgnoreSuppression, + // Cast to any because failedIgnoreSuppression fires before mapFailure is applied, + // so it has the original error type, not MappedError + failedIgnoreSuppression: failedIgnoreSuppression as any, }, }, }; @@ -533,7 +576,11 @@ function createDataSourceHandlers(dataSources: DataSource[]) { stopErrorPropagation?: boolean; meta?: unknown; }, - { stopErrorPropagation: boolean; error: unknown } + { + stopErrorPropagation: boolean; + error: unknown; + responseMeta?: { headers: Headers }; + } >({ handler: async ({ params, skipStale }) => { for (const dataSource of dataSources) { @@ -547,10 +594,11 @@ function createDataSourceHandlers(dataSources: DataSource[]) { if (fromSource) { return fromSource; } - } catch (error) { + } catch (error: any) { throw { stopErrorPropagation: false, - error, + error: error.error ?? error, + responseMeta: error.responseMeta, }; } } diff --git a/packages/core/src/retry/__tests__/retry.query.test.ts b/packages/core/src/retry/__tests__/retry.query.test.ts index 5f610c5be..300c21565 100644 --- a/packages/core/src/retry/__tests__/retry.query.test.ts +++ b/packages/core/src/retry/__tests__/retry.query.test.ts @@ -449,6 +449,7 @@ describe('retry with query', () => { { "error": [Error: Sorry, attempt 1], "meta": { + "responseMeta": undefined, "stale": false, "stopErrorPropagation": false, }, From 093467bb9bd1498bec290bb020bd09d48a9deae0 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 25 Dec 2025 18:43:16 +0700 Subject: [PATCH 02/15] Add docs --- .../docs/api/factories/create_json_query.md | 11 ++++ .../docs/api/factories/create_query.md | 52 +++++++++++++++++++ apps/website/docs/recipes/data_flow.md | 45 ++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/apps/website/docs/api/factories/create_json_query.md b/apps/website/docs/api/factories/create_json_query.md index f50165af2..a137be763 100644 --- a/apps/website/docs/api/factories/create_json_query.md +++ b/apps/website/docs/api/factories/create_json_query.md @@ -40,6 +40,17 @@ Config fields: - `params`: params which were passed to the [_Query_](/api/primitives/query) - `headers`: raw response headers + - `mapFailure?`: optional mapper for the error data, available overloads: + + - `(err) => mapped` + - `{ source: Store, fn: (data, err) => mapped }` + + `err` object contains: + + - `error`: the error that occurred (can be `HttpError`, `NetworkError`, `InvalidDataError`, or `PreparationError`) + - `params`: params which were passed to the [_Query_](/api/primitives/query) + - `headers`: raw response headers (available for HTTP errors and contract/validation errors, not available for network errors) + - `concurrency?`: concurrency settings for the [_Query_](/api/primitives/query) ::: danger Deprecation warning diff --git a/apps/website/docs/api/factories/create_query.md b/apps/website/docs/api/factories/create_query.md index f794d66a4..9694d9ec5 100644 --- a/apps/website/docs/api/factories/create_query.md +++ b/apps/website/docs/api/factories/create_query.md @@ -126,3 +126,55 @@ const languagesQuery = createQuery({ * }> */ ``` + +### `createQuery({ effect, contract?, validate?, mapData?, mapFailure: Function, initialData? })` + +Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapFailure` callback, and the result of the callback will be treated as the error of the [_Query_](/api/primitives/query). + +```ts +const languagesQuery = createQuery({ + effect: fetchLanguagesFx, + contract: languagesContract, + mapFailure({ error, params }) { + // Transform any error into a user-friendly message + if (isHttpError({ status: 404, error })) { + return { code: 'NOT_FOUND', message: 'Languages not found' }; + } + return { code: 'UNKNOWN', message: 'Failed to fetch languages' }; + }, +}); + +/* typeof languagesQuery.$error === Store<{ + * code: string, + * message: string, + * } | null> + */ +``` + +### `createQuery({ effect, contract?, validate?, mapData?, mapFailure: { source, fn }, initialData? })` + +Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapFailure.fn` callback as well as original parameters of the [_Query_](/api/primitives/query) and current value of `mapFailure.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Query_](/api/primitives/query). + +```ts +const $errorMessages = createStore({ + 404: 'Resource not found', + 500: 'Server error', +}); + +const languagesQuery = createQuery({ + effect: fetchLanguagesFx, + contract: languagesContract, + mapFailure: { + source: $errorMessages, + fn({ error, params }, errorMessages) { + if (isHttpError({ error })) { + return { + message: errorMessages[error.status] ?? 'Unknown error', + status: error.status, + }; + } + return { message: 'Network error', status: null }; + }, + }, +}); +``` diff --git a/apps/website/docs/recipes/data_flow.md b/apps/website/docs/recipes/data_flow.md index 11d69a168..de153e086 100644 --- a/apps/website/docs/recipes/data_flow.md +++ b/apps/website/docs/recipes/data_flow.md @@ -19,15 +19,19 @@ sequenceDiagram activate S S->>C: response deactivate S + C->>C: apply error mapper C-->>A: finished.failed C->>C: parse response + C->>C: apply error mapper C-->>A: finished.failed C->>C: apply contract + C->>C: apply error mapper C-->>A: finished.failed C->>C: apply validator + C->>C: apply error mapper C-->>A: finished.failed C->>C: apply data mapper @@ -133,6 +137,47 @@ const userQuery = createJsonQuery({ }); ``` +### Error mapping + +This is optional stage. If any of the previous stages fail, you can define a mapper to transform the error to the desired format before it reaches `.finished.failure` [_Event_](https://effector.dev/en/api/effector/event/) and `.$error` [_Store_](https://effector.dev/en/api/effector/store/). + +::: warning +Error mappers have to be pure function, so they are not allowed to throw an error. If the mapper throws an error, the data-flow stops immediately without any error handling. +::: + +Since error mapper is a [_Sourced_](/api/primitives/sourced), it's possible to add some extra data from the application to the mapping process. For example, it could be localized error messages: + +```ts +const $errorMessages = createStore({ + 404: 'User not found', + 500: 'Server error, please try again later', +}); + +const userQuery = createJsonQuery({ + //... + response: { + mapFailure: { + source: $errorMessages, + fn: ({ error, headers }, messages) => { + if (isHttpError({ error })) { + return { + message: messages[error.status] ?? 'Unknown error', + requestId: headers?.get('X-Request-Id'), + }; + } + return { message: 'Network error', requestId: null }; + }, + }, + }, +}); +``` + +The error mapper receives the following data: + +- `error`: the original error that occurred +- `params`: the parameters that were passed to the [_Query_](/api/primitives/query) +- `headers`: raw response headers (available for HTTP errors and contract/validation errors where the response was received, not available for network errors) + ## Data-flow in basic factories **Basic factories** are used to create _Remote Operations_ with a more control of data-flow in user-land. In this case, the user-land code have to describe **request-response cycle** and **response parsing** stages. Other stages could be handled by the library, but it is not required for **basic factories**. From 4163c6a6bdf86f7cbe3e6fb2d88ed3b4a2854b3f Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 25 Dec 2025 18:46:47 +0700 Subject: [PATCH 03/15] Added a new feature - `mapFailure` mapper to all Queries --- .changeset/popular-panthers-hope.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/popular-panthers-hope.md diff --git a/.changeset/popular-panthers-hope.md b/.changeset/popular-panthers-hope.md new file mode 100644 index 000000000..c145884b1 --- /dev/null +++ b/.changeset/popular-panthers-hope.md @@ -0,0 +1,5 @@ +--- +"@farfetched/core": minor +--- + +Added a new feature - `mapFailure` mapper to all Queries From a273aa83ac9eaece43a587d3a7dd8994b9e2ef8a Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 25 Dec 2025 18:52:28 +0700 Subject: [PATCH 04/15] Bump size-limit --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 681030320..0a575ed2a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,7 +42,7 @@ "size-limit": [ { "path": "./dist/core.js", - "limit": "16 kB" + "limit": "16.17 kB" } ] } From 12e2f5cde39eb4762a72641586ed66be7f9d11ef Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 25 Dec 2025 18:53:49 +0700 Subject: [PATCH 05/15] Fix formatting issues --- .../create_json_query.response.map_failure.test.ts | 10 ++++------ .../src/remote_operation/create_remote_operation.ts | 3 ++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts index bf4ad6c21..88f50317e 100644 --- a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts +++ b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts @@ -88,7 +88,9 @@ describe('remote_data/query/json.response.map_failure', () => { await allSettled(query.start, { scope, params: 'test_params' }); - expect(scope.getState(query.$error)).toMatchObject({ params: 'test_params' }); + expect(scope.getState(query.$error)).toMatchObject({ + params: 'test_params', + }); }); describe('headers in mapFailure', () => { @@ -174,10 +176,7 @@ describe('remote_data/query/json.response.map_failure', () => { const scope = fork({ handlers: [ - [ - fetchFx, - () => Promise.reject(new TypeError('Network error')), - ], + [fetchFx, () => Promise.reject(new TypeError('Network error'))], ], }); @@ -245,4 +244,3 @@ describe('remote_data/query/json.response.map_failure', () => { expect(scope.getState(query.$error)).toEqual(originalError); }); }); - diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts index ab6c56357..f8aa210fe 100644 --- a/packages/core/src/remote_operation/create_remote_operation.ts +++ b/packages/core/src/remote_operation/create_remote_operation.ts @@ -82,7 +82,8 @@ export function createRemoteOperation< const startWithMeta = createEvent<{ params: Params; meta: ExecutionMeta }>(); // Default mapFailure to identity function - const effectiveMapFailure = mapFailure ?? (({ error }) => error as MappedError); + const effectiveMapFailure = + mapFailure ?? (({ error }) => error as MappedError); const applyContractFx = createContractApplier( contract From 99cc884f3cacd7df171fcfad281ab344ed4cb080 Mon Sep 17 00:00:00 2001 From: Alexander Khoroshikh <32790736+AlexandrHoroshih@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:13:20 +0700 Subject: [PATCH 06/15] Update apps/website/docs/api/factories/create_json_query.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/website/docs/api/factories/create_json_query.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/docs/api/factories/create_json_query.md b/apps/website/docs/api/factories/create_json_query.md index a137be763..caf4c147e 100644 --- a/apps/website/docs/api/factories/create_json_query.md +++ b/apps/website/docs/api/factories/create_json_query.md @@ -43,7 +43,7 @@ Config fields: - `mapFailure?`: optional mapper for the error data, available overloads: - `(err) => mapped` - - `{ source: Store, fn: (data, err) => mapped }` + - `{ source: Store, fn: (err, data) => mapped }` `err` object contains: From c9f7b53d81965ecf46a5cc131c723083fc15cfea Mon Sep 17 00:00:00 2001 From: Alexander Khoroshikh <32790736+AlexandrHoroshih@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:13:59 +0700 Subject: [PATCH 07/15] Update apps/website/docs/recipes/data_flow.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/website/docs/recipes/data_flow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/docs/recipes/data_flow.md b/apps/website/docs/recipes/data_flow.md index de153e086..a1012e27c 100644 --- a/apps/website/docs/recipes/data_flow.md +++ b/apps/website/docs/recipes/data_flow.md @@ -142,7 +142,7 @@ const userQuery = createJsonQuery({ This is optional stage. If any of the previous stages fail, you can define a mapper to transform the error to the desired format before it reaches `.finished.failure` [_Event_](https://effector.dev/en/api/effector/event/) and `.$error` [_Store_](https://effector.dev/en/api/effector/store/). ::: warning -Error mappers have to be pure function, so they are not allowed to throw an error. If the mapper throws an error, the data-flow stops immediately without any error handling. +Error mappers have to be pure functions, so they are not allowed to throw an error. If the mapper throws an error, the data-flow stops immediately without any error handling. ::: Since error mapper is a [_Sourced_](/api/primitives/sourced), it's possible to add some extra data from the application to the mapping process. For example, it could be localized error messages: From 3f2d8e21422007eef176db5c6ee40edf54cac9e4 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 12:48:17 +0700 Subject: [PATCH 08/15] `mapFailure` -> `mapError` --- .changeset/popular-panthers-hope.md | 2 +- .../docs/api/factories/create_json_query.md | 2 +- .../docs/api/factories/create_query.md | 12 +++++----- apps/website/docs/recipes/data_flow.md | 2 +- ...te_json_query.response.map_failure.test.ts | 22 +++++++++---------- .../core/src/query/create_headless_query.ts | 12 +++++----- packages/core/src/query/create_json_query.ts | 18 +++++++-------- packages/core/src/query/create_query.ts | 2 +- .../create_remote_operation.ts | 22 +++++++++---------- 9 files changed, 47 insertions(+), 47 deletions(-) diff --git a/.changeset/popular-panthers-hope.md b/.changeset/popular-panthers-hope.md index c145884b1..e1b62024d 100644 --- a/.changeset/popular-panthers-hope.md +++ b/.changeset/popular-panthers-hope.md @@ -2,4 +2,4 @@ "@farfetched/core": minor --- -Added a new feature - `mapFailure` mapper to all Queries +Added a new feature - `mapError` mapper to all Queries diff --git a/apps/website/docs/api/factories/create_json_query.md b/apps/website/docs/api/factories/create_json_query.md index caf4c147e..cca95f089 100644 --- a/apps/website/docs/api/factories/create_json_query.md +++ b/apps/website/docs/api/factories/create_json_query.md @@ -40,7 +40,7 @@ Config fields: - `params`: params which were passed to the [_Query_](/api/primitives/query) - `headers`: raw response headers - - `mapFailure?`: optional mapper for the error data, available overloads: + - `mapError?`: optional mapper for the error data, available overloads: - `(err) => mapped` - `{ source: Store, fn: (err, data) => mapped }` diff --git a/apps/website/docs/api/factories/create_query.md b/apps/website/docs/api/factories/create_query.md index 9694d9ec5..2b74898d8 100644 --- a/apps/website/docs/api/factories/create_query.md +++ b/apps/website/docs/api/factories/create_query.md @@ -127,15 +127,15 @@ const languagesQuery = createQuery({ */ ``` -### `createQuery({ effect, contract?, validate?, mapData?, mapFailure: Function, initialData? })` +### `createQuery({ effect, contract?, validate?, mapData?, mapError: Function, initialData? })` -Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapFailure` callback, and the result of the callback will be treated as the error of the [_Query_](/api/primitives/query). +Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapError` callback, and the result of the callback will be treated as the error of the [_Query_](/api/primitives/query). ```ts const languagesQuery = createQuery({ effect: fetchLanguagesFx, contract: languagesContract, - mapFailure({ error, params }) { + mapError({ error, params }) { // Transform any error into a user-friendly message if (isHttpError({ status: 404, error })) { return { code: 'NOT_FOUND', message: 'Languages not found' }; @@ -151,9 +151,9 @@ const languagesQuery = createQuery({ */ ``` -### `createQuery({ effect, contract?, validate?, mapData?, mapFailure: { source, fn }, initialData? })` +### `createQuery({ effect, contract?, validate?, mapData?, mapError: { source, fn }, initialData? })` -Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapFailure.fn` callback as well as original parameters of the [_Query_](/api/primitives/query) and current value of `mapFailure.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Query_](/api/primitives/query). +Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapError.fn` callback as well as original parameters of the [_Query_](/api/primitives/query) and current value of `mapError.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Query_](/api/primitives/query). ```ts const $errorMessages = createStore({ @@ -164,7 +164,7 @@ const $errorMessages = createStore({ const languagesQuery = createQuery({ effect: fetchLanguagesFx, contract: languagesContract, - mapFailure: { + mapError: { source: $errorMessages, fn({ error, params }, errorMessages) { if (isHttpError({ error })) { diff --git a/apps/website/docs/recipes/data_flow.md b/apps/website/docs/recipes/data_flow.md index a1012e27c..30f709459 100644 --- a/apps/website/docs/recipes/data_flow.md +++ b/apps/website/docs/recipes/data_flow.md @@ -156,7 +156,7 @@ const $errorMessages = createStore({ const userQuery = createJsonQuery({ //... response: { - mapFailure: { + mapError: { source: $errorMessages, fn: ({ error, headers }, messages) => { if (isHttpError({ error })) { diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts index 88f50317e..9b10e92a4 100644 --- a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts +++ b/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts @@ -22,7 +22,7 @@ describe('remote_data/query/json.response.map_failure', () => { request, response: { contract: unknownContract, - mapFailure: ({ error }) => { + mapError: ({ error }) => { expect(error).toEqual(originalError); return transformedError; }, @@ -46,7 +46,7 @@ describe('remote_data/query/json.response.map_failure', () => { request, response: { contract: unknownContract, - mapFailure: { + mapError: { source: $suffix, fn: ({ error }, suffix) => { return { message: (error as any).message + suffix }; @@ -66,7 +66,7 @@ describe('remote_data/query/json.response.map_failure', () => { }); }); - test('receives params in mapFailure', async () => { + test('receives params in mapError', async () => { const query = createJsonQuery({ params: declareParams(), request: { @@ -75,7 +75,7 @@ describe('remote_data/query/json.response.map_failure', () => { }, response: { contract: unknownContract, - mapFailure: ({ error, params }) => { + mapError: ({ error, params }) => { expect(params).toBe('test_params'); return { ...error, params }; }, @@ -93,13 +93,13 @@ describe('remote_data/query/json.response.map_failure', () => { }); }); - describe('headers in mapFailure', () => { + describe('headers in mapError', () => { test('HTTP 4xx error has headers', async () => { const query = createJsonQuery({ request, response: { contract: unknownContract, - mapFailure: ({ error, headers }) => { + mapError: ({ error, headers }) => { expect(isHttpError({ error })).toBe(true); expect(headers?.get('X-Error-Code')).toBe('CUSTOM_ERROR'); return { error, hasHeaders: !!headers }; @@ -132,7 +132,7 @@ describe('remote_data/query/json.response.map_failure', () => { request, response: { contract: unknownContract, - mapFailure: ({ error, headers }) => { + mapError: ({ error, headers }) => { expect(isHttpError({ error })).toBe(true); expect(headers?.get('X-Server-Error')).toBe('DB_DOWN'); return { error, serverHeader: headers?.get('X-Server-Error') }; @@ -166,7 +166,7 @@ describe('remote_data/query/json.response.map_failure', () => { request, response: { contract: unknownContract, - mapFailure: ({ error, headers }) => { + mapError: ({ error, headers }) => { expect(isNetworkError({ error })).toBe(true); expect(headers).toBeUndefined(); return { error, hasHeaders: !!headers }; @@ -195,7 +195,7 @@ describe('remote_data/query/json.response.map_failure', () => { request, response: { contract: failingContract, - mapFailure: ({ error, headers }) => { + mapError: ({ error, headers }) => { // Contract errors occur after successful HTTP response expect(headers?.get('X-Request-Id')).toBe('req-123'); return { error, requestId: headers?.get('X-Request-Id') }; @@ -224,14 +224,14 @@ describe('remote_data/query/json.response.map_failure', () => { }); }); - test('without mapFailure, error passes through unchanged', async () => { + test('without mapError, error passes through unchanged', async () => { const originalError = { message: 'Original error' }; const query = createJsonQuery({ request, response: { contract: unknownContract, - // No mapFailure provided + // No mapError provided }, }); diff --git a/packages/core/src/query/create_headless_query.ts b/packages/core/src/query/create_headless_query.ts index 904c30cdf..f2f571099 100644 --- a/packages/core/src/query/create_headless_query.ts +++ b/packages/core/src/query/create_headless_query.ts @@ -49,7 +49,7 @@ export function createHeadlessQuery< MappedData, MappedError = Error | InvalidDataError, MapDataSource = void, - MapFailureSource = void, + mapErrorSource = void, ValidationSource = void, Initial = null, >( @@ -61,10 +61,10 @@ export function createHeadlessQuery< MappedData, MapDataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: Error | InvalidDataError; params: Params; headers?: Headers }, MappedError, - MapFailureSource + mapErrorSource >; validate?: Validator; sourced?: SourcedField[]; @@ -75,7 +75,7 @@ export function createHeadlessQuery< initialData: initialDataRaw, contract, mapData, - mapFailure, + mapError, enabled, validate, name, @@ -94,7 +94,7 @@ export function createHeadlessQuery< MappedError, QueryMeta, MapDataSource, - MapFailureSource, + mapErrorSource, ValidationSource >({ name: name ?? getFactoryName(), @@ -109,7 +109,7 @@ export function createHeadlessQuery< contract, validate, mapData, - mapFailure, + mapError, sourced, paramsAreMeaningless, }); diff --git a/packages/core/src/query/create_json_query.ts b/packages/core/src/query/create_json_query.ts index 36995e36c..45fbca6e1 100644 --- a/packages/core/src/query/create_json_query.ts +++ b/packages/core/src/query/create_json_query.ts @@ -120,7 +120,7 @@ export function createJsonQuery< TransformedData, DataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, unknown, FailureSource @@ -158,7 +158,7 @@ export function createJsonQuery< TransformedData, DataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, unknown, FailureSource @@ -189,7 +189,7 @@ export function createJsonQuery< > & { response: { contract: Contract; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, unknown, FailureSource @@ -220,7 +220,7 @@ export function createJsonQuery< initialData?: Data; response: { contract: Contract; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, unknown, FailureSource @@ -256,7 +256,7 @@ export function createJsonQuery< TransformedData, DataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, unknown, FailureSource @@ -292,7 +292,7 @@ export function createJsonQuery< TransformedData, DataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, unknown, FailureSource @@ -321,7 +321,7 @@ export function createJsonQuery< > & { response: { contract: Contract; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, unknown, FailureSource @@ -350,7 +350,7 @@ export function createJsonQuery< initialData?: Data; response: { contract: Contract; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, unknown, FailureSource @@ -388,7 +388,7 @@ export function createJsonQuery(config: any) { initialData: config.initialData, contract: config.response.contract ?? unknownContract, mapData: config.response.mapData ?? (({ result }) => result), - mapFailure: config.response.mapFailure, + mapError: config.response.mapError, validate: config.response.validate, enabled: config.enabled, name: config.name, diff --git a/packages/core/src/query/create_query.ts b/packages/core/src/query/create_query.ts index 93bbb0f63..4bebe60ff 100644 --- a/packages/core/src/query/create_query.ts +++ b/packages/core/src/query/create_query.ts @@ -205,7 +205,7 @@ export function createQuery< initialData: config.initialData ?? null, contract: config.contract ?? unknownContract, mapData: config.mapData ?? (({ result }) => result), - mapFailure: config.mapFailure, + mapError: config.mapError, enabled: config.enabled, validate: config.validate, name: config.name, diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts index f8aa210fe..74b3a3851 100644 --- a/packages/core/src/remote_operation/create_remote_operation.ts +++ b/packages/core/src/remote_operation/create_remote_operation.ts @@ -41,7 +41,7 @@ export function createRemoteOperation< MappedError = Error | InvalidDataError, Meta = unknown, MapDataSource = void, - MapFailureSource = void, + mapErrorSource = void, ValidationSource = void, >({ name: ownName, @@ -52,7 +52,7 @@ export function createRemoteOperation< contract, validate, mapData, - mapFailure, + mapError, sourced, paramsAreMeaningless, }: { @@ -68,10 +68,10 @@ export function createRemoteOperation< MappedData, MapDataSource >; - mapFailure?: DynamicallySourcedField< + mapError?: DynamicallySourcedField< { error: Error | InvalidDataError; params: Params; headers?: Headers }, MappedError, - MapFailureSource + mapErrorSource >; sourced?: SourcedField[]; paramsAreMeaningless?: boolean; @@ -81,9 +81,9 @@ export function createRemoteOperation< const pushError = createEvent(); const startWithMeta = createEvent<{ params: Params; meta: ExecutionMeta }>(); - // Default mapFailure to identity function - const effectiveMapFailure = - mapFailure ?? (({ error }) => error as MappedError); + // Default mapError to identity function + const effectivemapError = + mapError ?? (({ error }) => error as MappedError); const applyContractFx = createContractApplier( contract @@ -169,7 +169,7 @@ export function createRemoteOperation< ) >(), }; - // Intermediate event before mapFailure is applied + // Intermediate event before mapError is applied const failedBeforeMap = createEvent<{ params: Params; error: Error | InvalidDataError; @@ -190,12 +190,12 @@ export function createRemoteOperation< meta: ExecutionMeta; }>(); - // Apply mapFailure transformation + // Apply mapError transformation sample({ clock: failedBeforeMap, source: { partialMapper: normalizeSourced({ - field: effectiveMapFailure, + field: effectivemapError, }), }, fn: ({ partialMapper }, { error, params, meta }) => ({ @@ -552,7 +552,7 @@ export function createRemoteOperation< pushData, startWithMeta, callObjectCreated, - // Cast to any because failedIgnoreSuppression fires before mapFailure is applied, + // Cast to any because failedIgnoreSuppression fires before mapError is applied, // so it has the original error type, not MappedError failedIgnoreSuppression: failedIgnoreSuppression as any, }, From 16fd363ac20c64b6e8524065529841645cb5e03e Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 13:02:52 +0700 Subject: [PATCH 09/15] Support mapError for Mutations --- .../api/factories/create_json_mutation.md | 85 +++++ .../docs/api/factories/create_mutation.md | 51 +++ apps/website/docs/recipes/data_flow.md | 29 +- ...json_mutation.response.map_failure.test.ts | 338 ++++++++++++++++++ .../src/mutation/create_headless_mutation.ts | 16 +- .../core/src/mutation/create_json_mutation.ts | 25 ++ packages/core/src/mutation/create_mutation.ts | 41 +++ 7 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts diff --git a/apps/website/docs/api/factories/create_json_mutation.md b/apps/website/docs/api/factories/create_json_mutation.md index 766d0f695..440f4cbab 100644 --- a/apps/website/docs/api/factories/create_json_mutation.md +++ b/apps/website/docs/api/factories/create_json_mutation.md @@ -38,6 +38,17 @@ Config fields: - `params`: params which were passed to the [_Mutation_](/api/primitives/mutation) - `headers`: raw response headers + - `mapError?`: optional mapper for the error, available overloads: + + - `(err) => mapped` + - `{ source: Store, fn: (err, sourceValue) => mapped }` + + `err` object contains: + + - `error`: the original error that occurred + - `params`: params which were passed to the [_Mutation_](/api/primitives/mutation) + - `headers`: raw response headers (available for HTTP errors and contract/validation errors where the response was received, not available for network errors) + - `status.expected`: `number` or `Array` of expected HTTP status codes, if the response status code is not in the list, the mutation will be treated as failed - `concurrency?`: concurrency settings for the [_Mutation_](/api/primitives/mutation) @@ -50,3 +61,77 @@ Config fields: ::: - `abort?`: [_Event_](https://effector.dev/en/api/effector/event/) after calling which all in-flight requests will be aborted + +## Examples + +### Error mapping + +You can transform errors before they are passed to the [_Mutation_](/api/primitives/mutation) using `mapError`: + +```ts +const loginMutation = createJsonMutation({ + params: declareParams<{ login: string; password: string }>(), + request: { + method: 'POST', + url: 'https://api.salo.com/login', + body: ({ login, password }) => ({ login, password }), + }, + response: { + contract: loginContract, + mapError({ error, params, headers }) { + if (isHttpError({ status: 401, error })) { + return { + type: 'unauthorized', + message: 'Invalid credentials', + requestId: headers?.get('X-Request-Id'), + }; + } + if (isHttpError({ status: 429, error })) { + return { + type: 'rate_limited', + message: 'Too many attempts, please try again later', + requestId: headers?.get('X-Request-Id'), + }; + } + return { + type: 'unknown', + message: 'Something went wrong', + requestId: headers?.get('X-Request-Id'), + }; + }, + }, +}); +``` + +With a sourced mapper: + +```ts +const $errorMessages = createStore({ + 401: 'Invalid credentials', + 429: 'Too many attempts', +}); + +const loginMutation = createJsonMutation({ + params: declareParams<{ login: string; password: string }>(), + request: { + method: 'POST', + url: 'https://api.salo.com/login', + body: ({ login, password }) => ({ login, password }), + }, + response: { + contract: loginContract, + mapError: { + source: $errorMessages, + fn({ error, headers }, messages) { + if (isHttpError({ error })) { + return { + message: messages[error.status] ?? 'Unknown error', + requestId: headers?.get('X-Request-Id'), + }; + } + return { message: 'Network error', requestId: null }; + }, + }, + }, +}); +``` diff --git a/apps/website/docs/api/factories/create_mutation.md b/apps/website/docs/api/factories/create_mutation.md index 176e5cfa7..252a1ea0d 100644 --- a/apps/website/docs/api/factories/create_mutation.md +++ b/apps/website/docs/api/factories/create_mutation.md @@ -71,3 +71,54 @@ const loginMutation = createMutation({ // params: { login: string, password: string } // }> ``` + +### `createMutation({ effect, contract?, mapError: Function })` + +Creates [_Mutation_](/api/primitives/mutation) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Mutation_](/api/primitives/mutation) fails, the error is passed to `mapError` callback, and the result of the callback will be treated as the error of the [_Mutation_](/api/primitives/mutation). + +```ts +const loginMutation = createMutation({ + effect: loginFx, + contract: loginContract, + mapError({ error, params }) { + // Transform any error into a user-friendly message + if (isHttpError({ status: 401, error })) { + return { code: 'UNAUTHORIZED', message: 'Invalid credentials' }; + } + return { code: 'UNKNOWN', message: 'Failed to login' }; + }, +}); + +// typeof loginMutation.finished.failure === Event<{ +// error: { code: string, message: string }, +// params: { login: string, password: string } +// }> +``` + +### `createMutation({ effect, contract?, mapError: { source, fn } })` + +Creates [_Mutation_](/api/primitives/mutation) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Mutation_](/api/primitives/mutation) fails, the error is passed to `mapError.fn` callback as well as original parameters of the [_Mutation_](/api/primitives/mutation) and current value of `mapError.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Mutation_](/api/primitives/mutation). + +```ts +const $errorMessages = createStore({ + 401: 'Invalid credentials', + 403: 'Access denied', +}); + +const loginMutation = createMutation({ + effect: loginFx, + contract: loginContract, + mapError: { + source: $errorMessages, + fn({ error, params }, errorMessages) { + if (isHttpError({ error })) { + return { + message: errorMessages[error.status] ?? 'Unknown error', + status: error.status, + }; + } + return { message: 'Network error', status: null }; + }, + }, +}); +``` diff --git a/apps/website/docs/recipes/data_flow.md b/apps/website/docs/recipes/data_flow.md index 30f709459..264c0bc5e 100644 --- a/apps/website/docs/recipes/data_flow.md +++ b/apps/website/docs/recipes/data_flow.md @@ -175,9 +175,36 @@ const userQuery = createJsonQuery({ The error mapper receives the following data: - `error`: the original error that occurred -- `params`: the parameters that were passed to the [_Query_](/api/primitives/query) +- `params`: the parameters that were passed to the [_Query_](/api/primitives/query) or [_Mutation_](/api/primitives/mutation) - `headers`: raw response headers (available for HTTP errors and contract/validation errors where the response was received, not available for network errors) +The same `mapError` option is available for [_Mutations_](/api/primitives/mutation): + +```ts +const $errorMessages = createStore({ + 401: 'Invalid credentials', + 429: 'Too many attempts', +}); + +const loginMutation = createJsonMutation({ + //... + response: { + mapError: { + source: $errorMessages, + fn: ({ error, headers }, messages) => { + if (isHttpError({ error })) { + return { + message: messages[error.status] ?? 'Unknown error', + requestId: headers?.get('X-Request-Id'), + }; + } + return { message: 'Network error', requestId: null }; + }, + }, + }, +}); +``` + ## Data-flow in basic factories **Basic factories** are used to create _Remote Operations_ with a more control of data-flow in user-land. In this case, the user-land code have to describe **request-response cycle** and **response parsing** stages. Other stages could be handled by the library, but it is not required for **basic factories**. diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts new file mode 100644 index 000000000..f4d60fabc --- /dev/null +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts @@ -0,0 +1,338 @@ +import { allSettled, createStore, fork } from 'effector'; +import { describe, test, expect, vi } from 'vitest'; + +import { unknownContract } from '../../contract/unknown_contract'; +import { createJsonMutation } from '../create_json_mutation'; +import { declareParams } from '../../remote_operation/params'; +import { fetchFx } from '../../fetch/fetch'; +import { isHttpError, isNetworkError } from '../../errors/guards'; + +describe('remote_data/mutation/json.response.map_failure', () => { + // Does not matter + const request = { + url: 'http://api.salo.com', + method: 'POST' as const, + }; + + test('transform error with simple callback', async () => { + const originalError = { message: 'Original error' }; + const transformedError = { message: 'Transformed error' }; + + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + mapError: ({ error }) => { + expect(error).toEqual(originalError); + return transformedError; + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); + + const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: transformedError, + }) + ); + }); + + test('transform error with sourced callback', async () => { + const originalError = { message: 'Original error' }; + const $suffix = createStore('_suffix'); + + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + mapError: { + source: $suffix, + fn: ({ error }, suffix) => { + return { message: (error as any).message + suffix }; + }, + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); + + const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: { message: 'Original error_suffix' }, + }) + ); + }); + + test('receives params in mapError', async () => { + const mutation = createJsonMutation({ + params: declareParams(), + request: { + url: 'http://api.salo.com', + method: 'POST' as const, + }, + response: { + contract: unknownContract, + mapError: ({ error, params }) => { + expect(params).toBe('test_params'); + return { ...(error as object), params }; + }, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject({ message: 'error' })); + const failureHandler = vi.fn(); + + const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope, params: 'test_params' }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + params: 'test_params', + error: { message: 'error', params: 'test_params' }, + }) + ); + }); + + describe('headers in mapError', () => { + test('HTTP 4xx error has headers', async () => { + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + mapError: ({ error, headers }) => { + expect(isHttpError({ error })).toBe(true); + expect(headers?.get('X-Error-Code')).toBe('CUSTOM_ERROR'); + return { error, hasHeaders: !!headers }; + }, + }, + }); + + const failureHandler = vi.fn(); + + // Mock at transport level to get proper headers flow + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ error: 'Not Found' }), { + status: 404, + statusText: 'Not Found', + headers: { 'X-Error-Code': 'CUSTOM_ERROR' }, + }), + ], + ], + }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ hasHeaders: true }), + }) + ); + }); + + test('HTTP 5xx error has headers', async () => { + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + mapError: ({ error, headers }) => { + expect(isHttpError({ error })).toBe(true); + expect(headers?.get('X-Server-Error')).toBe('DB_DOWN'); + return { error, serverHeader: headers?.get('X-Server-Error') }; + }, + }, + }); + + const failureHandler = vi.fn(); + + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ error: 'Internal Server Error' }), { + status: 500, + statusText: 'Internal Server Error', + headers: { 'X-Server-Error': 'DB_DOWN' }, + }), + ], + ], + }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ serverHeader: 'DB_DOWN' }), + }) + ); + }); + + test('network error has no headers', async () => { + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + mapError: ({ error, headers }) => { + expect(isNetworkError({ error })).toBe(true); + expect(headers).toBeUndefined(); + return { error, hasHeaders: !!headers }; + }, + }, + }); + + const failureHandler = vi.fn(); + + const scope = fork({ + handlers: [ + [fetchFx, () => Promise.reject(new TypeError('Network error'))], + ], + }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ hasHeaders: false }), + }) + ); + }); + + test('contract error has headers from successful response', async () => { + const failingContract = { + isData: (raw: unknown): raw is never => false, + getErrorMessages: () => ['Contract validation failed'], + }; + + const mutation = createJsonMutation({ + request, + response: { + contract: failingContract, + mapError: ({ error, headers }) => { + // Contract errors occur after successful HTTP response + expect(headers?.get('X-Request-Id')).toBe('req-123'); + return { error, requestId: headers?.get('X-Request-Id') }; + }, + }, + }); + + const failureHandler = vi.fn(); + + const scope = fork({ + handlers: [ + [ + fetchFx, + () => + new Response(JSON.stringify({ data: 'invalid' }), { + status: 200, + headers: { 'X-Request-Id': 'req-123' }, + }), + ], + ], + }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ requestId: 'req-123' }), + }) + ); + }); + }); + + test('without mapError, error passes through unchanged', async () => { + const originalError = { message: 'Original error' }; + + const mutation = createJsonMutation({ + request, + response: { + contract: unknownContract, + // No mapError provided + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); + + const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope }); + + expect(scope.getState(mutation.$failed)).toBe(true); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: originalError, + }) + ); + }); + + test('finished.failure receives transformed error', async () => { + const originalError = { message: 'Original error' }; + const transformedError = { message: 'Transformed error', code: 'ERR_001' }; + + const mutation = createJsonMutation({ + params: declareParams<{ id: number }>(), + request, + response: { + contract: unknownContract, + mapError: () => transformedError, + }, + }); + + const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); + + const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); + + mutation.finished.failure.watch(failureHandler); + + await allSettled(mutation.start, { scope, params: { id: 1 } }); + + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: 1 }, + error: transformedError, + }) + ); + }); +}); + diff --git a/packages/core/src/mutation/create_headless_mutation.ts b/packages/core/src/mutation/create_headless_mutation.ts index f25ba43f3..567a0cbce 100644 --- a/packages/core/src/mutation/create_headless_mutation.ts +++ b/packages/core/src/mutation/create_headless_mutation.ts @@ -24,7 +24,9 @@ export function createHeadlessMutation< ContractData extends Data, MappedData, Error, + MappedError = Error | InvalidDataError, MapDataSource = void, + MapErrorSource = void, ValidationSource = void, >( config: SharedMutationFactoryConfig & { @@ -35,9 +37,14 @@ export function createHeadlessMutation< MappedData, MapDataSource >; + mapError?: DynamicallySourcedField< + { error: Error | InvalidDataError; params: Params; headers?: Headers }, + MappedError, + MapErrorSource + >; } -): Mutation { - const { name, enabled, contract, validate, mapData } = config; +): Mutation { + const { name, enabled, contract, validate, mapData, mapError } = config; const operation = createRemoteOperation< Params, @@ -45,10 +52,10 @@ export function createHeadlessMutation< ContractData, MappedData, Error, - Error | InvalidDataError, + MappedError, null, MapDataSource, - void, + MapErrorSource, ValidationSource >({ name: name ?? getFactoryName(), @@ -59,6 +66,7 @@ export function createHeadlessMutation< contract, validate, mapData, + mapError, }); // -- Protocols -- diff --git a/packages/core/src/mutation/create_json_mutation.ts b/packages/core/src/mutation/create_json_mutation.ts index 58e823479..ae6656c31 100644 --- a/packages/core/src/mutation/create_json_mutation.ts +++ b/packages/core/src/mutation/create_json_mutation.ts @@ -101,6 +101,7 @@ export function createJsonMutation< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonMutationConfigWithParams< @@ -118,6 +119,11 @@ export function createJsonMutation< TransformedData, DataSource >; + mapError?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; status?: { expected: number | number[] }; }; @@ -132,6 +138,7 @@ export function createJsonMutation< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonMutationConfigWithParams< @@ -144,6 +151,11 @@ export function createJsonMutation< > & { response: { contract: Contract; + mapError?: DynamicallySourcedField< + { error: JsonApiRequestError; params: Params; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; status?: { expected: number | number[] }; }; @@ -159,6 +171,7 @@ export function createJsonMutation< HeadersSource = void, UrlSource = void, DataSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonMutationConfigNoParams< @@ -175,6 +188,11 @@ export function createJsonMutation< TransformedData, DataSource >; + mapError?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; status?: { expected: number | number[] }; }; @@ -188,6 +206,7 @@ export function createJsonMutation< QuerySource = void, HeadersSource = void, UrlSource = void, + FailureSource = void, ValidationSource = void, >( config: BaseJsonMutationConfigNoParams< @@ -199,6 +218,11 @@ export function createJsonMutation< > & { response: { contract: Contract; + mapError?: DynamicallySourcedField< + { error: JsonApiRequestError; params: void; headers?: Headers }, + unknown, + FailureSource + >; validate?: Validator; status?: { expected: number | number[] }; }; @@ -218,6 +242,7 @@ export function createJsonMutation(config: any): Mutation { const headlessMutation = createHeadlessMutation({ contract: config.response.contract ?? unknownContract, mapData: config.response.mapData ?? (({ result }) => result), + mapError: config.response.mapError, validate: config.response.validate, enabled: config.enabled, name: config.name, diff --git a/packages/core/src/mutation/create_mutation.ts b/packages/core/src/mutation/create_mutation.ts index 6dc271b3b..b9631a1cc 100644 --- a/packages/core/src/mutation/create_mutation.ts +++ b/packages/core/src/mutation/create_mutation.ts @@ -9,6 +9,7 @@ import { Contract } from '../contract/type'; import { Mutation } from './type'; import { resolveExecuteEffect } from '../remote_operation/resolve_execute_effect'; import { unknownContract } from '../contract/unknown_contract'; +import { type DynamicallySourcedField } from '../libs/patronus'; // Overload: Only handler export function createMutation( @@ -24,6 +25,25 @@ export function createMutation( } & SharedMutationFactoryConfig ): Mutation; +// Overload: Effect with mapError +export function createMutation< + Params, + Data, + Error, + MappedError, + MapErrorSource = void, +>( + config: { + effect: Effect; + mapError: DynamicallySourcedField< + { error: Error; params: Params }, + MappedError, + MapErrorSource + >; + } & SharedMutationFactoryConfig +): Mutation; + +// Overload: Effect with contract export function createMutation( config: { effect: Effect; @@ -31,6 +51,26 @@ export function createMutation( } & SharedMutationFactoryConfig ): Mutation; +// Overload: Effect with contract and mapError +export function createMutation< + Params, + Data, + ContractData extends Data, + Error, + MappedError, + MapErrorSource = void, +>( + config: { + effect: Effect; + contract: Contract; + mapError: DynamicallySourcedField< + { error: Error | InvalidDataError; params: Params }, + MappedError, + MapErrorSource + >; + } & SharedMutationFactoryConfig +): Mutation; + // -- Implementation -- export function createMutation( // Use any because of overloads @@ -42,6 +82,7 @@ export function createMutation( enabled: config.enabled, contract: config.contract ?? unknownContract, mapData: ({ result }) => result, + mapError: config.mapError, }); mutation.__.executeFx.use(resolveExecuteEffect(config)); From 6e54aa35cf7dff9df5f97633bd2dd9ff5895c583 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 13:05:43 +0700 Subject: [PATCH 10/15] fix format errors --- .../create_json_mutation.response.map_failure.test.ts | 1 - packages/core/src/remote_operation/create_remote_operation.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts index f4d60fabc..c0112aeef 100644 --- a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts @@ -335,4 +335,3 @@ describe('remote_data/mutation/json.response.map_failure', () => { ); }); }); - diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts index 74b3a3851..993a81591 100644 --- a/packages/core/src/remote_operation/create_remote_operation.ts +++ b/packages/core/src/remote_operation/create_remote_operation.ts @@ -82,8 +82,7 @@ export function createRemoteOperation< const startWithMeta = createEvent<{ params: Params; meta: ExecutionMeta }>(); // Default mapError to identity function - const effectivemapError = - mapError ?? (({ error }) => error as MappedError); + const effectivemapError = mapError ?? (({ error }) => error as MappedError); const applyContractFx = createContractApplier( contract From 0114684e287b7bd407f3b929988d220e59ca1966 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 13:10:38 +0700 Subject: [PATCH 11/15] `mapFailure` -> `mapError` for tests too --- ...re.test.ts => create_json_mutation.response.map_error.test.ts} | 0 ...ilure.test.ts => create_json_query.response.map_error.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/core/src/mutation/__tests__/{create_json_mutation.response.map_failure.test.ts => create_json_mutation.response.map_error.test.ts} (100%) rename packages/core/src/query/__tests__/{create_json_query.response.map_failure.test.ts => create_json_query.response.map_error.test.ts} (100%) diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts similarity index 100% rename from packages/core/src/mutation/__tests__/create_json_mutation.response.map_failure.test.ts rename to packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts similarity index 100% rename from packages/core/src/query/__tests__/create_json_query.response.map_failure.test.ts rename to packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts From 8ba85eaab6344f8937fb085801516acb45dba592 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 13:22:01 +0700 Subject: [PATCH 12/15] Improve mapError in query tests --- ...eate_json_query.response.map_error.test.ts | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts index 9b10e92a4..2d31847ca 100644 --- a/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts +++ b/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts @@ -30,12 +30,20 @@ describe('remote_data/query/json.response.map_failure', () => { }); const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toEqual(transformedError); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: transformedError, + }) + ); }); test('transform error with sourced callback', async () => { @@ -56,14 +64,22 @@ describe('remote_data/query/json.response.map_failure', () => { }); const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toEqual({ message: 'Original error_suffix', }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: { message: 'Original error_suffix' }, + }) + ); }); test('receives params in mapError', async () => { @@ -77,20 +93,29 @@ describe('remote_data/query/json.response.map_failure', () => { contract: unknownContract, mapError: ({ error, params }) => { expect(params).toBe('test_params'); - return { ...error, params }; + return { ...(error as object), params }; }, }, }); const fetchMock = vi.fn(() => Promise.reject({ message: 'error' })); + const failureHandler = vi.fn(); const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope, params: 'test_params' }); expect(scope.getState(query.$error)).toMatchObject({ params: 'test_params', }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + params: 'test_params', + error: { message: 'error', params: 'test_params' }, + }) + ); }); describe('headers in mapError', () => { @@ -107,6 +132,8 @@ describe('remote_data/query/json.response.map_failure', () => { }, }); + const failureHandler = vi.fn(); + // Mock at transport level to get proper headers flow const scope = fork({ handlers: [ @@ -122,9 +149,16 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toMatchObject({ hasHeaders: true }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ hasHeaders: true }), + }) + ); }); test('HTTP 5xx error has headers', async () => { @@ -140,6 +174,8 @@ describe('remote_data/query/json.response.map_failure', () => { }, }); + const failureHandler = vi.fn(); + const scope = fork({ handlers: [ [ @@ -154,11 +190,18 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toMatchObject({ serverHeader: 'DB_DOWN', }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ serverHeader: 'DB_DOWN' }), + }) + ); }); test('network error has no headers', async () => { @@ -174,15 +217,24 @@ describe('remote_data/query/json.response.map_failure', () => { }, }); + const failureHandler = vi.fn(); + const scope = fork({ handlers: [ [fetchFx, () => Promise.reject(new TypeError('Network error'))], ], }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toMatchObject({ hasHeaders: false }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ hasHeaders: false }), + }) + ); }); test('contract error has headers from successful response', async () => { @@ -203,6 +255,8 @@ describe('remote_data/query/json.response.map_failure', () => { }, }); + const failureHandler = vi.fn(); + const scope = fork({ handlers: [ [ @@ -216,11 +270,18 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toMatchObject({ requestId: 'req-123', }); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ requestId: 'req-123' }), + }) + ); }); }); @@ -236,11 +297,19 @@ describe('remote_data/query/json.response.map_failure', () => { }); const fetchMock = vi.fn(() => Promise.reject(originalError)); + const failureHandler = vi.fn(); const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); + query.finished.failure.watch(failureHandler); + await allSettled(query.start, { scope }); expect(scope.getState(query.$error)).toEqual(originalError); + expect(failureHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: originalError, + }) + ); }); }); From a855bd36a0177f749a7511aa7ac87154cbe3b631 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 14:01:25 +0700 Subject: [PATCH 13/15] Update types to support mapError properly --- ...json_mutation.response.map_error.test-d.ts | 126 ++++++++++++++ .../core/src/mutation/create_json_mutation.ts | 20 ++- ...te_json_query.response.map_error.test-d.ts | 163 ++++++++++++++++++ packages/core/src/query/create_json_query.ts | 40 +++-- 4 files changed, 325 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts create mode 100644 packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts new file mode 100644 index 000000000..6b519f73f --- /dev/null +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts @@ -0,0 +1,126 @@ +import { createStore, Event } from 'effector'; +import { describe, test, expectTypeOf } from 'vitest'; + +import { unknownContract } from '../../contract/unknown_contract'; +import { declareParams } from '../../remote_operation/params'; +import { createJsonMutation } from '../create_json_mutation'; +import { JsonApiRequestError } from '../../fetch/api'; +import { ExecutionMeta } from '../../remote_operation/type'; + +describe('createJsonMutation', () => { + describe('mapError', () => { + test('callback receives correct types', () => { + createJsonMutation({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + mapError: ({ error, params, headers }) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + + return { code: 'ERROR', message: 'test' }; + }, + }, + }); + }); + + test('callback receives void params when no params declared', () => { + createJsonMutation({ + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + mapError: ({ error, params, headers }) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + + return { code: 'ERROR' }; + }, + }, + }); + }); + + test('return type is used for finished.failure event', () => { + const mutation = createJsonMutation({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + mapError: () => ({ code: 'ERROR', message: 'test' } as const), + }, + }); + + expectTypeOf(mutation.finished.failure).toEqualTypeOf< + Event<{ + error: { readonly code: 'ERROR'; readonly message: 'test' }; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + + test('sourced callback receives source value', () => { + createJsonMutation({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + mapError: { + source: createStore({ defaultMessage: 'Unknown error' }), + fn: ({ error, params, headers }, source) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + expectTypeOf(source).toEqualTypeOf<{ defaultMessage: string }>(); + + return { code: 'ERROR', message: source.defaultMessage }; + }, + }, + }, + }); + }); + + test('sourced callback return type is used for finished.failure', () => { + const mutation = createJsonMutation({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + mapError: { + source: createStore(42), + fn: () => ({ errorCode: 123 } as const), + }, + }, + }); + + expectTypeOf(mutation.finished.failure).toEqualTypeOf< + Event<{ + error: { readonly errorCode: 123 }; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + + test('without mapError, error type is JsonApiRequestError', () => { + const mutation = createJsonMutation({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'POST' as const }, + response: { + contract: unknownContract, + }, + }); + + expectTypeOf(mutation.finished.failure).toEqualTypeOf< + Event<{ + error: JsonApiRequestError; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + }); +}); + diff --git a/packages/core/src/mutation/create_json_mutation.ts b/packages/core/src/mutation/create_json_mutation.ts index ae6656c31..fbca5fb94 100644 --- a/packages/core/src/mutation/create_json_mutation.ts +++ b/packages/core/src/mutation/create_json_mutation.ts @@ -101,6 +101,7 @@ export function createJsonMutation< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -121,14 +122,14 @@ export function createJsonMutation< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; status?: { expected: number | number[] }; }; } -): Mutation; +): Mutation; // params + no mapData export function createJsonMutation< @@ -138,6 +139,7 @@ export function createJsonMutation< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -153,14 +155,14 @@ export function createJsonMutation< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; status?: { expected: number | number[] }; }; } -): Mutation; +): Mutation; // No params + mapData export function createJsonMutation< @@ -171,6 +173,7 @@ export function createJsonMutation< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -190,14 +193,14 @@ export function createJsonMutation< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; status?: { expected: number | number[] }; }; } -): Mutation; +): Mutation; // No params + no mapData export function createJsonMutation< @@ -206,6 +209,7 @@ export function createJsonMutation< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -220,14 +224,14 @@ export function createJsonMutation< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; status?: { expected: number | number[] }; }; } -): Mutation; +): Mutation; // -- Implementation -- export function createJsonMutation(config: any): Mutation { diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts b/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts new file mode 100644 index 000000000..7c0b83b3f --- /dev/null +++ b/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts @@ -0,0 +1,163 @@ +import { createStore, Event, Store } from 'effector'; +import { describe, test, expectTypeOf } from 'vitest'; + +import { unknownContract } from '../../contract/unknown_contract'; +import { declareParams } from '../../remote_operation/params'; +import { createJsonQuery } from '../create_json_query'; +import { JsonApiRequestError } from '../../fetch/api'; +import { ExecutionMeta } from '../../remote_operation/type'; + +describe('createJsonQuery', () => { + describe('mapError', () => { + test('callback receives correct types', () => { + createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: ({ error, params, headers }) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + + return { code: 'ERROR', message: 'test' }; + }, + }, + }); + }); + + test('callback receives void params when no params declared', () => { + createJsonQuery({ + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: ({ error, params, headers }) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + + return { code: 'ERROR' }; + }, + }, + }); + }); + + test('return type is used for $error store', () => { + const query = createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: () => ({ code: 'ERROR', message: 'test' } as const), + }, + }); + + expectTypeOf(query.$error).toEqualTypeOf< + Store<{ readonly code: 'ERROR'; readonly message: 'test' } | null> + >(); + }); + + test('return type is used for finished.failure event', () => { + const query = createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: () => ({ code: 'ERROR', message: 'test' } as const), + }, + }); + + expectTypeOf(query.finished.failure).toEqualTypeOf< + Event<{ + error: { readonly code: 'ERROR'; readonly message: 'test' }; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + + test('sourced callback receives source value', () => { + createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: { + source: createStore({ defaultMessage: 'Unknown error' }), + fn: ({ error, params, headers }, source) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(headers).toEqualTypeOf(); + expectTypeOf(source).toEqualTypeOf<{ defaultMessage: string }>(); + + return { code: 'ERROR', message: source.defaultMessage }; + }, + }, + }, + }); + }); + + test('sourced callback return type is used for $error', () => { + const query = createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: { + source: createStore(42), + fn: () => ({ errorCode: 123 } as const), + }, + }, + }); + + expectTypeOf(query.$error).toEqualTypeOf< + Store<{ readonly errorCode: 123 } | null> + >(); + }); + + test('sourced callback return type is used for finished.failure', () => { + const query = createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + mapError: { + source: createStore(42), + fn: () => ({ errorCode: 123 } as const), + }, + }, + }); + + expectTypeOf(query.finished.failure).toEqualTypeOf< + Event<{ + error: { readonly errorCode: 123 }; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + + test('without mapError, error type is JsonApiRequestError', () => { + const query = createJsonQuery({ + params: declareParams(), + request: { url: 'http://api.salo.com', method: 'GET' as const }, + response: { + contract: unknownContract, + }, + }); + + expectTypeOf(query.$error).toEqualTypeOf< + Store + >(); + + expectTypeOf(query.finished.failure).toEqualTypeOf< + Event<{ + error: JsonApiRequestError; + params: string; + meta: ExecutionMeta; + }> + >(); + }); + }); +}); + diff --git a/packages/core/src/query/create_json_query.ts b/packages/core/src/query/create_json_query.ts index 45fbca6e1..fdef93053 100644 --- a/packages/core/src/query/create_json_query.ts +++ b/packages/core/src/query/create_json_query.ts @@ -102,6 +102,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -122,13 +123,13 @@ export function createJsonQuery< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; export function createJsonQuery< Params, @@ -139,6 +140,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -160,13 +162,13 @@ export function createJsonQuery< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; // params + no mapData export function createJsonQuery< @@ -176,6 +178,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -191,13 +194,13 @@ export function createJsonQuery< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; export function createJsonQuery< Params, @@ -206,6 +209,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -222,13 +226,13 @@ export function createJsonQuery< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: Params; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; // No params + mapData export function createJsonQuery< @@ -239,6 +243,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -258,13 +263,13 @@ export function createJsonQuery< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; export function createJsonQuery< Data, @@ -274,6 +279,7 @@ export function createJsonQuery< HeadersSource = void, UrlSource = void, DataSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -294,13 +300,13 @@ export function createJsonQuery< >; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; // No params + no mapData export function createJsonQuery< @@ -309,6 +315,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -323,13 +330,13 @@ export function createJsonQuery< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; export function createJsonQuery< Data, @@ -337,6 +344,7 @@ export function createJsonQuery< QuerySource = void, HeadersSource = void, UrlSource = void, + MappedError = JsonApiRequestError, FailureSource = void, ValidationSource = void, >( @@ -352,13 +360,13 @@ export function createJsonQuery< contract: Contract; mapError?: DynamicallySourcedField< { error: JsonApiRequestError; params: void; headers?: Headers }, - unknown, + MappedError, FailureSource >; validate?: Validator; }; } -): Query; +): Query; // -- Implementation -- export function createJsonQuery(config: any) { From 88a3fbb414a26349ba0c2e0b2e622ef1ff8f60f7 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 14:10:03 +0700 Subject: [PATCH 14/15] `.watch` -> `createWatch` --- ...e_json_mutation.response.map_error.test.ts | 20 +++++++++---------- ...eate_json_query.response.map_error.test.ts | 18 ++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts index c0112aeef..e06e98868 100644 --- a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts @@ -1,4 +1,4 @@ -import { allSettled, createStore, fork } from 'effector'; +import { allSettled, createStore, createWatch, fork } from 'effector'; import { describe, test, expect, vi } from 'vitest'; import { unknownContract } from '../../contract/unknown_contract'; @@ -34,7 +34,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -68,7 +68,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -101,7 +101,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope, params: 'test_params' }); @@ -145,7 +145,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -186,7 +186,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -219,7 +219,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -264,7 +264,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -293,7 +293,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope }); @@ -323,7 +323,7 @@ describe('remote_data/mutation/json.response.map_failure', () => { const scope = fork({ handlers: [[mutation.__.executeFx, fetchMock]] }); - mutation.finished.failure.watch(failureHandler); + createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); await allSettled(mutation.start, { scope, params: { id: 1 } }); diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts b/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts index 2d31847ca..06a36ecc1 100644 --- a/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts +++ b/packages/core/src/query/__tests__/create_json_query.response.map_error.test.ts @@ -1,4 +1,4 @@ -import { allSettled, createStore, fork } from 'effector'; +import { allSettled, createStore, createWatch, fork } from 'effector'; import { describe, test, expect, vi } from 'vitest'; import { unknownContract } from '../../contract/unknown_contract'; @@ -34,7 +34,7 @@ describe('remote_data/query/json.response.map_failure', () => { const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -68,7 +68,7 @@ describe('remote_data/query/json.response.map_failure', () => { const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -103,7 +103,7 @@ describe('remote_data/query/json.response.map_failure', () => { const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope, params: 'test_params' }); @@ -149,7 +149,7 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -190,7 +190,7 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -225,7 +225,7 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -270,7 +270,7 @@ describe('remote_data/query/json.response.map_failure', () => { ], }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); @@ -301,7 +301,7 @@ describe('remote_data/query/json.response.map_failure', () => { const scope = fork({ handlers: [[query.__.executeFx, fetchMock]] }); - query.finished.failure.watch(failureHandler); + createWatch({ unit: query.finished.failure, fn: failureHandler, scope }); await allSettled(query.start, { scope }); From c33e8e8dfd13fec9b49ff4a555c4461ba1a9f4d2 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 26 Dec 2025 14:18:22 +0700 Subject: [PATCH 15/15] fix ci --- .changeset/popular-panthers-hope.md | 4 ++-- ...json_mutation.response.map_error.test-d.ts | 5 ++-- ...e_json_mutation.response.map_error.test.ts | 24 +++++++++++++++---- ...te_json_query.response.map_error.test-d.ts | 9 ++++--- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.changeset/popular-panthers-hope.md b/.changeset/popular-panthers-hope.md index e1b62024d..301751a4e 100644 --- a/.changeset/popular-panthers-hope.md +++ b/.changeset/popular-panthers-hope.md @@ -1,5 +1,5 @@ --- -"@farfetched/core": minor +'@farfetched/core': minor --- -Added a new feature - `mapError` mapper to all Queries +Added a new feature - `mapError` mapper to all Queries and Mutations diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts index 6b519f73f..f11d1f310 100644 --- a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test-d.ts @@ -48,7 +48,7 @@ describe('createJsonMutation', () => { request: { url: 'http://api.salo.com', method: 'POST' as const }, response: { contract: unknownContract, - mapError: () => ({ code: 'ERROR', message: 'test' } as const), + mapError: () => ({ code: 'ERROR', message: 'test' }) as const, }, }); @@ -90,7 +90,7 @@ describe('createJsonMutation', () => { contract: unknownContract, mapError: { source: createStore(42), - fn: () => ({ errorCode: 123 } as const), + fn: () => ({ errorCode: 123 }) as const, }, }, }); @@ -123,4 +123,3 @@ describe('createJsonMutation', () => { }); }); }); - diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts index e06e98868..b3763518e 100644 --- a/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts +++ b/packages/core/src/mutation/__tests__/create_json_mutation.response.map_error.test.ts @@ -145,7 +145,11 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); + createWatch({ + unit: mutation.finished.failure, + fn: failureHandler, + scope, + }); await allSettled(mutation.start, { scope }); @@ -186,7 +190,11 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); + createWatch({ + unit: mutation.finished.failure, + fn: failureHandler, + scope, + }); await allSettled(mutation.start, { scope }); @@ -219,7 +227,11 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); + createWatch({ + unit: mutation.finished.failure, + fn: failureHandler, + scope, + }); await allSettled(mutation.start, { scope }); @@ -264,7 +276,11 @@ describe('remote_data/mutation/json.response.map_failure', () => { ], }); - createWatch({ unit: mutation.finished.failure, fn: failureHandler, scope }); + createWatch({ + unit: mutation.finished.failure, + fn: failureHandler, + scope, + }); await allSettled(mutation.start, { scope }); diff --git a/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts b/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts index 7c0b83b3f..815ce796a 100644 --- a/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts +++ b/packages/core/src/query/__tests__/create_json_query.response.map_error.test-d.ts @@ -48,7 +48,7 @@ describe('createJsonQuery', () => { request: { url: 'http://api.salo.com', method: 'GET' as const }, response: { contract: unknownContract, - mapError: () => ({ code: 'ERROR', message: 'test' } as const), + mapError: () => ({ code: 'ERROR', message: 'test' }) as const, }, }); @@ -63,7 +63,7 @@ describe('createJsonQuery', () => { request: { url: 'http://api.salo.com', method: 'GET' as const }, response: { contract: unknownContract, - mapError: () => ({ code: 'ERROR', message: 'test' } as const), + mapError: () => ({ code: 'ERROR', message: 'test' }) as const, }, }); @@ -105,7 +105,7 @@ describe('createJsonQuery', () => { contract: unknownContract, mapError: { source: createStore(42), - fn: () => ({ errorCode: 123 } as const), + fn: () => ({ errorCode: 123 }) as const, }, }, }); @@ -123,7 +123,7 @@ describe('createJsonQuery', () => { contract: unknownContract, mapError: { source: createStore(42), - fn: () => ({ errorCode: 123 } as const), + fn: () => ({ errorCode: 123 }) as const, }, }, }); @@ -160,4 +160,3 @@ describe('createJsonQuery', () => { }); }); }); -