Skip to content

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 repository is generic over four types, inferred by withSchema / subcollection from schema values:

  • T — the read-data type, z.output<readSchema>. It carries no id; reads resolve to FirestoreDocument<T> (= Omit<T, 'id'> & { readonly id: ID }), with the id overlaid from the document name.
  • W — the write-input type, z.input<writeSchema> (defaults to T) — the caller’s pre-parse input to create / update. A writeSchema built 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 to T) — the at-rest shape that query field paths derive from.
  • WO — the parsed-write type, z.output<writeSchema> (defaults to W) — 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>

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.

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();

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.

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(): 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, afterCreate
  • beforeUpdate, afterUpdate
  • beforeDelete, afterDelete
  • beforeBulkCreate, afterBulkCreate
  • beforeBulkUpdate, afterBulkUpdate
  • beforeBulkDelete, 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.

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.