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.
Single-document operations
Section titled “Single-document operations”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 modelconst { id: newUserId } = await userRepo.create({ name: 'Alice', email: 'alice@example.com',});
// READconst user = await userRepo.getById('user-123'); // FirestoreDocument<T> | nullconst strictUser = await userRepo.getByIdOrThrow('user-123'); // Throws NotFoundError when missingconst users = await userRepo.getAll(); // Fetch all docsconst usersByEmail = await userRepo.findByField('email', 'alice@example.com'); // All matchesconst oneUserByEmail = await userRepo.getOneByField('email', 'alice@example.com'); // First match or nullconst 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 DOCUMENTconst 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 DOCUMENTconst upsertedUser = await userRepo.upsert( 'user-123', { name: 'Alice', email: 'alice@example.com' }, { returnDoc: true },);
// DELETEawait userRepo.delete('user-123'); // Hard delete; throws NotFoundError if the doc is missingRead methods
Section titled “Read methods”| 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 |
Reading snapshot metadata
Section titled “Reading snapshot metadata”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 listsconst [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 survivesconst projected = await userRepo.getMany(['alice', 'bob'], { fieldMask: ['name', 'address.city'],});Update vs. patch
Section titled “Update vs. patch”update(id, data, options?)accepts{ merge?, returnDoc?, withMetadata?, lastUpdateTime? }(the exportedUpdateOptions;returnDocandwithMetadataare 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? }—patchalways merges, so there is nomergeoption to set.lastUpdateTimeguards the write the same way as onupdate.
Both dot-notation and nested-object updates are supported; see
Dot-notation nested updates for the merge
semantics of paths like 'profile.nickname'.
Conditional writes
Section titled “Conditional writes”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.
Create-only (createWithId)
Section titled “Create-only (createWithId)”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.
Optimistic concurrency (lastUpdateTime)
Section titled “Optimistic concurrency (lastUpdateTime)”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
Section titled “Bulk Operations”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 modelsconst 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 lastUpdateTimeawait 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 writesawait orderRepo.query().where('status', '==', 'pending').update({ status: 'shipped' });
// Less efficient - fetches all IDs first, then updatesconst 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.