Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/structured-firestore-errors.md
Original file line number Diff line number Diff line change
@@ -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`.
Comment thread
besart-finsweet marked this conversation as resolved.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"cSpell.words": ["Firestore"]
}
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
besart-finsweet marked this conversation as resolved.
Outdated
- `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/).
Expand Down
7 changes: 4 additions & 3 deletions src/batch.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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<Firestore.CommitResponse>(data);

committed = true;
return data as Firestore.CommitResponse;
return data;
} finally {
committing = false;
}
Expand Down
5 changes: 3 additions & 2 deletions src/create.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +22,7 @@ export const create = async <Fields extends Record<string, any>>(
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: {
Expand All @@ -31,7 +32,7 @@ export const create = async <Fields extends Record<string, any>>(

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<Fields>(data);
return document;
Expand Down
151 changes: 151 additions & 0 deletions src/error.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
123 changes: 123 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -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<string, FirestoreErrorCode> = {
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<T>(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<Response> => {
try {
return await fetch(input, init);
} catch (err) {
throw new FirestoreError({
code: 'unavailable',
message: err instanceof Error ? err.message : 'Network request failed',
});
}
Comment thread
besart-finsweet marked this conversation as resolved.
};
Loading