Lifecycle Hooks
Inject custom logic at specific points in the data lifecycle — auditing, enrichment, validation, and cleanup — without cluttering your business logic.
Overview
Section titled “Overview”Hooks let you observe and shape writes as they flow through the repository. Register them with
on(event, fn); the callback may be synchronous or async (the repository awaits it). A single
event can carry multiple listeners, and they run in registration order.
userRepo.on('afterCreate', async (user, context) => { // context.event === 'afterCreate'; context.execution === 'direct' await auditLog.record('user_created', user);});Hook context (HookContext)
Section titled “Hook context (HookContext)”Every callback receives a second argument: a typed HookContext correlated with the
registration event.
| Field | Meaning |
|---|---|
event |
The registration event (useful for shared multi-event handlers). |
execution |
'direct' for normal repository/query writes; 'transaction' only on before* hooks fired from *InTransaction helpers. |
retryable |
false for direct; true for transaction before-hooks (the Admin SDK may re-run the callback under contention). |
attempt |
Present only on the transaction branch: a 1-based count of how many times runInTransaction entered the Admin SDK callback, or null when the caller owns a raw Admin SDK transaction. Diagnostic only — never an idempotency key. |
One-argument callbacks remain source-compatible (TypeScript allows fewer parameters).
Delivery rules
Section titled “Delivery rules”- Hooks run in registration order, each
awaited sequentially. - The first thrown/rejected hook stops later hooks for that event (fail-fast). There is no best-effort or aggregate delivery.
- Transaction
before*hooks may run once per callback attempt under contention. Key side effects by a business / write identity stored atomically with the data — not byattempt. after*hooks are postcommit, in-process, and not durable across process crash. Prefer idempotent side effects, or a durable outbox when available (tracked separately as #80).
When an outcome-sensitive failure occurs (before-hook, after-hook, partial fixed batch, or
postcommit { returnDoc: true } read-back), the repository throws WriteOutcomeError with a
discriminated outcome and the original failure as cause. Ordinary validation / conflict /
precondition errors remain top-level when no write committed and no hook is the failed phase. See
Error Handling.
Hook execution order
Section titled “Hook execution order”before*hooks run first and can enrich or normalize the payload before schema validation.- The validated payload is the one persisted to Firestore.
after*hooks run only after a successful write.- Bulk operations fire the corresponding
beforeBulk*/afterBulk*events with the same ordering guarantees.
Because before* runs before validation, it is the correct place to fill in defaults, coerce
values, or reject a write early. See
CRUD operations for the write methods
these hooks wrap.
Available hooks
Section titled “Available hooks”- Single operations:
beforeCreate,afterCreate,beforeUpdate,afterUpdate,beforeDelete,afterDelete - Bulk operations:
beforeBulkCreate,afterBulkCreate,beforeBulkUpdate,afterBulkUpdate,beforeBulkDelete,afterBulkDelete
upsert dispatches on existence
Section titled “upsert dispatches on existence”upsert(id, data) does an existence pre-read and then takes one of two branches, so which hooks
fire depends on whether the document already exists:
Document at id |
Hooks fired |
|---|---|
| does not exist | beforeCreate → afterCreate |
| already exists | beforeUpdate → afterUpdate |
If a rule must hold for every upsert, register it on both pairs — or use createWithId (always
create-only) when you want a single, predictable branch.
Operations that run no hooks
Section titled “Operations that run no hooks”Three write paths deliberately fire nothing:
bulkWrite— high-throughput, non-atomic BulkWriter path. If any bulk hook is registered on the repository, the call throws unless you pass{ skipHooks: true }to acknowledge that those hooks will not fire.recursiveDelete— deletes a document and every descendant; no per-document or bulk delete hooks run (the SDK streams name-only snapshots and descendants live in collections this repository may not model).recursiveDeleteCollection— deletes every document in this repository’s collection and every descendant; the same name-only / cross-collection reasons apply, so no delete hooks can honestly supply modeled payloads.
Do not assume every write goes through the hook table above.
Both recursive deletes throw when a
write interceptor is registered on the
repository, rather than deleting past it silently — the same reason bulkWrite throws on a registered
bulk hook, applied to a guarantee instead of a notification. So an invariant enforced by an
interceptor holds on these paths by refusing them; an invariant enforced by hooks does not hold on
them at all.
Hook payloads
Section titled “Hook payloads”| Event | Payload |
|---|---|
beforeCreate |
The create payload (before validation), plus a readonly id when the caller supplied one (createWithId, upsert). Absent for auto-id create(), so the field is optional. |
afterCreate |
The parsed write output (z.output<writeSchema>, coercions/defaults applied) plus the generated id |
beforeUpdate |
The update payload plus the target id (data & { id }) |
afterUpdate |
{ id } |
beforeDelete / afterDelete |
The full persisted document (FirestoreDocument<T>) |
beforeBulkCreate |
An array of create payloads (before validation), each with a readonly id — ids are generated before the hook runs, so they are always present here |
afterBulkCreate |
An array of parsed write outputs (z.output<writeSchema>), each plus its generated id |
beforeBulkUpdate |
{ id, data }[] |
afterBulkUpdate |
{ ids: ID[] } |
beforeBulkDelete / afterBulkDelete |
{ ids: ID[]; documents: FirestoreDocument<T>[] } |
Delete hooks (single and bulk) receive the full persisted document(s) as they existed before
deletion, so cleanup logic has access to every field, not just the id.
Hook payload immutability
Section titled “Hook payload immutability”Hook payloads protect identity and accounting:
- Identity is read-only. The
id/idson a payload cannot be repointed by a hook, and the event envelopes and bulk arrays are frozen — a hook cannot reorder, splice, or replace entries to redirect or suppress a write. - Before-update hooks may mutate data in place (
entry.data.someField = …) but may not replace the wholedataobject. - Delete payloads are observe-only and deep-frozen, so a
beforeDelete/beforeBulkDeletehook cannot forge nested data that a laterafterDelete/afterBulkDeletehook (or an audit/outbox consumer) then observes. Limitation: a class-instance field value returned by areadConverter(e.g. a mutableDate,Map, or custom class) is not cloned or frozen — treat such values as observe-only by convention.
Examples
Section titled “Examples”// Log all user creationsuserRepo.on('afterCreate', async user => { console.log(`User created: ${user.id}`); await auditLog.record('user_created', user);});
// Send welcome emailuserRepo.on('afterCreate', async user => { await sendWelcomeEmail(user.email);});
// Validate business rules before updateorderRepo.on('beforeUpdate', data => { if (data.status === 'shipped' && !data.trackingNumber) { throw new Error('Tracking number required for shipped orders'); }});
// Enrich create payload before validation (e.g., timestamps/defaults)orderRepo.on('beforeCreate', data => { data.createdAt = new Date().toISOString(); data.updatedAt = new Date().toISOString();});
// Clean up related data after deletionuserRepo.on('afterDelete', async user => { await orderRepo.query().where('userId', '==', user.id).delete();});In the last example, query().delete() is a query-level bulk write: it runs
beforeBulkDelete / afterBulkDelete on the target repository, but does not run the
per-document beforeDelete / afterDelete hooks — which is exactly what you want here, since it
avoids re-triggering per-document cleanup logic recursively.
Query-level writes run the bulk hooks
Section titled “Query-level writes run the bulk hooks”query().update(data) runs beforeBulkUpdate and afterBulkUpdate; query().delete() runs
beforeBulkDelete and afterBulkDelete. beforeBulkUpdate may mutate the update payload before it
is validated and written, afterBulkUpdate receives { ids } for the written documents, and the
bulk-delete hooks receive { ids, documents }. The per-document before/afterUpdate and
before/afterDelete hooks do not run on query-level writes — use the single-document methods
when you need those. Separately, bulkWrite, recursiveDelete, and recursiveDeleteCollection run
no hooks at all (see
above) — and all three throw when a
write interceptor is registered. See
Queries.
When hooks do not run
Section titled “When hooks do not run”Hooks are wired into the per-document and bulk methods on the repository. One path differs from that standard flow:
- Transactions —
before*only. InsiderunInTransaction((tx, repo) => { ... }), the transaction-scopedrepo’s write helpers (createInTransaction,updateInTransaction,patchInTransaction,deleteInTransaction) do run theirbefore*hooks (before validation and the staged write). Theirafter*hooks do not run — the transaction has not committed while the callback executes, so post-commit side effects belong afterrunInTransactionresolves. See Transactions.