diff --git a/.changeset/user-sync-409-message.md b/.changeset/user-sync-409-message.md new file mode 100644 index 0000000..3c3f28a --- /dev/null +++ b/.changeset/user-sync-409-message.md @@ -0,0 +1,5 @@ +--- +"@quranjs/api": patch +--- + +User Sync errors now carry the service's error code and message. A 409 `OutOfSyncError` from `client.auth.v1.sync` has `code: "OutOfSyncError"` and a message such as `409: Invalid lastMutationAt, please re-sync your data and try again.` instead of `409 `, for both the Gateway and direct backend response shapes. `isUserSyncOutOfSyncError` is unchanged. diff --git a/packages/api/src/runtime/user-sync.ts b/packages/api/src/runtime/user-sync.ts index 9cb26e3..153e9ae 100644 --- a/packages/api/src/runtime/user-sync.ts +++ b/packages/api/src/runtime/user-sync.ts @@ -13,7 +13,10 @@ import type { UserSyncSuccess, } from "@/types"; import { QuranHttpError } from "@/sdk/http-error"; -import { UserSyncPullRestartLimitError } from "@/sdk/user-sync-errors"; +import { + UserSyncPullRestartLimitError, + withUserSyncErrorDetails, +} from "@/sdk/user-sync-errors"; interface UserSyncFetcher { request( @@ -61,11 +64,8 @@ export const createUserSyncFacade = ( ): Promise> => { let mutationAt: number | null = null; let status = 0; - const body = await fetcher.request>( - "auth", - SYNC_PATH, - query, - { + const body = await fetcher + .request>("auth", SYNC_PATH, query, { ...options, auth: "user", method, @@ -74,8 +74,10 @@ export const createUserSyncFacade = ( status = metadata.status; }, preserveResponseKeys: true, - }, - ); + }) + .catch((error: unknown) => { + throw withUserSyncErrorDetails(error); + }); return { ...body, mutationAt, status }; }; diff --git a/packages/api/src/sdk/user-sync-errors.ts b/packages/api/src/sdk/user-sync-errors.ts index f82eb42..80a7533 100644 --- a/packages/api/src/sdk/user-sync-errors.ts +++ b/packages/api/src/sdk/user-sync-errors.ts @@ -22,6 +22,8 @@ export type UserSyncOutOfSyncErrorPayload = | { error: UserSyncServiceError; success: false }; export type UserSyncOutOfSyncError = QuranHttpError & { + /** Set on errors thrown by the User Sync facade. */ + readonly code?: typeof USER_SYNC_OUT_OF_SYNC_ERROR_CODE; readonly payload: UserSyncOutOfSyncErrorPayload; readonly status: 409; }; @@ -40,6 +42,78 @@ const readServiceErrorCode = (value: unknown): string | undefined => { return typeof code === "string" ? code : undefined; }; +const readServiceError = ( + value: unknown, +): { code?: string; message?: string } | undefined => { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const serviceError = (value as { error?: unknown }).error; + if (typeof serviceError !== "object" || serviceError === null) { + return undefined; + } + + const { code, message } = serviceError as { + code?: unknown; + message?: unknown; + }; + return { + code: typeof code === "string" ? code : undefined, + message: typeof message === "string" && message ? message : undefined, + }; +}; + +const DEFAULT_OUT_OF_SYNC_MESSAGE = + "Out of sync: lastMutationAt is stale; pull and retry."; + +/** + * Gives a User Sync `QuranHttpError` the service's error code and message. + * + * The shared HTTP error message is only `" "`, which is + * `"409 "` over HTTP/2, so the server's reason is otherwise lost. The error is + * updated in place, so `instanceof`, `status`, `headers` and `payload` stay as + * they were. Both the Gateway envelope (service error in `details.error`) and + * the direct backend envelope (service error in `error`) are read. + */ +export const withUserSyncErrorDetails = (error: unknown): unknown => { + if (!(error instanceof QuranHttpError)) { + return error; + } + + const payload = error.payload; + if (typeof payload !== "object" || payload === null) { + return error; + } + + const wrapped = readServiceError((payload as { details?: unknown }).details); + const direct = readServiceError(payload); + const code = wrapped?.code ?? direct?.code; + if (code === undefined) { + return error; + } + + const topLevelMessage = (payload as { message?: unknown }).message; + const message = + wrapped?.message ?? + direct?.message ?? + (typeof topLevelMessage === "string" && topLevelMessage + ? topLevelMessage + : undefined) ?? + (isUserSyncOutOfSyncError(error) ? DEFAULT_OUT_OF_SYNC_MESSAGE : undefined); + + Object.defineProperty(error, "code", { + configurable: true, + enumerable: true, + value: code, + writable: true, + }); + if (message !== undefined) { + error.message = `${error.message.trimEnd()}: ${message}`; + } + return error; +}; + /** Returns true for a 409 `OutOfSyncError` from `GET` or `POST /v1/sync`. */ export const isUserSyncOutOfSyncError = ( error: unknown, diff --git a/packages/api/test/user-sync.test.ts b/packages/api/test/user-sync.test.ts index 0b096cb..5bd5844 100644 --- a/packages/api/test/user-sync.test.ts +++ b/packages/api/test/user-sync.test.ts @@ -284,6 +284,96 @@ describe("User Sync facade", () => { ]); expect(isUserSyncOutOfSyncError(new Error("409"))).toBe(false); }); + + it("gives sync errors the service code and message for both envelopes", async () => { + // HTTP/2 responses carry no statusText, so the base message is "409 ". + const noStatusText = (body: unknown, status: number) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + const backendWithoutMessage = { + error: { code: "OutOfSyncError" }, + success: false, + }; + const responses = [ + noStatusText(OUT_OF_SYNC_BODY, 409), + noStatusText(OUT_OF_SYNC_BODY.details, 409), + noStatusText(backendWithoutMessage, 409), + HttpResponse.json( + { + details: { + error: { + code: "UniqueViolationError", + message: "You already have a collection with this name", + }, + success: false, + }, + message: "You already have a collection with this name", + success: false, + type: "service_error", + }, + { status: 409, statusText: "Conflict" }, + ), + noStatusText({ message: "nope" }, 500), + ]; + // MSW fills in a default statusText, so serve these through `fetch`. + const client = createPublicClient({ + clientId: "client-id", + clientType: "confidential-proxy", + fetch: () => Promise.resolve(responses.shift()!), + services: { authBaseUrl: "http://localhost:3001" }, + userSession: { accessToken: "user-access-token" }, + }); + + const errors: unknown[] = []; + for (let index = 0; index < 5; index += 1) { + errors.push( + await client.auth.v1.sync + .pushMutations({ + lastMutationAt: 1, + mutations: [{ resource: "NOTE", resourceId: "x", type: "DELETE" }], + }) + .catch((error: unknown) => error), + ); + } + + const [gateway, backend, bare, unique, other] = errors as QuranHttpError[]; + const reason = + "Invalid lastMutationAt, please re-sync your data and try again."; + expect(gateway).toMatchObject({ + code: "OutOfSyncError", + message: `409: ${reason}`, + payload: OUT_OF_SYNC_BODY, + status: 409, + }); + expect(backend).toMatchObject({ + code: "OutOfSyncError", + message: `409: ${reason}`, + payload: OUT_OF_SYNC_BODY.details, + status: 409, + }); + expect(bare).toMatchObject({ + code: "OutOfSyncError", + message: "409: Out of sync: lastMutationAt is stale; pull and retry.", + status: 409, + }); + expect(unique).toMatchObject({ + code: "UniqueViolationError", + message: "409 Conflict: You already have a collection with this name", + }); + expect(other).toBeInstanceOf(QuranHttpError); + // Errors without a service code keep the shared message unchanged. + expect(other?.message).toBe("500 "); + expect(other).not.toHaveProperty("code"); + expect(errors.map((error) => isUserSyncOutOfSyncError(error))).toEqual([ + true, + true, + true, + false, + false, + ]); + }); }); describe("User Sync pullAllMutations", () => {