FirestoreRepository
Full type signatures for FirestoreRepository. For the query builder returned by query(), see
FirestoreQueryBuilder; for the package’s exported types,
see Exported Types; for the error classes and the Express
middleware, see Error Handling.
The four generics
Section titled “The four generics”The repository is generic over four types, inferred by withSchema / subcollection from
schema values:
T— the read-data type,z.output<readSchema>. It carries noid; reads resolve toFirestoreDocument<T>(=Omit<T, 'id'> & { readonly id: ID }), with the id overlaid from the document name.W— the write-input type,z.input<writeSchema>(defaults toT) — the caller’s pre-parse input tocreate/update. AwriteSchemabuilt from the write combinators lets those fields accept their native values and sentinels on writes with no cast — see Per-Field Sentinel Approval.S— the stored-data type,z.output<storedSchema>(defaults toT) — the at-rest shape that query field paths derive from.WO— the parsed-write type,z.output<writeSchema>(defaults toW) — what the SDK persists and after-create hooks observe.
The Firestore document name is the sole authority for id. Schemas describe the document’s own
data and must not declare a top-level id (construction throws if they do) — see
Document Identity. The id is generated on
create / bulkCreate / createInTransaction, or taken from the id argument on update /
patch / upsert / delete, and is never part of a write payload.
UpdateInput<W> reuses the Firestore Admin SDK’s UpdateData<Omit<W, 'id'>>, so update-family
methods accept typed dot-notation field paths ('address.city') — no as any — while create
/ upsert (CreateInput<W> = WithFieldValue<Omit<W, 'id'>>) reject dotted keys. Query field
paths are derived from the stored shape S (excluding the synthetic id) via the exported
FieldPaths helper (with PathValue for resolving a path’s value type). See the
Dot Notation guide.
class FirestoreRepository<T extends object, W extends object = T, S extends object = T, WO extends object = W>
Static methods
Section titled “Static methods”withSchemaArgs<RS extends ZodObject, WS extends ZodObject = RS, SS extends ZodObject = RS>(db: Firestore, collectionPath: string, readSchema: RS, options?: { writeSchema?: WS; storedSchema?: SS; readConverter?: ReadConverter<z.output<RS>>; sentinelPolicy?: SentinelPolicy; allowLegacyDatastoreIds?: boolean; parentPath?: string }): RepositoryConstructorArgs<z.output<RS>, z.input<WS>, z.output<WS>>
Assemble the positional constructor arguments that withSchema would pass, so a subclass can
spread them into super(...). Same options bag as withSchema / subcollection, plus
parentPath. Guarantees the runtime read / write / stored split is correct by construction —
including when a writeSchema overlay is present — so schemas.read is never accidentally the
write overlay and schemas.stored is always populated.
See Advanced Patterns (Custom repository methods).
withSchema and subcollection call this internally (one assembly path).
The stored generic S is checked. The returned tuple carries the stored type, so a subclass
whose extends FirestoreRepository<T, W, S, WO> clause contradicts the storedSchema it passes
fails to compile at the super(...) call — you cannot silently mis-declare the shape that types
collectionGroup() and its field paths. The check is directional: an unrelated S, or one wider
than the stored schema (claiming a field nothing at rest has), is rejected; a narrower S is
accepted, since it only under-reports field paths.
parentPath is a marker: only its presence is read, by isSubcollection() — getParentId()
derives the id from the collection path — so pass the composed subcollection path, as
subcollection does.
withSchema<RS extends ZodObject, WS extends ZodObject = RS, SS extends ZodObject = RS>(db: Firestore, collection: string, readSchema: RS, options?: { writeSchema?: WS; storedSchema?: SS; readConverter?: ReadConverter<z.output<RS>>; sentinelPolicy?: SentinelPolicy; allowLegacyDatastoreIds?: boolean }): FirestoreRepository<z.output<RS>, z.input<WS>, z.output<SS>, z.output<WS>>
Create a schema-validated repository. The read type is z.output<readSchema>, the write-input
type is z.input<writeSchema> (defaults to the read type), and the stored type is
z.output<storedSchema> (defaults to the read type). Build the overlay from the write combinators
so those fields accept their native values / sentinels on create / update with no cast — see
Per-Field Sentinel Approval for the exact
guarantees.
Types are inferred from schema values — do not pass an explicit generic. The read / write /
stored schemas describe the document’s own data and must not declare a top-level id, or
construction throws with a remedial error — the document name is the sole id authority.
options.sentinelPolicy is 'strict' (default) or 'permissive'; strict mode enforces which
sentinel kind each field accepts. When a readConverter is supplied, storedSchema is required
(the converter changes the read shape, so query paths need an explicit at-rest schema) — see
Read Converters. allowLegacyDatastoreIds opts
into accepting legacy Datastore-mode numeric ids. Always returns a plain FirestoreRepository —
subclasses should use withSchemaArgs instead.
raw<T extends object, W extends object = T, S extends object = T>(db: Firestore, collection: string, options?: { readConverter?: ReadConverter<T>; allowLegacyDatastoreIds?: boolean }): FirestoreRepository<T, W, S, W>
Named entry point for an unvalidated (schema-less) repository. Types come from the explicit
generic T; no Zod validation runs. Prefer this over the positional constructor when you need a raw
repository with options — it keeps a security-relevant flag like allowLegacyDatastoreIds
discoverable instead of a trailing positional boolean.
new FirestoreRepository<T extends object, W extends object = T, S extends object = T, WO extends object = W>(...args: RepositoryConstructorArgs<T, W, WO>)
Low-level constructor with optional validation and an optional read-only converter. The argument
tuple is RepositoryConstructorArgs<T, W, WO> — positionally
(db, collectionPath, validator?, parentPath?, readConverter?, schemas?, allowLegacyDatastoreIds?).
The validator is required when WO diverges from W and optional when they match. A
ReadConverter<T> is the fromFirestore(snapshot) => T mapper only; the repository builds the full
FirestoreDataConverter internally and applies it to reads, so toFirestore is never invoked.
Prefer withSchema(...) (or raw(...) for an unvalidated repository) for typical use, and
withSchemaArgs when subclassing.
static suppressWriteOverrideWarning = false
Opt out of the once-per-class console.warn that fires when a subclass overrides a public write
method (update, create, delete, …) on its prototype. The warning exists because sibling write
paths do not route through the override; it points at
registerWriteInterceptor, which does enforce an invariant across every write
path, with the facade as the fallback — see
Enforced denormalization. Set
static suppressWriteOverrideWarning = true
on the subclass before the first instance is constructed. Method-style overrides only are
detected; class-field and constructor-body assignments are not. Because the flag is a normal JS
static, a suppressing parent also silences further subclasses unless they redeclare it false.
getById(id: ID): Promise<FirestoreDocument<T> | null>
Get document by ID. Resolves to null when the document does not exist.
getById(id: ID, options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>> | null>
Same as getById(id), but each result is { doc, metadata } — the document under doc (unchanged
from the default read) plus sibling DocumentMetadata under metadata. Pass the flag inline; a
hoisted const opts = { withMetadata: true } widens to { withMetadata: boolean } and matches no
overload.
getByIdOrThrow(id: ID): Promise<FirestoreDocument<T>>
Get document by ID; throws NotFoundError when missing.
getByIdOrThrow(id: ID, options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>>>
Throws NotFoundError when missing; otherwise returns { doc, metadata } like
getById(id, { withMetadata: true }).
getByIdWithUpdateTime(id: ID): Promise<{ doc: FirestoreDocument<T>; updateTime: Timestamp } | null>
Get a document together with its Firestore updateTime — the token for optimistic-concurrency
writes. Resolves to null when the document does not exist. The result is a pair, not an
overlay, so a stored field named updateTime is never shadowed. Pass updateTime back as
lastUpdateTime on update / patch / delete (or their bulk and transaction variants). A
configured readConverter applies to doc. For the general read-metadata shape (all provenance
fields, not just updateTime), use getById(id, { withMetadata: true }) instead — this method
remains the narrow CAS-token accessor. Not on ReadOnlyTransactionalRepository — it performs
non-transactional I/O. See
Conditional writes.
getMany(ids: ID[]): Promise<(FirestoreDocument<T> | null)[]> /
getMany(ids: ID[], options: { fieldMask: … }): Promise<(FirestoreDocument<DeepPartial<T>> | null)[]> /
getMany(ids: ID[], options: { withMetadata: true }): Promise<(WithMetadata<FirestoreDocument<T>> | null)[]> /
getMany(ids: ID[], options: { withMetadata: true; fieldMask: … }): Promise<(WithMetadata<FirestoreDocument<DeepPartial<T>>> | null)[]>
Batched multi-document read via one BatchGetDocuments RPC (db.getAll). Results are in input
order (SDK client-side re-sort). Missing documents are null in position (ids[i] is the missing
id). Empty input returns [] without contacting Firestore. Duplicate ids are allowed (one entry per
position). Prefer this over query().whereId('in', ids) for id lookups — no 30-value cap, input
order, and misses are marked rather than silently dropped. When fieldMask is supplied, the result
narrows to FirestoreDocument<DeepPartial<T>> (mirroring select()); id always survives;
fieldMask: [] is a legal ID-only projection.
fromSnapshot(snapshot: DocumentSnapshot): FirestoreDocument<T> | null
Map a raw Firestore snapshot — e.g. the one delivered to a trigger cloud function — to
FirestoreDocument<T>, applying the repository’s readConverter fromFirestore when configured
and overlaying the document id. Does no Firestore I/O; returns the read model T (not W), and
null for a non-existent snapshot. Not validated (like other reads); compose validate after a
null guard — see Cloud Functions & triggers.
validate(data: FirestoreDocument<T>): FirestoreDocument<T>
validate(data: FirestoreDocument<T>[]): FirestoreDocument<T>[]
Parse an already-read value through schemas.read and return the parsed output. Throws
ValidationError on mismatch (array form is all-or-nothing). Throws a plain Error if the
repository has no schema. See
Schema Validation.
safeValidate(data: FirestoreDocument<T>): SafeResult<T>
safeValidate(data: FirestoreDocument<T>[]): SafeResult<T>[]
Non-throwing variant of validate. Returns { success: true, data } or
{ success: false, error: ValidationError } (array form: one result per element). Still throws a
plain Error when no schema is configured.
getAll(): Promise<FirestoreDocument<T>[]> /
getAll(options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>>[]>
Get all documents in the collection. Pass { withMetadata: true } for { doc, metadata } rows.
findByField(field: FieldPaths<OmitId<S>> | FieldPath, value: unknown): Promise<FirestoreDocument<T>[]> /
findByField(field, value, options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>>[]>
Find all documents whose field (a stored field path) equals value.
getOneByField(field: FieldPaths<OmitId<S>> | FieldPath, value: unknown): Promise<FirestoreDocument<T> | null> /
getOneByField(field, value, options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>> | null>
Find the first document by field value. Returns null when no document matches.
getOneByFieldOrThrow(field: FieldPaths<OmitId<S>> | FieldPath, value: unknown): Promise<FirestoreDocument<T>>
getOneByFieldOrThrow(field, value, options: { withMetadata: true }): Promise<WithMetadata<FirestoreDocument<T>>>
Find exactly one document by field value. Throws NotFoundError when none match and ConflictError
when multiple documents match.
listenOne(id: ID, callback: (item: FirestoreDocument<T>) => void, onError?: (error: Error) => void): () => void
Subscribe to real-time updates for a single document by ID. Returns an unsubscribe function. See Real-time & Listeners.
listenOneDetailed(id: ID, callback: (item: WithMetadata<FirestoreDocument<T>>) => void, onError?: (error: Error) => void): () => void
Subscribe to a single document and deliver { doc, metadata } on every change — same provenance
fields as { withMetadata: true } reads. Returns an unsubscribe function synchronously. When the
document is deleted, routes to onError(new NotFoundError(...)) (mirrors listenOne) rather
than invoking the callback with a nullable document — the underlying deletion snapshot has no
createTime / updateTime to build metadata from.
Writes
Section titled “Writes”Opt-in write metadata: pass { withMetadata: true } on the helpers below to include the Admin
SDK commit writeTime on the result (WriteMetadata / WriteResultWithMetadata). Default shapes
are unchanged. returnDoc and withMetadata are mutually exclusive. Transactional helpers
(*InTransaction) do not accept withMetadata — the Admin SDK exposes no per-op write receipt
inside a transaction. Fixed batches above 500 ops concatenate receipts in enqueue order across
chunks; a failed chunk contributes no fabricated timestamps (WriteOutcomeError accounting is
unchanged). bulkWrite already returns writeTime on each successful item and does not take this
flag.
create(data: CreateInput<W>, options: { returnDoc: true }): Promise<FirestoreDocument<T>>
create(data: CreateInput<W>, options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }>
create(data: CreateInput<W>, options?: { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }>
Create a new document with an auto-generated Firestore ID. Returns { id } by default; pass
{ returnDoc: true } to resolve to the created FirestoreDocument<T>; pass { withMetadata: true }
for { id, writeTime }. A postcommit read/converter failure after a successful write throws
WriteOutcomeError with phase: 'read-back' (document is persisted).
bulkCreate(data: CreateInput<W>[], options: { returnDoc: true }): Promise<FirestoreDocument<T>[]>
bulkCreate(data: CreateInput<W>[], options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }[]>
bulkCreate(data: CreateInput<W>[], options?: { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }[]>
Create multiple documents, committed in batches of 500. Returns { id }[] by default; pass
{ returnDoc: true } for the created documents, or { withMetadata: true } for positional
{ id, writeTime }[]. Above 500 ops, a later-chunk failure throws
WriteOutcomeError (partially-committed) with exact committedWrites / totalWrites.
createWithId(id: ID, data: CreateInput<W>, options: { returnDoc: true }): Promise<FirestoreDocument<T>>
createWithId(id: ID, data: CreateInput<W>, options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }>
createWithId(id: ID, data: CreateInput<W>, options?: { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }>
Create-only write under a caller-supplied ID — the counterpart to upsert, which overwrites.
Throws ConflictError when a document already exists at that ID. The existence check happens on the
backend as part of the write, so two concurrent calls cannot both succeed. Fires beforeCreate /
afterCreate with the caller’s id. A postcommit { returnDoc: true } converter/read failure throws
WriteOutcomeError with phase: 'read-back'.
bulkCreateWithIds(entries: { id: ID; data: CreateInput<W> }[], options: { returnDoc: true }): Promise<FirestoreDocument<T>[]>
bulkCreateWithIds(entries: { id: ID; data: CreateInput<W> }[], options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }[]>
bulkCreateWithIds(entries: { id: ID; data: CreateInput<W> }[], options?: { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }[]>
Batched create-only writes under caller-supplied IDs. Throws ConflictError if any ID exists — at
or below 500 operations the batch is atomic, so no sibling lands. Above 500, a later-chunk
failure throws WriteOutcomeError (partially-committed) with exact counts. Duplicate IDs in the
input are rejected before any I/O. { returnDoc: true } read-back failures are phase: 'read-back'.
update(id: ID, data: UpdateInput<W>, options: UpdateOptions & { returnDoc: true }): Promise<FirestoreDocument<T>>
update(id: ID, data: UpdateInput<W>, options: UpdateOptions & { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }>
update(id: ID, data: UpdateInput<W>, options?: UpdateOptions & { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }>
Update a document with partial data. Supports dot notation for nested updates. Pass
{ merge: true } to normalize nested objects to dot paths before writing. Pass { lastUpdateTime }
(from getByIdWithUpdateTime) to make the write conditional — it commits only if the document is
still at that version, and otherwise throws PreconditionFailedError. Returns { id } by default;
pass { returnDoc: true } to resolve to the updated FirestoreDocument<T> (postcommit converter
failures are WriteOutcomeError / phase: 'read-back'); pass { withMetadata: true } for
{ id, writeTime }.
patch(id: ID, data: UpdateInput<W>, options: { returnDoc: true; lastUpdateTime?: Timestamp }): Promise<FirestoreDocument<T>>
patch(id: ID, data: UpdateInput<W>, options: { withMetadata: true; lastUpdateTime?: Timestamp }): Promise<{ id: ID; writeTime: Timestamp }>
patch(id: ID, data: UpdateInput<W>, options?: { returnDoc?: false; withMetadata?: false; lastUpdateTime?: Timestamp }): Promise<{ id: ID }>
Merge-style update — equivalent to update(id, data, { merge: true }). patch always merges,
so there is no merge option; { returnDoc: true } resolves to the updated FirestoreDocument<T>,
{ withMetadata: true } adds the commit receipt, and { lastUpdateTime } guards the write exactly
as on update.
bulkUpdate(updates: { id: ID; data: UpdateInput<W>; lastUpdateTime?: Timestamp }[], options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }[]>
bulkUpdate(updates: { id: ID; data: UpdateInput<W>; lastUpdateTime?: Timestamp }[], options?: { withMetadata?: false }): Promise<{ id: ID }[]>
Update multiple documents in a batch. Supports dot notation. Each entry may carry its own
lastUpdateTime; at or below 500 operations one failed precondition rejects the whole batch and
changes nothing. Above 500, a later-chunk failure throws WriteOutcomeError (partially-committed)
with exact committedWrites / totalWrites. Pass { withMetadata: true } for positional
{ id, writeTime }[].
bulkPatch(updates: { id: ID; data: UpdateInput<W>; lastUpdateTime?: Timestamp }[], options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }[]>
bulkPatch(updates: { id: ID; data: UpdateInput<W>; lastUpdateTime?: Timestamp }[], options?: { withMetadata?: false }): Promise<{ id: ID }[]>
Merge-style batch update. Each payload is normalized like patch(...) before the batched writes.
Same partial-batch WriteOutcomeError contract as bulkUpdate above 500 ops. Optional
{ withMetadata: true } matches bulkUpdate.
upsert(id: ID, data: CreateInput<W>, options: { returnDoc: true }): Promise<FirestoreDocument<T>>
upsert(id: ID, data: CreateInput<W>, options: { withMetadata: true }): Promise<{ id: ID; writeTime: Timestamp }>
upsert(id: ID, data: CreateInput<W>, options?: { returnDoc?: false; withMetadata?: false }): Promise<{ id: ID }>
Create or overwrite the document with the given ID. Returns { id } by default; pass
{ returnDoc: true } to resolve to the final persisted FirestoreDocument<T> (postcommit
converter/read failures are WriteOutcomeError / phase: 'read-back'); pass
{ withMetadata: true } for { id, writeTime } on either the create or update branch. Use
createWithId instead when the document must not already exist. Hook dispatch is
existence-dependent: a create fires beforeCreate / afterCreate, an overwrite fires
beforeUpdate / afterUpdate. It also costs an extra read (the existence pre-read).
delete(id: ID, options: { withMetadata: true; lastUpdateTime?: Timestamp }): Promise<{ writeTime: Timestamp }>
delete(id: ID, options?: { withMetadata?: false; lastUpdateTime?: Timestamp }): Promise<void>
Permanently delete a document. Throws NotFoundError when the document does not exist — including
when a lastUpdateTime was supplied, because delete’s own existence pre-read runs first. A
supplied lastUpdateTime that no longer matches throws PreconditionFailedError. Pass
{ withMetadata: true } to resolve to { writeTime } instead of void.
bulkDelete(ids: ID[], options: { withMetadata: true }): Promise<{ count: number; writeTimes: Timestamp[] }>
bulkDelete(ids: ID[], options?: { withMetadata?: false }): Promise<number>
bulkDelete(entries: { id: ID; lastUpdateTime?: Timestamp }[], options: { withMetadata: true }): Promise<{ count: number; writeTimes: Timestamp[] }>
bulkDelete(entries: { id: ID; lastUpdateTime?: Timestamp }[], options?: { withMetadata?: false }): Promise<number>
Permanently delete multiple documents. Resolves to the count of documents that actually existed
(not the length of the input array). With { withMetadata: true }, resolves to
{ count, writeTimes } for surviving documents only — missing requested ids contribute neither a
count nor a fabricated receipt (writeTimes.length === count). The two entry-shape overloads cannot
be mixed in one array. Documents that are already gone are filtered out by the existence pre-read,
so an entry with a lastUpdateTime whose document no longer exists is skipped rather than raising.
bulkWrite(operations: BulkWriteOperation<W>[], options?: BulkWriteOptions): Promise<BulkWriteResult[]>
High-throughput, non-atomic writes backed by the Admin SDK’s BulkWriter, with a positional
result per operation. This is a separate contract from the fixed-batch helpers: each op succeeds
or fails alone; lifecycle hooks do not run (throws if any bulk hook is registered unless
{ skipHooks: true }); duplicate explicit ids are rejected because same-document commit order is
undefined. Validation failures and backend refusals land as { ok: false, error } for that item
while siblings still write. Successful items already include writeTime — there is no separate
withMetadata flag. Optional throttling is forwarded to db.bulkWriter.
recursiveDelete(id: ID): Promise<void>
Destructive. Permanently deletes the document at id and every descendant (all
subcollections, any depth). No lifecycle hooks run; no count is returned. A missing document
resolves (idempotent). Partial failure is reported as a whole-call error — already-deleted docs stay
deleted; re-running is safe. Separate from delete(id), which orphans subcollections.
recursiveDeleteCollection(): Promise<void>
Highly destructive. Permanently deletes every document in this repository’s collection and
every descendant subcollection (any depth). Deliberately separate from recursiveDelete(id) (one
document subtree) and delete(id) (one document, orphans subcollections). When this repository
points at a subcollection, only that concrete subcollection is removed — its parent document and
sibling collections survive. A collection whose id merely shares this collection’s prefix also
survives. No lifecycle hooks run; no count is returned. An empty collection resolves; re-running is
safe. Partial failure is a whole-call error — already-deleted docs stay deleted.
// Wipe every user and every descendant beneath every user.await userRepo.recursiveDeleteCollection();
// Wipe every post below one user; the user document and sibling subcollections survive.const postRepo = userRepo.subcollection('user-123', 'posts', postSchema);await postRepo.recursiveDeleteCollection();Write interceptors
Section titled “Write interceptors”registerWriteInterceptor(interceptor: WriteOnlyInterceptor<T, W, WO>): void
registerWriteInterceptor<R>(interceptor: ReadCapableInterceptor<T, W, WO, R>): void
Register a callback whose writes the repository guarantees commit in the same atomic boundary as the write that triggered them — or that write path refuses. This is the enforcement primitive behind enforced denormalization; that guide is the full treatment, including the capacity trade-off and every refusal.
Registration is per repository instance and lasts for the life of the process, like
on(...). name must be unique on the repository — read-phase results are
keyed by it — and a duplicate throws. Interceptors run in registration order, sequentially; the
first to throw aborts the write, so nothing commits and no later interceptor runs. With none
registered, every write path behaves exactly as it did before.
Two shapes, and the shape decides the boundary:
// Write-only → staged into a WriteBatch. Bulk helpers and query write terminals keep working.type WriteOnlyInterceptor = { name: string; write: (ctx: { write: InterceptedWrite; writer: InterceptorWriter }) => void;};
// Read-capable → the repository runs a Transaction. `R` is inferred from `read`.type ReadCapableInterceptor<R> = { name: string; read: (ctx: { write: InterceptedWrite; reader: InterceptorReader }) => Promise<R>; write: (ctx: { write: InterceptedWrite; writer: InterceptorWriter; reads: R }) => void;};write is synchronous by type and that is deliberate: Firestore requires every read in a
transaction to precede every write, so all I/O belongs in read.
InterceptedWrite — the domain write being observed, discriminated on kind:
kind |
Payload | Notes |
|---|---|---|
'create' |
data: CreateOutput<WO> |
the validated create output, after schema transforms |
'update' |
data: UpdateInput<W> |
the validated update payload; patch reports as 'update' |
'delete' |
document: FirestoreDocument<T> |
the whole stored document — every delete path pre-reads it |
Every member also carries id: ID. upsert reports whichever write it actually performed.
InterceptorWriter — stages writes; it cannot commit and cannot reach the batch or transaction.
Each member takes the target repository positionally, and validates the payload through that
repository (id validation, schema validation, merge normalization, empty-payload rejection), so a
sibling write is checked exactly as a direct call to it would be:
Each member is named after the repository method it stages, and behaves the same way:
| Member | Repository equivalent | Payload | If the document is missing |
|---|---|---|---|
createWithId |
createWithId |
complete | creates it — a collision aborts the whole group |
set |
none — see below | complete | creates it ({ merge: true } keeps unmentioned fields instead of replacing) |
update |
update |
partial | fails, aborting the whole group |
patch |
patch |
partial, nested objects normalized to field paths | fails, aborting the whole group |
delete |
delete |
— | no-op |
set is the one member with no repository counterpart, and it keeps the raw Firestore verb
deliberately. It is not upsert: upsert replaces a nested map wholesale on an existing
document, while set(..., { merge: true }) deep-merges it, so borrowing the name would claim an
equivalence that does not hold. It exists because it is the only way to write a sibling that may not
exist yet without first reading it — the counter case interceptors are for.
set takes the complete write model on both branches, including under { merge: true }. A set
creates the document when it is absent, and a partial payload cannot produce a document that
satisfies its own schema — you would be persisting a record missing its required fields, and since
reads are not validated it would come back typed as complete. If you want to touch a subset of
fields, that is update, and its failure on a missing document is the guard rail: an order write
should not conjure a half-formed user.
The create validator applies to set on both branches, so it also rejects dot-notation keys (which
set() would turn into literal field names) and FieldValue.delete() (whose meaning would depend on
whether the sibling happened to exist — the same reason upsert rejects them).
InterceptorReader — get(repo, id): Promise<FirestoreDocument<T2> | null>, joining the same
transaction the write will be staged into.
Every target repository must be built on the same Firestore instance as the repository being
written; another instance is refused before anything commits (the SDK accepts such a write, reports
success, and lands it in neither database).
Coverage. The single-document writes and the *InTransaction helpers always run interceptors.
The fixed-batch helpers and query().update() / query().delete() run write-only interceptors in
chunked batches and throw for a read-capable one (a transaction cannot be chunked). bulkWrite,
recursiveDelete and recursiveDeleteCollection throw whenever any interceptor is registered —
there is no shared boundary to join, and no { skipHooks: true }-style waiver, because a guarantee
is not a notification. { withMetadata: true } throws under transaction mode only. A
transaction-mode write also throws instead of nesting a second transaction when one is already
open on the same Firestore instance — including the transaction-scoped repo’s own plain
create() / update() — so join with the *InTransaction helpers. See
the nested-write caution.
orderRepo.registerWriteInterceptor({ name: 'order-audit-trail', write: ({ write, writer }) => { if (write.kind === 'delete') { writer.delete(auditRepo, write.id); return; } writer.set(auditRepo, write.id, { orderId: write.id, event: write.kind }, { merge: true }); },});Field-derived targets need a little more care: an 'update' payload carries only the fields being
written, so a field like userId is optional there and the types say so. See
the worked example.
Identity
Section titled “Identity”id(raw: string): ID
Validate an untrusted document id at the boundary and return it as an ID. Throws
InvalidDocumentIdError when raw is malformed (not a string, empty, contains /, is . or
.., matches the __…__ reserved
pattern, or exceeds 1500 bytes). Use it before passing a request-supplied id to getById, update,
etc. See Document Identity.
newId(): ID
Generate a new, validated auto-id without writing a document. Persist under it explicitly with
upsert(id, …) or a transaction set — create() and createInTransaction() each generate their
own fresh id.
Query, hooks & helpers
Section titled “Query, hooks & helpers”query(): FirestoreQueryBuilder<T, W, S>
Create a query builder for complex queries, aggregations, streaming, and real-time listeners. See FirestoreQueryBuilder.
collectionGroup(): FirestoreCollectionGroup<T, S>
Get a handle on the collection group this repository’s collection belongs to — every collection
with the same id, at any depth, including a same-named root collection. The group id is the last
segment of this repository’s collection path, and the handle inherits this repository’s read model,
stored (query-path) model, readConverter, and allowLegacyDatastoreIds policy.
The handle is stateless and reusable. It exposes collectionId, query() (a fresh
collection-group query builder
each call), and fromSnapshot(snapshot) — the group counterpart of the repository’s own
fromSnapshot, returning a CollectionGroupDocument<T> | null with full-path identity.
A collection group is a Query, not a CollectionReference, so the surface is read-only and
results carry path / parentPath alongside id (ids are not unique across a group). Throws
if this repository’s read or stored schema declares a top-level path or parentPath, which
the identity overlay would shadow — the stored model counts because query field paths derive from
it. fromSnapshot(snapshot) throws for a snapshot outside the group (its collection id must
match), so an out-of-group trigger cannot produce a well-typed document carrying the wrong identity.
Collection-group queries also need explicitly created collection-group-scoped indexes in production.
See
collection-group queries.
on(event: HookEvent, fn: (data, context: HookContext) => void | Promise<void>): void
Register a lifecycle hook. Every callback receives a second HookContext argument (event,
execution, retryable, and on transaction before-hooks a diagnostic attempt). One-argument
callbacks remain source-compatible. Hooks run in registration order and fail-fast.
Supported events:
beforeCreate,afterCreatebeforeUpdate,afterUpdatebeforeDelete,afterDeletebeforeBulkCreate,afterBulkCreatebeforeBulkUpdate,afterBulkUpdatebeforeBulkDelete,afterBulkDelete
Payload notes: beforeCreate / beforeUpdate receive the mutable write payload
(CreateInput<W> / UpdateInput<W>);
afterCreate receives the parsed write output (z.output<writeSchema>) plus the generated id;
afterUpdate receives { id }; afterBulkUpdate receives { ids }; beforeBulkDelete /
afterBulkDelete receive { ids: ID[]; documents: FirestoreDocument<T>[] }; single-delete hooks
receive the full persisted document as a FirestoreDocument<T> at runtime. query().update() /
query().delete() run the bulk hooks (beforeBulkUpdate/afterBulkUpdate,
beforeBulkDelete/afterBulkDelete), not the per-document hooks; inside transactions only
before* hooks run, via the transaction-scoped repo passed to runInTransaction (with
execution: 'transaction' and an observed attempt, or null for caller-managed raw
transactions). bulkWrite, recursiveDelete, and recursiveDeleteCollection run no hooks —
bulkWrite throws when any bulk hook is registered unless { skipHooks: true } is passed. See
Lifecycle hooks for full detail.
subcollection<RS extends ZodObject, WS extends ZodObject = RS, SS extends ZodObject = RS>(parentId: ID, subcollectionName: string, readSchema: RS, options?: { writeSchema?: WS; storedSchema?: SS; readConverter?: ReadConverter<z.output<RS>>; sentinelPolicy?: SentinelPolicy; allowLegacyDatastoreIds?: boolean }): FirestoreRepository<z.output<RS>, z.input<WS>, z.output<SS>, z.output<WS>>
Access a subcollection under a specific parent document. Mirrors withSchema: read/write/stored
types are inferred from schema values, and a writeSchema overlay enables cast-free combinator
writes. Converters are explicit per repository instance and are not inherited from the parent
repository. The read / write / stored schemas must not declare a top-level id (construction
throws otherwise); when a readConverter is supplied, storedSchema is required. For an
unvalidated subcollection, construct a repository directly against the full path with
new FirestoreRepository<Order>(db, ${parentPath}/${parentId}/orders). See
Subcollections.
isSubcollection(): boolean
true when this repository targets a subcollection (it has a parent path), false for a top-level
collection.
get schemas(): RepositorySchemaSet | undefined /
get readSchema(): ZodObject | undefined /
get createSchema(): ZodObject | undefined /
get updateSchema(): ZodObject | undefined
The schemas attached to a validated repository, or undefined on an unvalidated one. schemas is
the whole bundle (read / create / update, plus an optional stored); the other three are
convenience getters for its members. A repository constructed with only a validator (and no
schemas argument) has no bundle, so these are undefined and validate / safeValidate throw a
config error.
getParentId(): ID | null
Get the parent document ID (for subcollections); null for a top-level repository.
getCollectionPath(): string
Get the full collection path. Pure — also available on ReadOnlyTransactionalRepository so
query-shaped PITR escape hatches can build a collection reference from the callback repo.
Transactions
Section titled “Transactions”runInTransaction<R>(fn, options: FirebaseFirestore.ReadOnlyTransactionOptions): Promise<R> /
runInTransaction<R>(fn, options?: FirebaseFirestore.ReadWriteTransactionOptions): Promise<R>
Execute a function within a Firestore transaction. Options are forwarded to the Admin SDK
(maxAttempts on read-write; { readOnly: true, readTime? } for a lock-free / PITR snapshot). The
option types are Admin SDK types (FirebaseFirestore.ReadOnlyTransactionOptions /
FirebaseFirestore.ReadWriteTransactionOptions) and are not re-exported by this package. When
readOnly: true, the callback repo is narrowed to ReadOnlyTransactionalRepository (read-safe
members only — write helpers and non-transactional reads are absent from the type). Otherwise the
callback receives a full transaction-scoped repo; use its *InTransaction methods so that hooks
fire correctly. See Transactions.
runReadOnlyAt<R>(readTime: Timestamp, fn): Promise<R>
Convenience for a read-only transaction at readTime. Equivalent to
runInTransaction(fn, { readOnly: true, readTime }). Callback repo is
ReadOnlyTransactionalRepository.
getInTransaction(tx: Transaction, id: ID): Promise<FirestoreDocument<T> | null>
Read a document inside a transaction. Takes a pessimistic lock in a read-write transaction;
lock-free when readOnly: true. Available on both the full repo and
ReadOnlyTransactionalRepository.
getManyInTransaction(tx: Transaction, ids: ID[]): Promise<(FirestoreDocument<T> | null)[]> /
getManyInTransaction(tx: Transaction, ids: ID[], options: { fieldMask: … }): Promise<(FirestoreDocument<DeepPartial<T>> | null)[]>
Batched multi-document read inside a transaction via tx.getAll. Same positional / null-for-miss /
field-mask / empty-input / duplicate-id contract as getMany. In a read-write transaction this
takes pessimistic locks on all requested ids in one round trip; in a read-only / PITR
transaction it is lock-free. Is on ReadOnlyTransactionalRepository (unlike plain getMany,
which performs non-transactional I/O and is deliberately absent).
updateInTransaction(tx: Transaction, id: ID, data: UpdateInput<W>, options?: { merge?: boolean; lastUpdateTime?: Timestamp }): Promise<void>
Update a document within a transaction. Pass { merge: true } to normalize nested objects to dot
paths before writing, and { lastUpdateTime } to guard the write. A failed precondition does
not retry the transaction — Firestore retries on contention, not on a rejected precondition — so
the callback runs once and the transaction fails with PreconditionFailedError. Not on
ReadOnlyTransactionalRepository.
patchInTransaction(tx: Transaction, id: ID, data: UpdateInput<W>, options?: { lastUpdateTime?: Timestamp }): Promise<void>
Merge-style update within a transaction — equivalent to
updateInTransaction(tx, id, data, { merge: true }). Accepts only lastUpdateTime (merge is
implied). Not on ReadOnlyTransactionalRepository.
createInTransaction(tx: Transaction, data: CreateInput<W>): Promise<{ id: ID }>
Create a document within a transaction (auto-generated ID). Returns { id } — a transaction cannot
read a document back after writing it, so there is no returnDoc option. Not on
ReadOnlyTransactionalRepository.
createWithIdInTransaction(tx: Transaction, id: ID, data: CreateInput<W>): Promise<{ id: ID }>
Create-only write under a caller-supplied ID within a transaction. Throws ConflictError if the
ID is taken, without retrying the callback. Only beforeCreate fires (after-hooks never run inside
a transaction). Not on ReadOnlyTransactionalRepository.
deleteInTransaction(tx: Transaction, id: ID, options?: { lastUpdateTime?: Timestamp }): Promise<void>
Delete a document within a transaction, optionally guarded by lastUpdateTime. The transactional
existence read runs first, so a missing document still throws NotFoundError. Not on
ReadOnlyTransactionalRepository.