shelving/firebasemodule

FirestoreProvider — a Cloud Firestore DBProvider that talks directly to the Firestore REST API using fetch.

  • Zero dependencies. No firebase or @google-cloud/firestore SDK, no gRPC — it runs anywhere fetch runs: Node.js, Bun, Cloudflare Workers, Deno, and other edge runtimes.
  • Transactions. DBProvider.transact() is fully supported: reads (including queries and counts) see one consistent snapshot, writes buffer and commit atomically, and contended commits retry automatically.
  • No realtime. DBProvider.getItemSequence() and DBProvider.getQuerySequence() throw UnsupportedError — the :listen endpoint needs a streaming session the plain REST API can't provide.

Usage

ts
import { FirestoreProvider } from "shelving/firebase";

const provider = new FirestoreProvider({
  project: "my-project",
  token: () => auth.getAccessToken(), // e.g. from `google-auth-library`
});

const id = await provider.addItem(POSTS, { title: "Hello", body: "First post.", published: false });

await provider.transact(async db => {
  const post = await db.requireItem(POSTS, id);
  await db.updateItem(POSTS, id, { published: true, "+=views": 1 });
});

Options

  • project — Google Cloud project id (required).
  • database — Firestore database id (defaults to "(default)").
  • token — callback returning an OAuth2 access token for each request. Use google-auth-library, or any code that can mint a token for the https://www.googleapis.com/auth/datastore scope. Omit for the emulator.
  • host — base URL of the Firestore API (defaults to https://firestore.googleapis.com). Point it at the emulator in tests.
  • fetch — custom fetch implementation (defaults to the global fetch).

With the Firestore emulator

ts
const provider = new FirestoreProvider({
  project: "demo-project",
  host: `http://${process.env.FIRESTORE_EMULATOR_HOST}`,
});

Behaviour notes

  • Ids: DBProvider.addItem() generates a random 20-character id client-side and commits with an exists: false precondition, so it fails rather than overwriting on the (vanishingly unlikely) collision.
  • Updates: DBProvider.updateItem() uses an update mask plus field transforms — += maps to increment, +[] to appendMissingElements, -[] to removeAllFromArray — and fails if the item does not exist.
  • Query writes: setQuery() / updateQuery() / deleteQuery() read the matching document names (a __name__-only query) then commit writes in batches of 500.
  • Values: safe integers store as Firestore integers, other finite numbers as doubles; integers beyond Number.MAX_SAFE_INTEGER lose precision when read back. Foreign types written by other clients (timestamps, references, bytes) read back as their string form. Data read from Firestore is unvalidated — wrap the provider in ValidationDBProvider to guarantee types.
  • Transactions: retried up to 5 times with jittered exponential backoff on contention (ABORTED), so transact() callbacks must have no side effects other than through their provider. Firestore limits a transaction to 270 seconds with a 60-second idle timeout.

Testing

Unit tests (value codec and request protocol) run offline in the normal test suite. The universal DBProvider contract suite runs against the real Firestore emulator via its own command (excluded from bun run test; run in CI on every PR and release):

sh
bun run firebase

This wraps bun test ./modules/firebase in firebase emulators:exec, which starts the emulator (requires Java 21+), sets FIRESTORE_EMULATOR_HOST, and shuts it down afterwards. Without that env var the emulator-backed tests don't register, so the offline suite stays green.

firebase.json pins the emulator to 127.0.0.1 (not localhost, whose IPv6-first resolution depends on client fallback behaviour) and to port 8981 rather than the default 8080. The port matters on macOS: when Screen Time's "Web content" filtering is enabled, Apple's webfilterproxyd transparently proxies loopback connections on well-known HTTP ports (including 8080). The emulator's REST front-end opens an internal loopback connection back to its own port for every data request; the filter captures that connection and cannot relay it, so every read and write hangs forever (or resets, e.g. the historical ECONNRESET from fetch on localhost:8080). Ports outside the filter's watch list, like 8981, are untouched.

Functions

Go

toFirestoreValue()function

Convert a data value to its Firestore REST API JSON representation.

toFirestoreValue(value: unknown, caller: AnyCaller = toFirestoreValue): FirestoreValue
Go

toFirestoreFields()function

Convert a data object to Firestore REST API document fields.

toFirestoreFields(data: Data, caller: AnyCaller = toFirestoreFields): FirestoreFields
Go

toDataValue()function

Convert a Firestore REST API JSON value back to a data value.

toDataValue(value: FirestoreValue, caller: AnyCaller = toDataValue): unknown
Go

toData()function

Convert Firestore REST API document fields back to a data object.

toData(fields: FirestoreFields | undefined, caller: AnyCaller = toData): Data

Classes

Go

FirestoreProviderclass

Cloud Firestore database provider that talks to the Firestore REST API using fetch, implementing the DBProvider abstraction.

new FirestoreProvider<I, T>(options: FirestoreProviderOptions)

Interfaces

Go

FirestoreProviderOptionsinterface

Options for FirestoreProvider.

{
	readonly project: string;
	readonly database?: string | undefined;
	readonly host?: string | undefined;
	readonly token?: (() => string | PromiseLike<string>) | undefined;
	readonly fetch?: ((input: string, init: RequestInit) => Promise<Response>) | undefined;
}

Types

Go

FirestoreValuetype

JSON representation of a single value in the Firestore REST API.

{
	readonly nullValue?: null;
	readonly booleanValue?: boolean;
	readonly integerValue?: string | number;
	readonly doubleValue?: string | number;
	readonly stringValue?: string;
	readonly timestampValue?: string;
	readonly bytesValue?: string;
	readonly referenceValue?: string;
	readonly geoPointValue?: { readonly latitude?: number; readonly longitude?: number };
	readonly arrayValue?: { readonly values?: ImmutableArray<FirestoreValue> };
	readonly mapValue?: { readonly fields?: FirestoreFields };
}
Go

FirestoreFieldstype

JSON representation of the fields of a Firestore document in the Firestore REST API.

{
	readonly [key: string]: FirestoreValue;
}