Core Concepts
The foundational building blocks of FirestoreORM: the per-collection repository, Firestore converters for read/write serialization, and delete semantics.
FirestoreORM’s core is a per-collection repository. This page covers the repository pattern, Firestore converters, and delete behavior. The other foundational topics each have their own page: schema validation, field-value sentinels, timestamps, lifecycle hooks, queries, and vector search.
Repository Pattern
Section titled “Repository Pattern”The repository abstracts Firestore operations behind a clean, consistent API. Each collection gets its own repository instance.
// Initialize once, use everywhereconst userRepo = FirestoreRepository.withSchema<User>(db, 'users', userSchema);const orderRepo = FirestoreRepository.withSchema<Order>(db, 'orders', orderSchema);const productRepo = new FirestoreRepository<Product>(db, 'products'); // Without validationThe withSchema factory attaches a Zod schema for runtime validation. Each schema it receives
(userSchema, orderSchema above) must declare a required top-level id: z.string() — the
factory throws at construction if it is missing. withSchema has two forms: a direct form,
withSchema<User>(db, 'users', userSchema), which types writes by the read type, and a curried
form, withSchema<User>()(db, 'users', userSchema), which infers the write type from the schema and
enables cast-free combinator writes. Construct a repository directly with
new FirestoreRepository<Product>(db, 'products') when you don’t need validation. See
schema validation for the full contract.
The full constructor signature is
new FirestoreRepository<T, W>(db, collectionPath, validator?, parentPath?, converter?, schemas?).
There is no options, config, debug, or logger bag — everything is passed through these positional
arguments (plus the small opts object the withSchema and subcollection factories accept, whose
only field is sentinelPolicy).
Firestore Converters
Section titled “Firestore Converters”FirestoreORM supports Firestore withConverter(...) through optional repository converter arguments
— mainly for custom read deserialization (e.g. Timestamp -> number/Date).
toFirestoreruns on create-family writes only — do not rely on it for write conversion. The Admin SDK invokes a converter’stoFirestoreonadd/set(create,bulkCreate,upsertwhen creating,createInTransaction) but not onupdate()— so it is skipped byupdate,patch,bulkUpdate,bulkPatch,upsert(when updating),updateInTransaction/patchInTransaction, andquery().update().fromFirestore, by contrast, runs on every read. So keeptoFirestorea pass-through and put read transforms infromFirestore; for write-time normalization that must apply on every path, use abefore*hook (hooks run before validation on all write paths) — see Lifecycle Hooks.
import { Timestamp, FirestoreDataConverter } from 'firebase-admin/firestore';
const userConverter: FirestoreDataConverter<User> = { // Pass-through. Do NOT convert here — the Admin SDK skips toFirestore on update(). toFirestore: user => user as FirebaseFirestore.DocumentData, // Runs on every read: map the stored Timestamp back to a Date. Return data WITHOUT `id` — the // repository overlays the document id after fromFirestore returns. fromFirestore: snapshot => { const data = snapshot.data(); return { ...data, createdAt: (data.createdAt as Timestamp).toDate() } as User; },};
// Write a Date/serverTimestamp() (stored as a Timestamp on every write path); read back a Date.const userRepo = FirestoreRepository.withSchema<User>(db, 'users', userSchema, userConverter);Because fromFirestore receives only the stored document body, it must return data without an
id field; the repository reads the snapshot’s document id and overlays it onto the result after
the converter runs. This is why reads resolve to T & { id } even though the converter never sets
id itself. A raw snapshot from a trigger cloud function is not converter-applied and has no
id — use fromSnapshot to reconstruct the read shape there.
For the common Timestamp -> number case, the built-in
createMillisTimestampConverter packages exactly this shape (recursive read
conversion + pass-through write).
Converter behavior is instance-local by design:
- Parent repositories and subcollections do not share converters automatically.
- Pass a converter explicitly to each
subcollection(...)call that needs converter behavior.
Delete Behavior
Section titled “Delete Behavior”Deletes are explicit hard deletes. Calling delete() removes the document from Firestore
immediately.
await userRepo.delete('user-123');A few details worth knowing:
delete(id)throwsNotFoundErrorif the document does not exist.- Delete lifecycle hooks (
beforeDelete/afterDelete) receive the full persisted document ({ ...data, id }) at runtime, so a hook can inspect what is being removed. bulkDelete(ids)resolves to the count of documents that actually existed — not the length of the input array — so ids that were already absent are not counted.