Firebase Firestore skill for Codex

Predictable reads, tested rules, versioned indexes. Codex models Firestore around each screen's queries and read count, splits documents by access rule, and keeps composite indexes in firestore.indexes.json instead of console clicks. Security rules validate shape and changed keys, deny by default and get emulator tests for every allowed and denied case. Listeners, counters and sequential fields are checked for bill spikes and write hotspots.

  • Databases
  • $firebase-firestore
  • 193 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- firebase-firestore

Installs to ~/.agents/skills/firebase-firestore/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $firebase-firestore, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Models Cloud Firestore data from screens and queries - documents, subcollections, collection group queries, denormalized fields and counters - writes and unit-tests security rules with the Emulator Suite and @firebase/rules-unit-testing, manages composite indexes in firestore.indexes.json, and controls cost by counting document reads from listeners, pagination and aggregation queries. Covers the modular Web SDK, Admin SDK batched writes and transactions, and hotspot limits. Use when designing Firestore collections, writing firestore.rules, a query fails for a missing index, the Firestore bill or read count jumps, or writes are throttled.

Use it when

  • Designing Firestore collections and subcollections
  • Writing or reviewing firestore.rules
  • A query needs a composite index or fails with an index error
  • Firestore costs or write contention increase

Use something else when

How to check the result

Emulator rules tests pass for allowed and denied cases on every collection, each screen's reads match the query table, and all code queries have indexes in firestore.indexes.json.

The mistake it prevents

Treating security rules as filters, so a query without the matching where clause is rejected even though each returned document would be allowed.

~/.agents/skills/firebase-firestore/SKILL.md

--- name: firebase-firestore description: Models Cloud Firestore data from screens and queries - documents, subcollections, collection group queries, denormalized fields and counters - writes… license: MIT ---

Done well means: each screen loads with a small, predictable number of document reads; security rules deny by default and are covered by emulator tests for every allowed and denied case; composite indexes live in version control; and no single document or index range takes more writes than Firestore can sustain.

Inspect first

  1. Read AGENTS.md; open firebase.json, .firebaserc, firestore.rules and firestore.indexes.json.
  2. Identify SDKs: Web modular SDK (firebase/firestore), Admin SDK (firebase-admin), mobile SDKs. The Admin SDK bypasses security rules entirely.
  3. Find listeners (onSnapshot) and queries per screen; they drive cost.
  4. Confirm the Emulator Suite runs locally (firebase emulators:start --only firestore); it needs Java and the Firebase CLI.

Model from screens

Write the screen/query table first:

ScreenQueryDocs read per loadLive?
Project listorgs/{orgId}/projects where archived == false orderBy updatedAt desc limit 2020yes
Project pageorgs/{orgId}/projects/{id} + .../tasks limit 5051yes
My tasks across projectscollection group tasks where assigneeId == uid<= 50no

Rules:

  • A document is the unit of read billing, security and contention. Group fields read together into one document; split fields with different access rules into different documents (for example users/{uid} public profile vs users/{uid}/private/settings).
  • Subcollections for unbounded children (tasks of a project). Arrays only for small, bounded lists (tags, up to a few dozen).
  • Documents max 1 MiB; keep them small since every read downloads the whole document.
  • Denormalize what list screens display (projectName on a task) and decide how copies are updated (Cloud Function trigger or batched write at edit time).
  • Collection group queries (collectionGroup(db, "tasks")) query every tasks subcollection; they need a collection-group index and rules written for the {path=**}/tasks/{taskId} form.
  • Store ids of related documents, not references to fetch one by one; batch with in queries (up to 30 values) or restructure.

Queries and indexes

import { collection, query, where, orderBy, limit, startAfter, getDocs, getCountFromServer } from "firebase/firestore";

const q = query(
  collection(db, "orgs", orgId, "projects"),
  where("archived", "==", false),
  orderBy("updatedAt", "desc"),
  limit(20),
);
const page = await getDocs(q);
const next = query(q, startAfter(page.docs[page.docs.length - 1]));
const total = await getCountFromServer(q);   // aggregation: billed per batch of index entries, not per doc
  • Single-field indexes are automatic. Equality plus orderBy on another field, or range filters with sorting, need a composite index. The error message contains a console link; instead of clicking it, add the index to firestore.indexes.json and deploy, so it is versioned:
{
  "indexes": [
    { "collectionGroup": "projects", "queryScope": "COLLECTION",
      "fields": [ { "fieldPath": "archived", "order": "ASCENDING" },
                  { "fieldPath": "updatedAt", "order": "DESCENDING" } ] },
    { "collectionGroup": "tasks", "queryScope": "COLLECTION_GROUP",
      "fields": [ { "fieldPath": "assigneeId", "order": "ASCENDING" },
                  { "fieldPath": "dueAt", "order": "ASCENDING" } ] }
  ],
  "fieldOverrides": [
    { "collectionGroup": "events", "fieldPath": "payload",
      "indexes": [] }
  ]
}
  • firebase firestore:indexes prints the deployed index config, to reconcile with the file.
  • Exempt large maps, long strings and fields never queried (fieldOverrides with empty indexes) to cut storage and write cost.
  • Paginate with cursors (startAfter), never offset: skipped documents are still billed.
  • in, not-in and array-contains-any accept up to 30 values; or queries are limited by the number of disjunctions they expand into.

Writes, transactions, hotspots

// Admin SDK: atomic multi-document write
const batch = adminDb.batch();
batch.update(projectRef, { taskCount: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp() });
batch.set(taskRef, task);
await batch.commit();
  • Use FieldValue.increment for counters instead of read-then-write.
  • Transactions read before they write and retry on contention; keep them small. Client SDK transactions fail offline.
  • A single document sustains roughly one write per second. High-rate counters need a distributed counter (N shard documents summed on read) or aggregation queries.
  • Sequential values in an indexed field (timestamps, incrementing ids) concentrate writes in one index range. For high write rates, exempt the field from indexing or add a random prefix/shard field. Ramp new collections gradually (start near 500 ops/s and grow about 50% every 5 minutes).
  • Bulk deletes or backfills from scripts use BulkWriter in the Admin SDK; run against production only with explicit user confirmation, after testing on the emulator.

Security rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() { return request.auth != null; }
    function isMember(orgId) {
      return signedIn() && exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
    }

    match /orgs/{orgId}/projects/{projectId} {
      allow read: if isMember(orgId);
      allow create: if isMember(orgId)
        && request.resource.data.keys().hasOnly(['name', 'archived', 'ownerId', 'updatedAt'])
        && request.resource.data.ownerId == request.auth.uid
        && request.resource.data.name is string && request.resource.data.name.size() <= 120;
      allow update: if isMember(orgId)
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['name', 'archived', 'updatedAt']);
      allow delete: if false;
    }
  }
}
  • Rules are not filters: a query is allowed only if the rules can prove every possible result is readable. Queries must include the constraints the rules check (for example where("ownerId", "==", uid)).
  • Validate shape on create and restrict changed keys on update; otherwise clients can write any field, including roles.
  • get()/exists() calls count as billed reads and are capped per request (10 for single document requests, 20 for multi-document reads, transactions and batches).
  • Store roles in documents the client cannot write, or in custom claims set by the Admin SDK.
  • Deny by default: no allow read, write: if true, and no test-mode rules with expiry dates left in production.

For a catalog of rule patterns (role checks, owner-only fields, immutable fields, rate-limiting hints, collection group rules) and a full emulator test harness, read references/security-rules-patterns.md when writing or reviewing rules.

Cost control

  • Every document returned is a read, including each document delivered to a listener when it changes and the initial snapshot. Listeners on large collections are the usual bill spike.
  • Limit listeners to what is on screen; detach them on unmount.
  • Offline persistence serves repeat loads from cache; resumed listeners within 30 minutes are billed only for changed documents.
  • Use aggregation queries (count, sum, average) instead of downloading documents to count.
  • Check usage in the console (Usage tab, Key Visualizer for hotspots) and set a budget alert.

Deploy

firebase emulators:exec --only firestore "npm test"     # rules + integration tests
firebase deploy --only firestore:rules,firestore:indexes  # confirm with the user first

Deploying rules takes effect within about a minute for new queries and can lock out clients; deploy only after emulator tests pass, and with user confirmation. Deploying needs network and Firebase credentials.

Verification

  • Emulator rules tests cover allowed and denied cases for each collection (signed out, other user, member, owner, bad shape, forbidden field change).
  • Each screen’s read count matches the query table (Emulator UI request log, or console usage).
  • No query in the code requires an index missing from firestore.indexes.json.

Output

**Collections:** <path: fields, who writes, who reads>
**Queries:** <screen -> query -> index -> reads per load>
**Rules:** <summary per collection>, tests: <n allowed / n denied cases>
**Indexes:** firestore.indexes.json changes, exemptions
**Cost/hotspot notes:** <listeners, counters, sharding>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Firebase Firestore"
  short_description: "Predictable reads, tested rules, versioned indexes"
  default_prompt: "$firebase-firestore Design Firestore collections for orgs, projects and tasks with per-org access"
references/security-rules-patterns.md119 lines, 5.3 KB
# Firestore security rules patterns and tests

Use these when writing or reviewing `firestore.rules`. Every pattern needs a matching
allowed and denied test in the emulator.

## Helper functions

```text
function signedIn() { return request.auth != null; }
function uid() { return request.auth.uid; }
function isOwner(data) { return signedIn() && data.ownerId == uid(); }
function hasRole(orgId, roles) {
  return signedIn()
    && get(/databases/$(database)/documents/orgs/$(orgId)/members/$(uid())).data.role in roles;
}
function claimAdmin() { return signedIn() && request.auth.token.admin == true; }
function unchanged(field) {
  return request.resource.data.get(field, null) == resource.data.get(field, null);
}
```

`get()` on the same document path is cached within one request evaluation, but each
distinct path counts toward the per-request limit and is billed as a read.

## Pattern catalog

| Need | Rule fragment |
|---|---|
| Owner-only document | `allow read, write: if isOwner(resource.data);` plus create rule using `request.resource.data` |
| Create sets owner to caller | `allow create: if request.resource.data.ownerId == uid();` |
| Only listed fields on create | `request.resource.data.keys().hasOnly([...]) && request.resource.data.keys().hasAll([...required])` |
| Only some fields change on update | `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'body'])` |
| Immutable field | `unchanged('createdAt') && unchanged('ownerId')` |
| Server timestamp enforced | `request.resource.data.updatedAt == request.time` |
| Type and size checks | `data.title is string && data.title.size() >= 1 && data.title.size() <= 200` |
| Enum value | `request.resource.data.status in ['draft', 'published']` |
| Role from membership doc | `hasRole(orgId, ['admin', 'editor'])` |
| Role from custom claim | `request.auth.token.role == 'admin'` (set via Admin SDK `setCustomUserClaims`) |
| Email verified | `request.auth.token.email_verified == true` |
| Read only published, or own drafts | `resource.data.status == 'published' \|\| isOwner(resource.data)` (queries must filter the same way) |
| Collection group read | `match /{path=**}/tasks/{taskId} { allow read: if resource.data.assigneeId == uid(); }` |
| Limit list size per query | `allow list: if request.query.limit <= 50;` |
| Separate get vs list | `allow get: if ...; allow list: if ...;` |
| Deny everything else | Omit matches; unmatched paths are denied |

Notes:

- There is no range operator; write explicit `>=`/`<=` comparisons.
- `resource` is the document before the write (null on create); `request.resource` is the
  document as it would be after the write, including unchanged fields.
- Rules cannot rate-limit reliably. Use App Check to restrict clients to your apps, and
  Cloud Functions or a backend for abuse-sensitive operations.
- Role documents (`members/{uid}`) must themselves be writable only by admins or the Admin SDK.

## Test harness (Node, Vitest or Jest)

```ts
import { initializeTestEnvironment, assertFails, assertSucceeds, RulesTestEnvironment } from "@firebase/rules-unit-testing";
import { readFileSync } from "node:fs";
import { doc, getDoc, setDoc, updateDoc, collection, query, where, getDocs } from "firebase/firestore";

let env: RulesTestEnvironment;

beforeAll(async () => {
  env = await initializeTestEnvironment({
    projectId: "demo-rules-test",             // demo- prefix: never touches a real project
    firestore: { rules: readFileSync("firestore.rules", "utf8") },
  });
});
afterAll(() => env.cleanup());
beforeEach(async () => {
  await env.clearFirestore();
  await env.withSecurityRulesDisabled(async (ctx) => {
    const db = ctx.firestore();
    await setDoc(doc(db, "orgs/o1/members/alice"), { role: "editor" });
    await setDoc(doc(db, "orgs/o1/projects/p1"), { name: "A", ownerId: "alice", archived: false });
  });
});

test("member reads project", async () => {
  const db = env.authenticatedContext("alice").firestore();
  await assertSucceeds(getDoc(doc(db, "orgs/o1/projects/p1")));
});

test("non-member cannot read", async () => {
  const db = env.authenticatedContext("mallory").firestore();
  await assertFails(getDoc(doc(db, "orgs/o1/projects/p1")));
});

test("signed-out cannot read", async () => {
  const db = env.unauthenticatedContext().firestore();
  await assertFails(getDoc(doc(db, "orgs/o1/projects/p1")));
});

test("member cannot change owner", async () => {
  const db = env.authenticatedContext("alice").firestore();
  await assertFails(updateDoc(doc(db, "orgs/o1/projects/p1"), { ownerId: "mallory" }));
});

test("non-member list query is rejected", async () => {
  const db = env.authenticatedContext("bob").firestore();
  await assertFails(getDocs(query(collection(db, "orgs/o1/projects"), where("archived", "==", false))));
});
```

Run under the emulator:

```bash
firebase emulators:exec --only firestore "npx vitest run tests/rules"
```

## Review checklist

- [ ] No `if true` on any path holding user data; no expired "test mode" rules.
- [ ] Every `create` validates keys, types and ownership; every `update` restricts changed keys.
- [ ] Role and membership documents are not client-writable.
- [ ] Collection group queries have matching `{path=**}` rules.
- [ ] `get()`/`exists()` count per request stays under the limit for batch writes.
- [ ] Tests cover signed-out, other user, member, owner, malformed data and forbidden field changes.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem