Skip to content

CRUD Operations

Create, read, update, upsert, and delete documents — and run batched bulk writes — through the repository API. For create-only claims and optimistic-concurrency updates, see Conditional writes.

Write payloads never contain an id: the document id is the document name — auto-generated by Firestore on create/bulkCreate, or the id argument you pass to update, patch, upsert, and delete.

// CREATE — returns { id } by default; pass { returnDoc: true } for the created read model
const { id: newUserId } = await userRepo.create({
name: 'Alice',
email: 'alice@example.com',
});
// READ
const user = await userRepo.getById('user-123'); // FirestoreDocument<T> | null
const strictUser = await userRepo.getByIdOrThrow('user-123'); // Throws NotFoundError when missing
const users = await userRepo.getAll(); // Fetch all docs
const usersByEmail = await userRepo.findByField('email', 'alice@example.com'); // All matches
const oneUserByEmail = await userRepo.getOneByField('email', 'alice@example.com'); // First match or null
const strictUserByEmail = await userRepo.getOneByFieldOrThrow('email', 'alice@example.com'); // See below
// UPDATE (default return is { id: 'user-123' })
await userRepo.update('user-123', {
name: 'Alice Updated',
});
// UPDATE AND RETURN DOCUMENT
const updatedUser = await userRepo.update(
'user-123',
{ name: 'Alice Updated Again' },
{ returnDoc: true },
);
// UPDATE WITH MERGE (merges nested fields instead of replacing the object wholesale)
await userRepo.update('user-123', { 'profile.nickname': 'Ally' }, { merge: true });
// PATCH (always merges — no merge option; takes { returnDoc?, withMetadata?, lastUpdateTime? })
await userRepo.patch('user-123', { name: 'Alice Patched' });
// UPSERT (create if doesn't exist, update if exists)
await userRepo.upsert('user-123', {
name: 'Alice',
email: 'alice@example.com',
});
// UPSERT AND RETURN DOCUMENT
const upsertedUser = await userRepo.upsert(
'user-123',
{ name: 'Alice', email: 'alice@example.com' },
{ returnDoc: true },
);
// DELETE
await userRepo.delete('user-123'); // Hard delete; throws NotFoundError if the doc is missing
Method Returns
getById(id) FirestoreDocument<T> | null
getById(id, { withMetadata: true }) { doc, metadata } | null — general read-metadata shape
getByIdOrThrow(id) FirestoreDocument<T> — throws NotFoundError when the doc is missing
getMany(ids, options?) (FirestoreDocument<T> | null)[] — input order; null marks missing; optional fieldMask / { withMetadata: true }
getAll() All documents in the collection
findByField(field, value) Array of all matching documents
getOneByField(field, value) First match, or null when there are none
getOneByFieldOrThrow(field, val) Single match — throws NotFoundError on zero, ConflictError on two or more

Pass { withMetadata: true } on supported reads to receive { doc, metadata } instead of a flat document. The document under doc is unchanged from the default read (still JSON-serializable); provenance (ref, path, parentPath, createTime, updateTime, readTime) lives in the sibling metadata object. metadata.ref is a live DocumentReference and is not JSON-serializable — prefer metadata.path when you only need identity.

const row = await userRepo.getById('user-123', { withMetadata: true });
if (row) {
console.log(row.doc.name, row.metadata.updateTime.toDate());
}

Pass the flag inline — a hoisted options object widens and fails to match:

// ❌ const opts = { withMetadata: true }; — widens to { withMetadata: boolean }
const row = await userRepo.getById('user-123', { withMetadata: true }); // ✅

For optimistic-concurrency tokens only, getByIdWithUpdateTime remains the narrow accessor; see Conditional writes.

// Batched id lookup — prefer over whereId('in', …) for id lists
const [alice, missing, bob] = await userRepo.getMany(['alice', 'ghost', 'bob']);
// alice / bob are documents (or null); missing is null; order matches the input
// Field-mask projection (DeepPartial narrowing); id always survives
const projected = await userRepo.getMany(['alice', 'bob'], {
fieldMask: ['name', 'address.city'],
});
  • update(id, data, options?) accepts { merge?, returnDoc?, withMetadata?, lastUpdateTime? } (the exported UpdateOptions; returnDoc and withMetadata are mutually exclusive). It is always a partial update — unspecified top-level fields are left unchanged. By default a nested object in the payload replaces that field’s stored value wholesale; pass { merge: true } to deep-merge nested objects instead (they are flattened to dot-paths, so sibling nested fields are preserved).
  • patch(id, data, options?) accepts { returnDoc?, withMetadata?, lastUpdateTime? } — patch always merges, so there is no merge option to set. lastUpdateTime guards the write the same way as on update.

Both dot-notation and nested-object updates are supported; see Dot-notation nested updates for the merge semantics of paths like 'profile.nickname'.

Two related capabilities for race-safe writes: create-only by explicit id, and an optional lastUpdateTime precondition on update/delete. Neither redefines upsert() — that method is still create-or-overwrite.

createWithId(id, data) claims a caller-supplied id with Firestore DocumentReference.create() semantics: the write succeeds only if the document does not already exist. A collision raises ConflictError (HTTP 409). The check is part of the write, so two concurrent claims cannot both succeed — exactly one wins.

import { ConflictError } from 'flintfire';
try {
await userRepo.createWithId('external-id-123', {
name: 'Ada',
email: 'ada@example.com',
});
} catch (error) {
if (error instanceof ConflictError) {
// That id is already taken — the stored document is untouched.
}
}

bulkCreateWithIds([{ id, data }, …]) is the batched form (atomic at ≤ 500 ops). Prefer this over upsert when you need to claim an externally-derived id once; use upsert when re-running the write should overwrite. See ID strategies.

Read a version token with getByIdWithUpdateTime, then pass it back as lastUpdateTime on update / patch / delete (or their bulk and transaction variants). The write commits only if nobody else changed the document in between; otherwise the repository raises PreconditionFailedError (HTTP 412) and leaves the stored document untouched.

import { PreconditionFailedError } from 'flintfire';
for (let attempt = 0; attempt < 3; attempt++) {
const current = await userRepo.getByIdWithUpdateTime('user-123');
if (!current) break;
try {
await userRepo.update(
current.doc.id,
{ balance: current.doc.balance + 100 },
{ lastUpdateTime: current.updateTime },
);
break;
} catch (error) {
// Someone else wrote first — re-read and retry against the newer version.
if (!(error instanceof PreconditionFailedError)) throw error;
}
}

The result of getByIdWithUpdateTime is a pair { doc, updateTime } (not an overlay on the document), so a stored field named updateTime is never shadowed. For the general read-metadata shape (all provenance fields, not just updateTime), use getById(id, { withMetadata: true }). A plain update on a missing document still raises NotFoundError; a precondition-guarded one raises PreconditionFailedError instead (Firestore reports the absent document as stored version 0).

Bulk operations use Firestore batch writes and commit in batches of 500 operations. The ORM automatically chunks operations if you exceed this limit, so you can pass arrays of any size.

Above 500 operations the write is not globally atomic. Each 500-op chunk commits independently. If a later chunk fails, earlier chunks remain committed, the after-hook does not run, and the call rejects with WriteOutcomeError (state: 'partially-committed', phase: 'commit') carrying exact committedWrites and totalWrites. A failure with zero successful writes (e.g. a first-chunk collision) remains the ordinary top-level error (ConflictError, etc.).

try {
await userRepo.bulkCreateWithIds(entries); // entries.length > 500
} catch (error) {
if (error instanceof WriteOutcomeError && error.outcome.state === 'partially-committed') {
console.log(error.outcome.committedWrites, error.outcome.totalWrites);
}
}
// Bulk create — returns [{ id }, ...] by default; pass { returnDoc: true } for read models
const created = await userRepo.bulkCreate([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Charlie', email: 'charlie@example.com' },
]);
// Bulk create under caller-supplied ids (create-only; a collision rejects the whole batch)
await userRepo.bulkCreateWithIds([
{ id: 'user-1', data: { name: 'Alice', email: 'alice@example.com' } },
{ id: 'user-2', data: { name: 'Bob', email: 'bob@example.com' } },
]);
// Bulk update (returns [{ id: 'user-1' }, { id: 'user-2' }]); each entry may carry lastUpdateTime
await userRepo.bulkUpdate([
{ id: 'user-1', data: { status: 'active' } },
{ id: 'user-2', data: { status: 'inactive' }, lastUpdateTime: token },
]);
// Bulk patch (always merges each document)
await userRepo.bulkPatch([
{ id: 'user-1', data: { lastSeenAt: Date.now() } },
{ id: 'user-2', data: { lastSeenAt: Date.now() } },
]);
// Bulk delete — returns the count of documents that actually existed (not the input length).
// Also accepts `{ id, lastUpdateTime? }[]` for per-entry preconditions (do not mix forms).
const deletedCount = await userRepo.bulkDelete(['user-1', 'user-2', 'user-3']);

High-throughput bulkWrite (separate contract)

Section titled “High-throughput bulkWrite (separate contract)”

bulkWrite is not a faster bulkCreate/bulkUpdate/bulkDelete. It uses the Admin SDK’s BulkWriter and trades atomicity + hooks for parallelism and per-item results:

Fixed batch (bulk*) bulkWrite
Atomicity atomic at or below 500 ops never — each op succeeds or fails alone
Failure first failure throws; nothing after it is applied per-item result; siblings still land
Hooks run none (throws if any bulk hook is registered — see skipHooks)
Retries none SDK default: transient statuses, up to 10 attempts per op
Throughput 500-op sequential commits parallel, rate-limit ramped
Duplicate ids rejected rejected (same-document commit order is undefined)

Operations are discriminated on op, and there are five verbs. Only create may omit id (one is generated); only the update/delete verbs accept a lastUpdateTime precondition:

op Shape Semantics
create { op, id?, data } Create-only; collision → ConflictError
set { op, id, data } Create or overwrite (the upsert verb)
update { op, id, data, lastUpdateTime? } Partial update
patch { op, id, data, lastUpdateTime? } Merge-style update
delete { op, id, lastUpdateTime? } Delete
const results = await userRepo.bulkWrite([
{ op: 'create', data: { name: 'Ada', email: 'ada@example.com' } },
{ op: 'set', id: 'user-0', data: { name: 'Grace', email: 'grace@example.com' } },
{ op: 'update', id: 'user-1', data: { status: 'active' } },
{ op: 'patch', id: 'user-3', data: { profile: { verified: true } } },
{ op: 'delete', id: 'user-2', lastUpdateTime: token },
]);
const failed = results.filter(result => !result.ok);
console.log(`${results.length - failed.length} written, ${failed.length} rejected`);
for (const failure of failed) console.error(failure.index, failure.error.message);

Pass { skipHooks: true } when the repository has bulk hooks registered and you deliberately want them not to fire. For a document subtree, use recursiveDelete(id) — separate from delete(id), which orphans subcollections. For the entire repository collection (every document plus all nested descendants), use recursiveDeleteCollection() — highly destructive, deliberately a distinct method name so an omitted document id cannot select a collection wipe. When the repository points at a subcollection, only that concrete subcollection is removed; its parent document and sibling collections survive.

Performance Tip: For simple bulk updates on query results, use query().update() instead:

// More efficient - single query + batched writes
await orderRepo.query().where('status', '==', 'pending').update({ status: 'shipped' });
// Less efficient - fetches all IDs first, then updates
const orders = await orderRepo.query().where('status', '==', 'pending').get();
await orderRepo.bulkUpdate(orders.map(o => ({ id: o.id, data: { status: 'shipped' } })));

Note that query().update() and query().delete() run the bulk lifecycle hooks (beforeBulkUpdate/afterBulkUpdate and beforeBulkDelete/afterBulkDelete respectively), not the per-document before/afterUpdate / before/afterDelete hooks. Use the single-document methods if you need per-document hooks. bulkWrite, recursiveDelete, and recursiveDeleteCollection run no hooks — bulkWrite throws when bulk hooks are registered unless you pass { skipHooks: true }. See lifecycle hooks.