Migrating from v2 to v3
Upgrade guide for moving from @reggieofarrell/firestore-orm 2.x to flintfire 3.x.
v3 is published only as the unscoped package flintfire. There is no
@reggieofarrell/firestore-orm@3. Change the package name and every import specifier first, then
apply the API-level breaking changes below.
This page covers only the v2 → v3 contract. If you are still on the older upstream package
(@spacelabstech/firestoreorm / 1.x), migrate to 2.x first — see the project
CHANGELOG entry for
2.0.0.
Use the version switcher in the docs header to compare against the archived v2 docs while you upgrade.
Breaking changes
Section titled “Breaking changes”v3 tightens several public contracts. Review each section below; everything under Migration steps and Recommended upgrades is optional cleanup.
1. Virtual document identity — schemas no longer declare id
Section titled “1. Virtual document identity — schemas no longer declare id”This is the defining v3 change. The Firestore document name is the sole authority for id.
Schemas describe the document’s own data only and must not declare a top-level id — a read,
write, or stored schema with a top-level id is rejected at construction with a remedial error.
// v2 — id declared in the schemaconst userSchema = z.object({ id: z.string(), name: z.string(), email: z.email() });
// v3 — remove the top-level id; the document name is the idconst userSchema = z.object({ name: z.string(), email: z.email() });type User = z.infer<typeof userSchema>; // read-data shape (no id)If your v2 collections stored id === name (the common mirror pattern), the field becomes inert —
drop it from the schema now; the stored copy can be cleaned up later with an optional migration and
is harmless in the meantime.
What this changes:
- Reads return
FirestoreDocument<T>(Omit<T, 'id'> & { readonly id: ID }), replacing the oldT & { id }. Theidis always overlaid from the document name, never from the document’s own fields.DataOf<R>,StoredDataOf<R>, andDocumentOf<R>extract these types from a repository. - Write input is
z.input<writeSchema>(theWgeneric), the caller’s pre-parse input — neverz.infer, and never containingid. AwriteSchemaoverlay changes only field write types. - Four repository generics:
FirestoreRepository<T, W = T, S = T, WO = W>— the newS(stored data,z.output<storedSchema>) is the source of query field paths, andWOis the parsed write output. Query field paths now derive fromS, excluding the syntheticid. - Query by id with
whereId/orderById.where('id', …)andorderBy('id')are now compile errors (the synthetic id is not a stored field path). UsewhereId(op, value)(scalar ops take astring;in/not-intake areadonly string[]) andorderById(direction?), which query the document name natively. - Validated id boundaries. Every id-taking method validates its id and rejects one containing
/, that is.or.., that matches a__…__reserved pattern, that is not a string, that is empty, or that exceeds 1500 bytes — throwing the newInvalidDocumentIdError. Userepo.id(raw)to validate an untrusted id at the boundary, andrepo.newId()to mint a validated auto-id without writing. Reads echo the document name asid, never a caller-supplied path. - Legacy Datastore ids. If you must address imported Datastore-mode numeric ids, opt in with
allowLegacyDatastoreIds: trueonwithSchema/raw/subcollection.
2. Value-inferred withSchema / subcollection
Section titled “2. Value-inferred withSchema / subcollection”Factories no longer accept an explicit read generic (withSchema<User>(…)) or a curried call
(withSchema<User>()(…)). Read and write types are inferred from schema values, and every
optional argument lives in a trailing options object.
| v2 | v3 |
|---|---|
withSchema<User>(db, col, schema) |
withSchema(db, col, schema) |
withSchema<User>()(db, col, writeSchema) |
withSchema(db, col, readSchema, { writeSchema }) |
Positional converter, opts |
{ writeSchema?, storedSchema?, readConverter?, sentinelPolicy?, allowLegacyDatastoreIds? } |
Untyped subcollection(parentId, name) |
new FirestoreRepository(db, fullPath) (or pass a schema) |
Curried path exposed write schema as schemas.read |
schemas.read is always the plain read schema |
Hand-written interfaces can no longer be passed as the factory generic — derive the type from the
schema: type User = z.infer<typeof userSchema> (with no top-level id — see section 1). For
an unvalidated repository, prefer the new FirestoreRepository.raw<User>(db, 'users', options?)
static over the positional constructor.
3. Converters are read-only (converter → readConverter)
Section titled “3. Converters are read-only (converter → readConverter)”The old converter option accepted a full FirestoreDataConverter<T>. Only fromFirestore was
reliable on reads; toFirestore ran on some create-family writes and was skipped on updates.
In v3:
- The option is renamed
readConverter. - It accepts only a
ReadConverter<T>— thefromFirestoremapper(snapshot) => T. - A
readConverternow requires astoredSchema. Because the converter changes the read shape, the at-rest schema that query field paths derive from must be supplied explicitly. createMillisTimestampConverter()returns that mapper (not a full converter).- Any create-time write transform that lived in
toFirestoremust move to abefore*hook.
4. Type-safe, validated dot-notation and query paths
Section titled “4. Type-safe, validated dot-notation and query paths”Dot-notation is now first-class instead of a stringly-typed, runtime-only feature. Most code needs
no change — you can delete the as any casts you previously used on nested updates — but a few
contracts tightened:
- Query field paths are typed from the stored shape.
where,orderBy,select(and the vector builder’swhere/select) acceptFieldPaths<OmitId<S>> | FieldPathinstead of an arbitrarystring. Use the exportedOmitIdhelper — not built-inOmit<S, 'id'>— becauseOmitflattens{ id: string; name: string } & Record<string, unknown>to the index signature and losesnameas a typed path.OmitIdomits declaredidfrom the path-facing key set while reconstructing original index signatures, so declared siblings stay typed paths and arbitrary map keys still require an SDKFieldPath. Typos and unknown paths are now compile errors, and nested paths (orderBy('address.city')) are supported. For a genuinely dynamic field name, pass aFieldPath(where(new FieldPath(name), '==', v)) instead of a computed string. Querying the document id useswhereId/orderById(see section 1). idis no longer a writable update key, andcreate/upsertreject dot-notation keys (a compile error, and a runtime error if forced with a cast — Firestore would create a field whose name literally contains a dot). Use a nested object on create.- Behavior fix (important): in v2, explicit dot-notation update keys on a schema-validated
repository were silently stripped and never written. In v3 they are validated and persisted; a bad
leaf value or an unknown field path now throws
ValidationErrorinstead of silently doing nothing. If you relied on that no-op, audit those call sites. query().update(...)returns the number of documents written. A payload that sanitizes to empty is rejected with aValidationErrorrather than skipped (see #10), so on success the count equals the matched count.bulkPatch’sbeforeBulkUpdatehook now receives the raw (un-flattened) input, matching single-documentpatch. In v2 it saw pre-flattened dot-notation keys. A hook that readupdate.data['profile.verified']should readupdate.data.profile?.verified(or handle both).
Hook context and write-outcome errors
Section titled “Hook context and write-outcome errors”Lifecycle callbacks may accept a second HookContext argument (one-argument callbacks remain
source-compatible). Transaction before* hooks were already supported; v3 makes retry execution
observable via execution: 'transaction' and a diagnostic attempt (or null for caller-managed
raw transactions). Do not use attempt as an idempotency key.
Outcome-sensitive failures (hook throw, partial fixed batch after prior success, postcommit
{ returnDoc: true } read-back) now surface as WriteOutcomeError with a discriminated outcome
and the original error as cause. Ordinary validation / conflict / precondition errors remain
top-level when no write committed and no hook is the failed phase. See
Error Handling and
Lifecycle Hooks.
New type helpers FieldPaths<T> and PathValue<T, P> are exported from the package root.
5. zod peer floor raised to ^4.0.0
Section titled “5. zod peer floor raised to ^4.0.0”The zod peer range is now ^4.0.0 (was ^3.25.0 || ^4.0.0). If you are still on zod 3, upgrade
to zod 4 — see the zod v4 migration guide. No FlintFire API
changes accompany this bump; the validator internals now target the v4 schema shapes only.
6. create / bulkCreate / createInTransaction return { id } by default
Section titled “6. create / bulkCreate / createInTransaction return { id } by default”These methods previously returned the created document cast to the read type, but never actually
read it back — so with a divergent read/write schema or a readConverter, the runtime value did not
match the promised read model. They now return only { id } (or { id }[]); pass
{ returnDoc: true } to create/bulkCreate to read the document back through the readConverter
and get the converted FirestoreDocument<T> (matching update/upsert). createInTransaction
returns { id } only (a transaction cannot read a document back after writing it).
// v2: const user = await repo.create(input); user.name // full doc// v3:const { id } = await repo.create(input); // default: id onlyconst user = await repo.create(input, { returnDoc: true }); // FirestoreDocument<T>7. sentinelPolicy defaults to 'strict'
Section titled “7. sentinelPolicy defaults to 'strict'”The default flips from 'permissive' to 'strict'. Under permissive, a FieldValue sentinel on a
field whose schema did not explicitly allow it silently caused the entire raw payload to be
written, discarding every Zod coercion, default, and transform elsewhere. Under strict, only
sentinels a field’s schema permits pass (declare them with the write combinators zNumberWrite /
zArrayWrite / zDateWrite / withDelete / zSentinel), and the parsed Zod output is always
returned. Pass { sentinelPolicy: 'permissive' } to withSchema/subcollection to keep the old
behavior as a migration shim. See
Field-value sentinels.
8. FieldValue.delete() is rejected on create / set / upsert
Section titled “8. FieldValue.delete() is rejected on create / set / upsert”FieldValue.delete() clears a field, which is only meaningful on an update. v3 rejects it on every
create/set chokepoint — create, bulkCreate, createInTransaction, and upsert — scanning the
parsed write output, so a transform- or default-introduced delete is caught too. Use update()
or patch() to clear a field. The other sentinels (increment, arrayUnion, arrayRemove,
serverTimestamp) remain valid on create.
9. Aggregations: totalCount → collectionCount, and average returns number | null
Section titled “9. Aggregations: totalCount → collectionCount, and average returns number | null”QueryBuilder.totalCount()is renamed tocollectionCount(). The name now signals that it counts the whole base collection and ignores the builder’swhereclauses;count()stays the single query-aware count.average(field)returnsnumber | null(was effectivelynumber). It resolves tonullwhen there are no numeric values to average, so “no data” stays distinct from a genuine average of0.sum(field)still returnsnumber(0on no match).aggregate(spec)is new in 3.0.0: multiple aliasedcount/sum/averagevalues in one round trip (typed aliases; backend max 5). See Aggregations.explain(options?)is new in 3.0.0 for Core and vector queries (afterfindNearest): returns{ metrics, documents }(documentsisnullplan-only,[]when analyzed empty). The emulator throwsNo explain results— real metrics need production Firestore. See Query Explain.explainStream(options?)is new in 3.0.0 for Core queries only (collection + collection-group): streams mapped document chunks and optional metrics. No vector equivalent. Locally rejectslimitToLast(useexplain()). The emulator streams documents without metrics — do not treat that as production diagnostics. See Query Explain.distinctValues(field)now drops onlyundefinedand preserves a storednullas a distinct value, and dedupes structured/reference values by Firestore-aware semantic equality (maps/arrays structural, key order irrelevant;Timestamp/GeoPoint/DocumentReference/Bytes/VectorValueby value). UnrecognizedreadConverteroutput falls back to identity.
10. Empty update payloads are rejected
Section titled “10. Empty update payloads are rejected”An update whose payload is empty after validation (e.g. every value undefined) previously skipped
the write and reported success — so a missing document looked “updated”. update, patch,
bulkUpdate, bulkPatch, updateInTransaction, and query().update() now throw a
ValidationError for an empty patch. Provide at least one field, or use delete() to remove a
document. (A mixed payload still filters undefined leaves and writes the rest.)
11. errorHandler moved to the flintfire/express subpath
Section titled “11. errorHandler moved to the flintfire/express subpath”The Express middleware is no longer exported from the package root; import it from the optional
flintfire/express subpath and install express (now an optional peer). This
keeps express out of the core type graph so consumers who never use the adapter can type-check
without @types/express. The FirestoreIndexError response is now 503 (was 404), and its body
no longer includes the Firestore index-console URL — that URL can disclose project/database and
index structure, so it is kept server-side on the caught error’s indexUrl for logging only.
// v2: import { errorHandler } from '@reggieofarrell/firestore-orm';// v3:import { errorHandler } from 'flintfire/express';12. Node 22+ and Firebase Admin 14
Section titled “12. Node 22+ and Firebase Admin 14”The declared engine floor is now Node.js 22 (18/20 are end-of-life). That floor comes from
firebase-admin 14, which itself requires Node >= 22 — the library’s own code targets ES2020,
so if you stay on firebase-admin 12/13 it still runs on Node 18+ (just outside the
tested/supported window; engines is advisory, so npm warns rather than blocks). The
firebase-admin peer range adds ^14.0.0 (12/13 remain supported), and the TypeScript floor is
5.5 (required by zod 4). v3 also ships a dual ESM + CommonJS build — CommonJS consumers can
now require() the package (this is additive; existing ESM imports are unchanged).
13. Vector search adds vectorQuery() (no longer overrides query())
Section titled “13. Vector search adds vectorQuery() (no longer overrides query())”withVectorSearch(repo) used to replace query() with a restricted vector builder. In v3 it leaves
query() returning the normal FirestoreQueryBuilder and adds a vectorQuery() entry point.
Migrate .query().findNearest(…) to .vectorQuery().findNearest(…). The object-form findNearest
requires @google-cloud/firestore >= 7.10 (guaranteed by firebase-admin >= 13), and
vectorEmbeddingSchema now enforces finite / exact / maximum dimensions on native
FieldValue.vector() values too. See
Vector search.
14. Type-only tightening (projection, aggregation)
Section titled “14. Type-only tightening (projection, aggregation)”These change only compile-time types (no runtime behavior):
- After
select(...), query reads returnFirestoreDocument<DeepPartial<T>>— every data property, including nested map properties, is optional, so a field you projected away (at any depth, e.g. an unselected sibling ofselect('address.city')) is a compile error to access without a guard. (select()also now returns a new builder — see Query-builder behavior refinements below.) findByFieldand itsgetOneByField*siblings accept typed stored field paths and takevalue: unknown.
15. Transactions: getForUpdateInTransaction → getInTransaction
Section titled “15. Transactions: getForUpdateInTransaction → getInTransaction”getForUpdateInTransaction(tx, id) is renamed to getInTransaction(tx, id). The method body
was always mode-agnostic (tx.get + id overlay); locking is a property of the transaction mode, not
of the method. Under a read-only / PITR transaction the old name was false in both halves — nothing
is locked and no update can follow — and it is the sole transaction-scoped document read on the
new ReadOnlyTransactionalRepository surface (mapping still goes through fromSnapshot for
query-shaped PITR), so it fronts every PITR example. There is no deprecated alias. Same kind of
rename as
collectionCount
(ADR-0021 D11), landed in the same 3.0.0 release.
v3 also adds transaction options (runInTransaction(fn, options?) with maxAttempts /
{ readOnly: true, readTime? }) and runReadOnlyAt(readTime, fn). See
Transactions.
Smaller hardening you are unlikely to hit: pagination inputs must be positive finite integers, bulk operations reject duplicate ids, cursors are bound to their collection, and vector validation rejects non-finite values.
16. parseFirestoreError reclassifies create-only collisions and failed preconditions
Section titled “16. parseFirestoreError reclassifies create-only collisions and failed preconditions”parseFirestoreError is publicly exported, and in v3 it normalizes two Firestore status codes
it previously returned unchanged:
| Firestore status | v2 (and earlier) | v3 |
|---|---|---|
gRPC 6 / already-exists |
raw Error |
ConflictError (HTTP 409 via Express) |
gRPC 9 / failed-precondition (non-index) |
raw Error |
PreconditionFailedError (HTTP 412) |
Missing-index errors (code 9 whose details contain requires an index) still become
FirestoreIndexError — that narrower check stays above the blanket precondition branch.
If your application caught a raw Firestore Error and inspected .code after any repository
operation (not only the new conditional-write surfaces), switch those branches to
instanceof ConflictError / instanceof PreconditionFailedError. The new create-only /
lastUpdateTime APIs (createWithId, getByIdWithUpdateTime, …) are additive — see
Conditional writes
and Errors.
Behavior fix: Zod defaults are no longer injected on a partial update()
Section titled “Behavior fix: Zod defaults are no longer injected on a partial update()”This is not a breaking API contract (no code change is required to compile) — it removes a silent data-loss bug, so it is called out here separately from the three breaking contracts above.
In v2, a partial update() on a schema-validated repository re-applied every field’s Zod
.default(...), including for fields you did not mention. On a schema with, say,
prefs: z.object({ … }).default({}), calling update(id, { name }) silently wrote prefs: {} and
overwrote the stored prefs map — data loss for a field the caller never touched. (This bit any
field with a default, and is especially easy to hit with the read-side .default(...) backfill
pattern recommended in
Schema Evolution.)
In v3, a partial update writes only the keys you actually provide, at every nesting level;
update(id, { config: {} }) writes {} rather than re-injecting a nested count default. Defaults
still apply on create. No migration is needed — but if you were relying on a partial update to
re-apply a default, set that value explicitly in the update payload.
Query-builder behavior refinements
Section titled “Query-builder behavior refinements”A few smaller behavior changes you are unlikely to hit unless you use these patterns:
-
select()returns a new builder (immutable). Fluent chains (repo.query().where(…).select(…).get()) are unaffected. Only code that calledselect()for its side effect on a retained builder reference must switch to the returned builder:// Before: the original `q` was (unsoundly) projected in place.const q = repo.query();q.select('name');const rows = await q.get(); // now returns FULL documents (q was never projected)// After: use the builder select() returns.const projected = repo.query().select('name');const rows = await projected.get(); -
select().onSnapshot()now throws locally — Firestore does not allow a real-time listener on a field-masked query. Listen withoutselect()and project in your callback, or useget()/stream(). -
query().update({})on a zero-match query now throwsValidationError(it previously returned0). The empty-update contract is no longer data-dependent. A valid, non-empty payload against a zero-match query still returns0. -
Vector
select()+distanceResultField: pass only stored fields toselect(); do not list the computed distance field.findNearest()appends it and widens the mask automatically, and it appears in the result type.
Migration steps
Section titled “Migration steps”Rename the package and import specifiers
Section titled “Rename the package and import specifiers”v3 does not publish under the old scoped name. Uninstall 2.x, install FlintFire, then change every
package import before addressing API-level breaking changes. Do not write ./vector or
./express — those are package.json "exports" keys, not consumer specifiers (T18).
npm uninstall @reggieofarrell/firestore-ormnpm install flintfire@^3 firebase-admin zodyarn remove @reggieofarrell/firestore-ormyarn add flintfire@^3 firebase-admin zodpnpm remove @reggieofarrell/firestore-ormpnpm add flintfire@^3 firebase-admin zodThen update import specifiers:
| v2 | v3 |
|---|---|
@reggieofarrell/firestore-orm |
flintfire |
@reggieofarrell/firestore-orm/vector |
flintfire/vector |
@reggieofarrell/firestore-orm/express |
flintfire/express |
// v2import { FirestoreRepository } from '@reggieofarrell/firestore-orm';import { withVectorSearch } from '@reggieofarrell/firestore-orm/vector';import { errorHandler } from '@reggieofarrell/firestore-orm/express';
// v3import { FirestoreRepository } from 'flintfire';import { withVectorSearch } from 'flintfire/vector';import { errorHandler } from 'flintfire/express';Drop curry and explicit <T> on factories
Section titled “Drop curry and explicit <T> on factories”Before (v2):
type User = { id: string; name: string; email: string };
const userSchema = z.object({ id: z.string(), name: z.string(), email: z.email(),});
// Direct — writes typed as the read typeconst userRepo = FirestoreRepository.withSchema<User>(db, 'users', userSchema);
// Curried — write type inferred from a write/combinator schemaconst userRepoCurried = FirestoreRepository.withSchema<User>()(db, 'users', userWriteSchema);After (v3):
const userSchema = z.object({ // No top-level `id` — the document name is the id (see breaking change #1). name: z.string(), email: z.email(),});
type User = z.infer<typeof userSchema>;
const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema);
// Clean read schema + combinator write overlayconst userRepoStrict = FirestoreRepository.withSchema(db, 'users', userSchema, { writeSchema: userWriteSchema, sentinelPolicy: 'strict',});withSchema<User>(…) intentionally fails to compile in v3 (User is not a ZodObject).
Move converter / options into the options object
Section titled “Move converter / options into the options object”Before (v2):
FirestoreRepository.withSchema<User>(db, 'users', userSchema, converter, { sentinelPolicy: 'strict',});
// Filler undefined when you only needed opts:FirestoreRepository.withSchema<User>(db, 'users', userSchema, undefined, { sentinelPolicy: 'strict',});After (v3):
import type { ReadConverter } from 'flintfire';
const userReadConverter: ReadConverter<User> = snap => ({ ...snap.data() }) as User;
FirestoreRepository.withSchema(db, 'users', userSchema, { readConverter: userReadConverter, storedSchema: userStoredSchema, // required whenever readConverter is set (the at-rest shape) sentinelPolicy: 'strict',});
// Reuse an existing converter's read half:FirestoreRepository.withSchema(db, 'users', userSchema, { readConverter: existingConverter.fromFirestore.bind(existingConverter), storedSchema: userStoredSchema,});Relocate toFirestore write transforms into hooks
Section titled “Relocate toFirestore write transforms into hooks”Before (v2) — toFirestore on create-family writes only:
const converter: FirestoreDataConverter<User> = { fromFirestore: snap => ({ ...snap.data() }) as User, toFirestore: data => ({ ...data, updatedAt: FieldValue.serverTimestamp(), }),};After (v3) — read mapper + hook:
const userReadConverter: ReadConverter<User> = snap => ({ ...snap.data() }) as User;
// A hook writes `serverTimestamp()` into `updatedAt`, and hooks run BEFORE validation — so under// the v3 default `sentinelPolicy: 'strict'` that field's write schema must permit the sentinel// (see breaking change #7). Without this overlay every write would throw a ValidationError.const userWriteSchema = userSchema.extend({ updatedAt: z.union([z.string(), zSentinel('serverTimestamp')]),});
const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema, { writeSchema: userWriteSchema, readConverter: userReadConverter, storedSchema: userStoredSchema, // required whenever readConverter is set});
// v3 before-hooks MUTATE the payload in place and return void (they do not return a new object).userRepo.on('beforeCreate', async data => { data.updatedAt = FieldValue.serverTimestamp();});
userRepo.on('beforeUpdate', async data => { data.updatedAt = FieldValue.serverTimestamp();});createMillisTimestampConverter() is still a drop-in for readConverter — only its return type
narrowed. See Timestamps ↔ Millis.
Fix untyped subcollections
Section titled “Fix untyped subcollections”Before (v2):
const orders = userRepo.subcollection('user-123', 'orders');After (v3) — pass a schema, or use the raw constructor:
const orders = userRepo.subcollection('user-123', 'orders', orderSchema);
// Unvalidated (same pattern as a top-level raw repo):const ordersRaw = new FirestoreRepository<Order>(db, 'users/user-123/orders');Recommended upgrades (non-breaking)
Section titled “Recommended upgrades (non-breaking)”These APIs are additive in v3. Adopt them while you migrate; they are not required to compile.
Prefer validate / safeValidate over schemas.read.parse
Section titled “Prefer validate / safeValidate over schemas.read.parse”The old trigger workaround leaked a raw ZodError:
// v2 workaround — prefer not to keep thisrepo.schemas?.read.parse(repo.fromSnapshot(snap));Use the repository validators instead:
const mapped = event.data && repo.fromSnapshot(event.data);if (!mapped) return;const user = repo.validate(mapped); // ValidationError on mismatch
const results = repo.safeValidate(docs); // SafeResult<T>[] — filter failuresDetails: Schema Validation and Firestore Triggers.
Checklist
Section titled “Checklist”- Remove the top-level
idfrom every read / write / stored schema — it is now rejected at construction; the document name is the id, overlaid on reads asFirestoreDocument<T> - Replace
where('id', …)/orderBy('id')withwhereId(op, value)/orderById(direction?) - Validate untrusted ids with
repo.id(raw)(catchesInvalidDocumentIdError); userepo.newId()for an id you need before writing - Drop
()curry and explicit<User>(or similar) onwithSchema/subcollection; preferFirestoreRepository.raw<User>(…)for an unvalidated repository - Derive read types with
z.infer<typeof schema>(no top-levelid); write input isz.input<writeSchema> - Move positional
converter/{ sentinelPolicy }into{ writeSchema?, storedSchema?, readConverter?, sentinelPolicy?, allowLegacyDatastoreIds? } - Rename
converter→readConverter; pass only thefromFirestoremapper, and add the now- requiredstoredSchema - Convert
addHook(event, fn)torepo.on(event, fn); make before-hooks mutate the payload in place (no return value). Callbacks may accept a secondHookContextargument (one-argument callbacks remain source-compatible). Audit side-effect idempotency — transactionbefore*hooks may re-run under contention;attemptis diagnostic only. - Branch on
WriteOutcomeError.outcomefor hook / partial-batch /{ returnDoc: true }read-back failures (original error iscause). Ordinary validation/conflict/etc. remain top-level. - Move any
toFirestorecreate-time logic intobeforeCreate/beforeUpdate(etc.) - Capture
create/bulkCreateresults as{ id }(or pass{ returnDoc: true }); renametotalCount()→collectionCount(); renamegetForUpdateInTransaction()→getInTransaction(); handleaverage()returningnull - If you inspected raw Firestore
.codeafterparseFirestoreError(or any repository catch), switch gRPC6/9branches toConflictError/PreconditionFailedError - Replace
FieldValue.delete()oncreate/upsertwithupdate()/patch() - Migrate
withVectorSearch(repo).query().findNearest(…)to.vectorQuery().findNearest(…) - Replace untyped
subcollection(parent, name)with a schema ornew FirestoreRepository(db, path) - Prefer
repo.validate/safeValidateoverschemas.read.parse(...)at trust boundaries - Run
tsc/ your typecheck —withSchema<User>(…)should fail intentionally
Further reading
Section titled “Further reading”- Core Concepts —
readConverter, repository construction - Schema Validation —
writeSchema,validate/safeValidate - Lifecycle Hooks — write-time transforms
- Subcollections
- Design records (in-repo): ADR-0007 (factories), ADR-0008 (read-only converters), ADR-0009 (explicit validators)