diff --git a/.changeset/structured-firestore-errors.md b/.changeset/structured-firestore-errors.md new file mode 100644 index 0000000..109be97 --- /dev/null +++ b/.changeset/structured-firestore-errors.md @@ -0,0 +1,5 @@ +--- +'fireworkers': minor +--- + +Add `FirestoreError` with a stable `code` field (compatible with the Firebase Web SDK's `FirestoreErrorCode`) so callers can branch on a kebab-cased code instead of regex-matching `.message`. Also exposes `status` (canonical status string, e.g. `'NOT_FOUND'`) and `httpCode` (HTTP status) for debugging. Network failures wrap into `FirestoreError` with `code: 'unavailable'`. Non-breaking — `FirestoreError` extends `Error` and `err.message` still equals the REST response's `error.message`. diff --git a/.vscode/settings.json b/.vscode/settings.json index 1947a0d..430a7c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" - } + }, + "cSpell.words": ["Firestore"] } diff --git a/README.md b/README.md index 2f58918..e3c491c 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,45 @@ const response = await b.commit(); --- +## Error handling + +All operations reject with a `FirestoreError` when Firestore returns an error response or the network request fails. `FirestoreError` extends the built-in `Error`, so existing `try/catch` and `.message` checks keep working — but you can now branch on a stable string `code` instead of parsing the message. + +```typescript +import * as Firestore from 'fireworkers'; + +try { + await Firestore.get(db, 'todos', 'missing-id'); +} catch (err) { + if (err instanceof Firestore.FirestoreError) { + if (err.code === 'not-found') { + // handle missing document + } else if (err.code === 'permission-denied') { + // surface auth failure + } + } + throw err; +} +``` + +### Fields + +- `code` — `FirestoreErrorCode` (kebab-cased string, see list below) +- `message` — the original `error.message` from the Firestore REST response when present, otherwise a generic fallback (`'Unknown Firestore error'`) +- `status` — the original canonical status string (e.g. `'NOT_FOUND'`), when present +- `httpCode` — the original numeric HTTP status code from the REST response, when present +- `name` — always `'FirestoreError'` + +Network-level failures (DNS, connection reset, etc.) surface as `FirestoreError` with `code: 'unavailable'`. + +### FirestoreErrorCode values + +The 16 canonical status codes, kebab-cased — same set the Firebase Web SDK uses: + +`cancelled`, `unknown`, `invalid-argument`, `deadline-exceeded`, `not-found`, `already-exists`, `permission-denied`, `resource-exhausted`, `failed-precondition`, `aborted`, `out-of-range`, `unimplemented`, `internal`, `unavailable`, `data-loss`, `unauthenticated`. + +--- + ## Testing Unit tests run against the [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) using [Vitest](https://vitest.dev/). diff --git a/src/batch.ts b/src/batch.ts index 9ef3ab2..8f0b565 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { create_document_from_fields } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -101,7 +102,7 @@ export const batch = ({ jwt, project_id }: Firestore.DB) => { const body: Firestore.CommitRequest = { writes }; try { - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'POST', body: JSON.stringify(body), headers: { @@ -111,10 +112,10 @@ export const batch = ({ jwt, project_id }: Firestore.DB) => { const data = await response.json(); - if ('error' in data) throw new Error(data.error.message); + throw_if_error(data); committed = true; - return data as Firestore.CommitResponse; + return data; } finally { committing = false; } diff --git a/src/create.ts b/src/create.ts index cb3655d..1da079e 100644 --- a/src/create.ts +++ b/src/create.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { create_document_from_fields, extract_fields_from_document } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -21,7 +22,7 @@ export const create = async >( const endpoint = get_firestore_endpoint(project_id, paths); const payload = create_document_from_fields(fields); - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'POST', body: JSON.stringify(payload), headers: { @@ -31,7 +32,7 @@ export const create = async >( const data: Firestore.GetResponse = await response.json(); - if ('error' in data) throw new Error(data.error.message); + throw_if_error(data); const document = extract_fields_from_document(data); return document; diff --git a/src/error.test.ts b/src/error.test.ts new file mode 100644 index 0000000..8210fc5 --- /dev/null +++ b/src/error.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearFirestore, initDb } from '../tests/unit/helpers'; +import { create } from './create'; +import { FirestoreError, safe_fetch, status_to_code } from './error'; +import { get } from './get'; +import { remove } from './remove'; +import type { DB } from './types'; +import { update } from './update'; + +let db: DB; + +describe('FirestoreError', () => { + beforeAll(async () => { + db = await initDb(); + }); + beforeEach(clearFirestore); + + it('throws a FirestoreError with code "not-found" when getting a missing document', async () => { + let caught: unknown; + try { + await get(db, 'todos', 'does-not-exist'); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + expect(caught).toBeInstanceOf(Error); + const err = caught as FirestoreError; + expect(err.code).toBe('not-found'); + expect(err.status).toBe('NOT_FOUND'); + expect(err.name).toBe('FirestoreError'); + expect(err.message).toMatch(/./); + }); + + it('throws a FirestoreError with code "not-found" when updating a missing document', async () => { + let caught: unknown; + try { + await update(db, 'todos', 'does-not-exist', { completed: true }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + const err = caught as FirestoreError; + expect(err.code).toBe('not-found'); + }); + + it('throws a FirestoreError for unauthenticated/permission-denied requests', async () => { + const bad_db: DB = { project_id: db.project_id, jwt: 'not-a-valid-jwt' }; + + let caught: unknown; + try { + await create(bad_db, 'todos', { title: 'x', completed: false }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + const err = caught as FirestoreError; + // Emulator may surface bad-auth as any of these depending on why it rejects + // (missing/malformed/expired token vs. rule violation). + expect(['unauthenticated', 'permission-denied', 'invalid-argument']).toContain(err.code); + }); + + it('throws a FirestoreError when remove is called with an invalid JWT', async () => { + const bad_db: DB = { project_id: db.project_id, jwt: 'not-a-valid-jwt' }; + + let caught: unknown; + try { + await remove(bad_db, 'todos', 'any-id'); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + const err = caught as FirestoreError; + expect(['unauthenticated', 'permission-denied', 'invalid-argument']).toContain(err.code); + }); + + it('preserves the original REST error message', async () => { + let caught: FirestoreError | undefined; + try { + await get(db, 'todos', 'does-not-exist'); + } catch (err) { + caught = err as FirestoreError; + } + + expect(caught?.message.length).toBeGreaterThan(0); + }); +}); + +describe('status_to_code', () => { + it('maps known canonical status strings to kebab-case codes', () => { + expect(status_to_code('NOT_FOUND')).toBe('not-found'); + expect(status_to_code('PERMISSION_DENIED')).toBe('permission-denied'); + expect(status_to_code('FAILED_PRECONDITION')).toBe('failed-precondition'); + expect(status_to_code('UNAUTHENTICATED')).toBe('unauthenticated'); + }); + + it('falls back to "unknown" for unrecognized status strings', () => { + expect(status_to_code('NOT_A_REAL_STATUS')).toBe('unknown'); + expect(status_to_code('')).toBe('unknown'); + }); +}); + +describe('safe_fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('wraps network-level fetch rejections in a FirestoreError with code "unavailable"', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to connect')); + + let caught: unknown; + try { + await safe_fetch('https://example.invalid'); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + const err = caught as FirestoreError; + expect(err.code).toBe('unavailable'); + expect(err.message).toBe('Failed to connect'); + expect(err.httpCode).toBeUndefined(); + }); + + it('falls back to a generic message when the thrown value is not an Error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue('raw rejection'); + + let caught: unknown; + try { + await safe_fetch('https://example.invalid'); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(FirestoreError); + const err = caught as FirestoreError; + expect(err.code).toBe('unavailable'); + expect(err.message).toBe('Network request failed'); + }); + + it('returns the Response unchanged when fetch resolves', async () => { + const response = new Response('ok', { status: 200 }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + + await expect(safe_fetch('https://example.invalid')).resolves.toBe(response); + }); +}); diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..7e2e241 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,123 @@ +import type { Status } from './types'; + +/** + * String error codes mirroring the Firebase Web SDK's `FirestoreErrorCode`. + * The 16 canonical status codes, kebab-cased. + * + * Reference: {@link https://firebase.google.com/docs/reference/js/firestore_.firestoreerror#firestoreerrorcode} + */ +export type FirestoreErrorCode = + | 'cancelled' + | 'unknown' + | 'invalid-argument' + | 'deadline-exceeded' + | 'not-found' + | 'already-exists' + | 'permission-denied' + | 'resource-exhausted' + | 'failed-precondition' + | 'aborted' + | 'out-of-range' + | 'unimplemented' + | 'internal' + | 'unavailable' + | 'data-loss' + | 'unauthenticated'; + +const STATUS_TO_CODE: Record = { + CANCELLED: 'cancelled', + UNKNOWN: 'unknown', + INVALID_ARGUMENT: 'invalid-argument', + DEADLINE_EXCEEDED: 'deadline-exceeded', + NOT_FOUND: 'not-found', + ALREADY_EXISTS: 'already-exists', + PERMISSION_DENIED: 'permission-denied', + RESOURCE_EXHAUSTED: 'resource-exhausted', + FAILED_PRECONDITION: 'failed-precondition', + ABORTED: 'aborted', + OUT_OF_RANGE: 'out-of-range', + UNIMPLEMENTED: 'unimplemented', + INTERNAL: 'internal', + UNAVAILABLE: 'unavailable', + DATA_LOSS: 'data-loss', + UNAUTHENTICATED: 'unauthenticated', +}; + +/** + * Maps a canonical Firestore status string (e.g. `'NOT_FOUND'`) to the + * kebab-cased error code. Unrecognized values fall back to `'unknown'`. + */ +export const status_to_code = (status: string): FirestoreErrorCode => + STATUS_TO_CODE[status] ?? 'unknown'; + +/** + * Error thrown by every `fireworkers` operation when Firestore rejects a + * request or the network request fails. Shape mirrors the Firebase Web SDK's + * `FirestoreError` so callers can branch on `err.code`. + */ +export class FirestoreError extends Error { + readonly code: FirestoreErrorCode; + readonly httpCode?: number; + readonly status?: string; + + constructor({ + code, + message, + httpCode, + status, + }: { + code: FirestoreErrorCode; + message: string; + httpCode?: number; + status?: string; + }) { + super(message); + this.name = 'FirestoreError'; + this.code = code; + this.httpCode = httpCode; + this.status = status; + } +} + +const is_error_response = (data: unknown): data is { error: Status } => + data !== null && + typeof data === 'object' && + 'error' in data && + typeof (data as { error: unknown }).error === 'object' && + (data as { error: unknown }).error !== null; + +/** + * Inspects a parsed REST response body and throws a `FirestoreError` if it + * contains an `error` object. Otherwise narrows `data` to the success shape. + */ +export function throw_if_error(data: T | { error: Status }): asserts data is T { + if (!is_error_response(data)) return; + + const { code: httpCode, message, status } = data.error; + + throw new FirestoreError({ + code: typeof status === 'string' ? status_to_code(status) : 'unknown', + httpCode, + status, + message: message ?? 'Unknown Firestore error', + }); +} + +/** + * `fetch` wrapper that converts network-level rejections (e.g. DNS failure, + * connection reset) into a `FirestoreError` with code `'unavailable'` so + * consumers have a single error type to catch. + */ +export const safe_fetch = async ( + input: URL | RequestInfo, + init?: RequestInit +): Promise => { + try { + return await fetch(input, init); + } catch (err) { + throw new FirestoreError({ + code: 'unavailable', + message: err instanceof Error ? err.message : 'Network request failed', + }); + } +}; diff --git a/src/get.ts b/src/get.ts index ec27341..9cedf95 100644 --- a/src/get.ts +++ b/src/get.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { extract_fields_from_document } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -16,7 +17,7 @@ export const get = async >( ) => { const endpoint = get_firestore_endpoint(project_id, paths); - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { headers: { Authorization: `Bearer ${jwt}`, }, @@ -24,7 +25,7 @@ export const get = async >( const data: Firestore.GetResponse = await response.json(); - if ('error' in data) throw new Error(data.error.message); + throw_if_error(data); const document = extract_fields_from_document(data); return document; diff --git a/src/index.ts b/src/index.ts index 34e613c..3b1c187 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './batch'; export * from './create'; +export { FirestoreError, type FirestoreErrorCode } from './error'; export * from './get'; export * from './init'; export * from './query'; diff --git a/src/query.ts b/src/query.ts index a225812..92d14eb 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { extract_fields_from_document } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -35,7 +36,7 @@ export const query = async >( structuredQuery: query, }; - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'POST', body: JSON.stringify(payload), headers: { @@ -43,7 +44,8 @@ export const query = async >( }, }); - const data: RunQueryResponse = await response.json(); + const data: RunQueryResponse | { error: Firestore.Status } = await response.json(); + throw_if_error(data); const documents = data.reduce[]>((acc, { document }) => { if (!document) return acc; diff --git a/src/remove.ts b/src/remove.ts index 9b850a2..641669a 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,3 +1,4 @@ +import { FirestoreError, safe_fetch, throw_if_error } from './error'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -13,12 +14,22 @@ import { get_firestore_endpoint } from './utils'; export const remove = async ({ jwt, project_id }: Firestore.DB, ...paths: string[]) => { const endpoint = get_firestore_endpoint(project_id, paths); - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}`, }, }); - return response.ok; + if (!response.ok) { + const data = await response.json().catch(() => null); + throw_if_error(data); + throw new FirestoreError({ + code: 'unknown', + message: `Firestore delete failed with HTTP ${response.status}`, + httpCode: response.status, + }); + } + + return true; }; diff --git a/src/set.ts b/src/set.ts index a71a745..6e7a0b8 100644 --- a/src/set.ts +++ b/src/set.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { create_document_from_fields, extract_fields_from_document } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -44,7 +45,7 @@ export const set = async >( } } - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'PATCH', body: JSON.stringify(payload), headers: { @@ -54,7 +55,7 @@ export const set = async >( const data: Firestore.GetResponse = await response.json(); - if ('error' in data) throw new Error(data.error.message); + throw_if_error(data); const document = extract_fields_from_document(data); return document; diff --git a/src/update.ts b/src/update.ts index 233af80..0cb4201 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { safe_fetch, throw_if_error } from './error'; import { create_document_from_fields, extract_fields_from_document } from './fields'; import type * as Firestore from './types'; import { get_firestore_endpoint } from './utils'; @@ -31,7 +32,7 @@ export const update = async >( endpoint.searchParams.append('updateMask.fieldPaths', key); } - const response = await fetch(endpoint, { + const response = await safe_fetch(endpoint, { method: 'PATCH', body: JSON.stringify(payload), headers: { @@ -41,7 +42,7 @@ export const update = async >( const data: Firestore.GetResponse = await response.json(); - if ('error' in data) throw new Error(data.error.message); + throw_if_error(data); const document = extract_fields_from_document(data); return document;