Model API

The raw Firestore SDK returns DocumentSnapshot objects whose data() is untyped, and it accepts any object on a write without checking it against anything. Every model that extends BaseModel gets the static methods below instead: reads come back as instances of your class with id populated, and writes are validated against the field metadata your decorators declared. All of it requires initializeFiremap(db) to have run at startup — call it before the first query or you get a NOT_INITIALIZED error.

findById(id)

typescript
const user = await User.findById('abc123');
// Returns: Promise<User | null>

A missing document resolves to null rather than throwing, which matches Firestore's own behaviour: a read of a non-existent path is a successful read of an empty snapshot, and it is still billed as a document read. The nullable return type is the point — TypeScript forces you to handle the miss at the call site instead of discovering it as an undefined property access later.

find(query)

typescript
const users = await User.find({
  where: { status: 'active', role: 'admin' },
  orderBy: 'createdAt',
  limit: 20,
  startAfter: lastDoc,
});
// Returns: Promise<User[]>

Passing where as a plain object is the shorthand for equality filters: each key becomes an == clause, and the keys are constrained to fields that exist on the model. When you need a range or membership test, pass an array of explicit clauses instead — the supported operators are <, <=, ==, !=, >=, >, array-contains and in. orderBy takes either a field name or a { field, direction } object when you want descending order.

Two Firestore rules bite here regardless of which client you use. Any query that filters and orders across more than one field needs a composite index — the query above, filtering on two fields and ordering on a third, needs one. And ordering by a field silently excludes documents that do not have that field at all, which is the usual explanation for a document that exists in the console but never appears in results.

create(data)

typescript
const user = await User.create({
  name: 'Jane',
  email: '[email protected]',
});
// Validates required fields and types
// Throws FiremapValidationError on failure

Before anything reaches Firestore, defaults declared on @Field are filled in for keys you omitted, then every required field is checked for presence and every provided field is checked against its declared type. Failures throw a FiremapValidationError carrying the collection, the field, and the expected versus received type, which is considerably more actionable than the shape-agnostic write Firestore would otherwise have accepted. An explicit document ID can be passed as a second argument; without one, Firestore generates the ID.

update(id, data)

typescript
await User.update('abc123', {
  name: 'Jane Doe',
});
// Validates only changed fields
// Uses Firestore merge

Updates are partial, so the required-field check is skipped and only the keys you supply are type-checked. Note that a Firestore update fails if the document does not exist — it is not an upsert. Nested map fields are replaced wholesale unless you address them by dotted path, a common source of accidentally erased sibling keys.

delete(id)

typescript
await User.delete('abc123');
// Hard delete (or soft delete with @SoftDelete)

With @SoftDelete on the collection this writes a deletedAt timestamp instead of removing the document. The document therefore still exists, still counts toward storage, and still comes back from find — filter on deletedAt in your queries. Separately, deleting a document in Firestore does not delete its subcollections; those documents are orphaned and have to be cleaned up deliberately.

stream(query)

typescript
const unsubscribe = User.stream(
  { where: { status: 'active' } },
  (users) => {
    console.log('Updated:', users);
  }
);
// Returns unsubscribe function

This wraps Firestore's realtime listener, so the callback fires once with the current result set and again on every change matching the query. The options argument is optional — passing just a callback streams the whole collection. Always call the returned unsubscribe function when the component or request that opened the listener goes away: an orphaned listener keeps an open connection and keeps billing you a document read for every changed document it delivers.