@intentsolutionsio/jeremy-firestore 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ # Firestore Operations Manager: Examples
2
+
3
+ ## Example 1: Fix a Missing Composite Index
4
+
5
+ Query fails with `FAILED_PRECONDITION: The query requires an index`.
6
+
7
+ ```typescript
8
+ // This query needs a composite index on (status ASC, createdAt DESC)
9
+ const snapshot = await db.collection("orders")
10
+ .where("status", "==", "pending")
11
+ .orderBy("createdAt", "desc")
12
+ .limit(50).get();
13
+ ```
14
+
15
+ The error message includes a Firebase Console URL to auto-create the index. Or add manually to `firestore.indexes.json`:
16
+
17
+ ```json
18
+ {
19
+ "indexes": [{
20
+ "collectionGroup": "orders", "queryScope": "COLLECTION",
21
+ "fields": [
22
+ { "fieldPath": "status", "order": "ASCENDING" },
23
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
24
+ ]
25
+ }],
26
+ "fieldOverrides": []
27
+ }
28
+ ```
29
+
30
+ Deploy: `firebase deploy --only firestore:indexes` then check status with `firebase firestore:indexes` (wait for READY).
31
+
32
+ **Prevention**: Any `where()` + `orderBy()` on different fields requires a composite index. Single-field queries do not.
33
+
34
+ ---
35
+
36
+ ## Example 2: Batch Migrate 100k Documents with Checkpoints
37
+
38
+ Add a `status` field (default: `"active"`) to all documents in the `users` collection.
39
+
40
+ ```typescript
41
+ import * as admin from "firebase-admin";
42
+ admin.initializeApp();
43
+ const db = admin.firestore();
44
+
45
+ const MIGRATION_ID = "add-status-field-2026-03";
46
+ const BATCH_SIZE = 500;
47
+
48
+ interface Checkpoint { lastDocId: string; processed: number; skipped: number; status: string; }
49
+
50
+ async function getCheckpoint(): Promise<Checkpoint | null> {
51
+ const doc = await db.collection("_migrations").doc(MIGRATION_ID).get();
52
+ return doc.exists ? (doc.data() as Checkpoint) : null;
53
+ }
54
+
55
+ async function saveCheckpoint(cp: Checkpoint): Promise<void> {
56
+ await db.collection("_migrations").doc(MIGRATION_ID).set({
57
+ ...cp, updatedAt: admin.firestore.FieldValue.serverTimestamp(),
58
+ });
59
+ }
60
+
61
+ async function migrate() {
62
+ let checkpoint = await getCheckpoint();
63
+ let processed = checkpoint?.processed || 0;
64
+ let skipped = checkpoint?.skipped || 0;
65
+
66
+ while (true) {
67
+ let query = db.collection("users").orderBy("__name__").limit(BATCH_SIZE);
68
+ if (checkpoint?.lastDocId) {
69
+ const lastDoc = await db.collection("users").doc(checkpoint.lastDocId).get();
70
+ if (lastDoc.exists) query = query.startAfter(lastDoc);
71
+ }
72
+
73
+ const snapshot = await query.get();
74
+ if (snapshot.empty) break;
75
+
76
+ const batch = db.batch();
77
+ let batchCount = 0;
78
+ for (const doc of snapshot.docs) {
79
+ if (doc.data().status !== undefined) { skipped++; continue; } // Idempotent
80
+ batch.update(doc.ref, { status: "active" });
81
+ batchCount++;
82
+ }
83
+ if (batchCount > 0) await batch.commit();
84
+ processed += batchCount;
85
+
86
+ const lastDoc = snapshot.docs[snapshot.docs.length - 1];
87
+ checkpoint = { lastDocId: lastDoc.id, processed, skipped, status: "in_progress" };
88
+ await saveCheckpoint(checkpoint);
89
+ console.log(`Processed: ${processed}, Skipped: ${skipped}`);
90
+ }
91
+
92
+ await saveCheckpoint({ lastDocId: checkpoint?.lastDocId || "", processed, skipped, status: "completed" });
93
+ console.log(`Done. Processed: ${processed}, Skipped: ${skipped}`);
94
+ }
95
+ migrate().catch(console.error);
96
+ ```
97
+
98
+ Run against emulator first: `FIRESTORE_EMULATOR_HOST=localhost:8080 npx ts-node migrate.ts`
99
+
100
+ Cost estimate: 100k reads ($0.06) + 100k writes ($0.18) = ~$0.24.
101
+
102
+ ---
103
+
104
+ ## Example 3: Security Rules for Multi-Tenant App
105
+
106
+ Data model: `tenants/{tenantId}`, `tenants/{tenantId}/members/{userId}`, `tenants/{tenantId}/projects/{projId}`.
107
+
108
+ ```javascript
109
+ rules_version = '2';
110
+ service cloud.firestore {
111
+ match /databases/{database}/documents {
112
+ function isAuth() { return request.auth != null; }
113
+ function isMember(tenantId) {
114
+ return isAuth() && exists(/databases/$(database)/documents/tenants/$(tenantId)/members/$(request.auth.uid));
115
+ }
116
+ function isTenantAdmin(tenantId) {
117
+ return isMember(tenantId) &&
118
+ get(/databases/$(database)/documents/tenants/$(tenantId)/members/$(request.auth.uid)).data.role == 'admin';
119
+ }
120
+
121
+ match /tenants/{tenantId} {
122
+ allow read: if isMember(tenantId);
123
+ allow update: if isTenantAdmin(tenantId);
124
+ allow create, delete: if false; // Admin SDK only
125
+ }
126
+ match /tenants/{tenantId}/members/{userId} {
127
+ allow read: if isMember(tenantId);
128
+ allow write: if isTenantAdmin(tenantId);
129
+ }
130
+ match /tenants/{tenantId}/projects/{projId} {
131
+ allow read: if isMember(tenantId);
132
+ allow create: if isMember(tenantId) && request.resource.data.createdBy == request.auth.uid;
133
+ allow update: if isMember(tenantId) && resource.data.createdBy == request.auth.uid;
134
+ allow delete: if isTenantAdmin(tenantId);
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ Emulator test:
141
+
142
+ ```typescript
143
+ const testEnv = await initializeTestEnvironment({
144
+ projectId: "demo-test", firestore: { rules: readFileSync("firestore.rules", "utf8") },
145
+ });
146
+ await testEnv.withSecurityRulesDisabled(async (ctx) => {
147
+ await ctx.firestore().doc("tenants/acme").set({ name: "Acme" });
148
+ await ctx.firestore().doc("tenants/acme/members/alice").set({ role: "admin" });
149
+ await ctx.firestore().doc("tenants/acme/members/bob").set({ role: "member" });
150
+ });
151
+
152
+ const alice = testEnv.authenticatedContext("alice");
153
+ await assertSucceeds(alice.firestore().doc("tenants/acme").update({ name: "Acme Inc" }));
154
+ const bob = testEnv.authenticatedContext("bob");
155
+ await assertFails(bob.firestore().doc("tenants/acme").update({ name: "Bob Corp" }));
156
+ const eve = testEnv.authenticatedContext("eve");
157
+ await assertFails(eve.firestore().doc("tenants/acme").get()); // Not a member
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Example 4: Cursor-Based Pagination
163
+
164
+ Fetch products by category, 20 per page, with stable ordering.
165
+
166
+ ```typescript
167
+ interface PaginationCursor { lastPrice: number; lastDocId: string; }
168
+
169
+ async function getProductPage(category: string, pageSize = 20, cursor?: PaginationCursor) {
170
+ let query = db.collection("products")
171
+ .where("category", "==", category)
172
+ .orderBy("price", "asc")
173
+ .orderBy("__name__", "asc") // Tiebreaker for stable pagination
174
+ .limit(pageSize + 1); // One extra to detect next page
175
+
176
+ if (cursor) query = query.startAfter(cursor.lastPrice, cursor.lastDocId);
177
+
178
+ const snapshot = await query.get();
179
+ const hasNextPage = snapshot.docs.length > pageSize;
180
+ const docs = snapshot.docs.slice(0, pageSize);
181
+
182
+ return {
183
+ items: docs.map((doc) => ({ id: doc.id, ...doc.data() })),
184
+ nextCursor: hasNextPage
185
+ ? { lastPrice: docs[docs.length - 1].data().price, lastDocId: docs[docs.length - 1].id }
186
+ : null,
187
+ hasNextPage,
188
+ };
189
+ }
190
+
191
+ // Usage
192
+ const page1 = await getProductPage("electronics");
193
+ if (page1.nextCursor) {
194
+ const page2 = await getProductPage("electronics", 20, page1.nextCursor);
195
+ }
196
+ ```
197
+
198
+ Required composite index in `firestore.indexes.json`:
199
+
200
+ ```json
201
+ { "collectionGroup": "products", "queryScope": "COLLECTION",
202
+ "fields": [
203
+ { "fieldPath": "category", "order": "ASCENDING" },
204
+ { "fieldPath": "price", "order": "ASCENDING" }
205
+ ]}
206
+ ```
207
+
208
+ `__name__` is automatically included as a tiebreaker -- no explicit index entry needed.
209
+
210
+ ---
211
+ *[Tons of Skills](https://tonsofskills.com) by [Intent Solutions](https://intentsolutions.io) | [jeremylongshore.com](https://jeremylongshore.com)*
@@ -0,0 +1,214 @@
1
+ # Firestore Operations Manager: Implementation Guide
2
+
3
+ ## Firestore Data Model Patterns
4
+
5
+ ### Subcollections vs Root Collections
6
+
7
+ | Pattern | When to Use | Trade-off |
8
+ |---------|-------------|-----------|
9
+ | Root collection (`/orders`) | Query across all documents regardless of parent | Must store parent IDs as fields; no automatic scoping |
10
+ | Subcollection (`/users/{uid}/orders`) | Data naturally owned by a parent; security rules scope by parent | Cannot query across all users' orders without collection group query |
11
+ | Collection group query | Cross-parent queries on subcollections | Must create collection group index; same rules apply to all subcollections with that name |
12
+
13
+ ### Denormalization
14
+
15
+ Firestore has no joins. Duplicate data where read patterns require it:
16
+
17
+ ```typescript
18
+ // Store user name directly on order (avoids extra read)
19
+ await db.collection("orders").add({
20
+ userId: "alice123", userName: "Alice Smith",
21
+ items: [...], total: 49.99,
22
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
23
+ });
24
+
25
+ // When name changes, batch-update all denormalized copies
26
+ async function updateUserName(userId: string, newName: string) {
27
+ const orders = await db.collection("orders").where("userId", "==", userId).get();
28
+ const BATCH_SIZE = 500;
29
+ for (let i = 0; i < orders.docs.length; i += BATCH_SIZE) {
30
+ const batch = db.batch();
31
+ orders.docs.slice(i, i + BATCH_SIZE).forEach((doc) => batch.update(doc.ref, { userName: newName }));
32
+ await batch.commit();
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Batch Write Mechanics
38
+
39
+ ### The 500-Operation Limit
40
+
41
+ Each `WriteBatch.commit()` accepts max 500 operations (`set`, `update`, `delete`). Exceeding throws `INVALID_ARGUMENT`.
42
+
43
+ ### Reusable Chunked Batch Writer
44
+
45
+ ```typescript
46
+ async function batchWrite<T>(
47
+ items: T[],
48
+ writeFn: (batch: admin.firestore.WriteBatch, item: T) => void,
49
+ options: { batchSize?: number; onProgress?: (done: number, total: number) => void } = {}
50
+ ) {
51
+ const { batchSize = 500, onProgress } = options;
52
+ let processed = 0;
53
+ for (let i = 0; i < items.length; i += batchSize) {
54
+ const batch = db.batch();
55
+ items.slice(i, i + batchSize).forEach((item) => writeFn(batch, item));
56
+ await batch.commit();
57
+ processed += Math.min(batchSize, items.length - i);
58
+ onProgress?.(processed, items.length);
59
+ }
60
+ return { processed };
61
+ }
62
+ ```
63
+
64
+ ### Retry Logic
65
+
66
+ ```typescript
67
+ async function commitWithRetry(batch: admin.firestore.WriteBatch, maxRetries = 3): Promise<void> {
68
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
69
+ try { await batch.commit(); return; }
70
+ catch (err: any) {
71
+ if (attempt === maxRetries) throw err;
72
+ if (err.code === 10 /* ABORTED */ || err.code === 14 /* UNAVAILABLE */) {
73
+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000 + Math.random() * 500));
74
+ continue;
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Composite Index Design
83
+
84
+ ### When Indexes Are Required
85
+
86
+ | Query Pattern | Index Needed? |
87
+ |---------------|---------------|
88
+ | Single `where('field', '==', value)` | No (auto-indexed) |
89
+ | Single `where('field', '>', value)` | No (auto-indexed) |
90
+ | `where('a', '==', v1).where('b', '==', v2)` | Yes (composite) |
91
+ | `where('a', '==', v).orderBy('b')` | Yes (unless a == b) |
92
+ | `where('a', '>', v).orderBy('a')` | No (same field) |
93
+ | `where('a', '>', v).orderBy('b')` | Yes (a must be first orderBy) |
94
+ | `where('a', 'array-contains', v).orderBy('b')` | Yes (composite) |
95
+
96
+ ### Index File Format
97
+
98
+ ```json
99
+ { "indexes": [
100
+ { "collectionGroup": "products", "queryScope": "COLLECTION",
101
+ "fields": [{ "fieldPath": "category", "order": "ASCENDING" }, { "fieldPath": "price", "order": "ASCENDING" }] },
102
+ { "collectionGroup": "orders", "queryScope": "COLLECTION",
103
+ "fields": [{ "fieldPath": "userId", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" }] }
104
+ ], "fieldOverrides": [] }
105
+ ```
106
+
107
+ Deploy: `firebase deploy --only firestore:indexes`. Large collections (>1M docs) can take hours. Deploy indexes before query code.
108
+
109
+ ## Security Rules Testing Workflow
110
+
111
+ ```bash
112
+ npm install -D @firebase/rules-unit-testing firebase-admin
113
+ ```
114
+
115
+ ```typescript
116
+ import { initializeTestEnvironment, assertSucceeds, assertFails, RulesTestEnvironment } from "@firebase/rules-unit-testing";
117
+ import { readFileSync } from "fs";
118
+
119
+ let testEnv: RulesTestEnvironment;
120
+ beforeAll(async () => {
121
+ testEnv = await initializeTestEnvironment({
122
+ projectId: "demo-test", firestore: { rules: readFileSync("firestore.rules", "utf8") },
123
+ });
124
+ });
125
+ afterAll(() => testEnv.cleanup());
126
+ afterEach(() => testEnv.clearFirestore());
127
+
128
+ test("owner can read own profile", async () => {
129
+ await testEnv.withSecurityRulesDisabled(async (ctx) => {
130
+ await ctx.firestore().doc("users/alice").set({ name: "Alice", role: "user" });
131
+ });
132
+ await assertSucceeds(testEnv.authenticatedContext("alice").firestore().doc("users/alice").get());
133
+ });
134
+
135
+ test("unauthenticated cannot read profiles", async () => {
136
+ await assertFails(testEnv.unauthenticatedContext().firestore().doc("users/alice").get());
137
+ });
138
+ ```
139
+
140
+ Run: `firebase emulators:exec --only firestore "npx jest tests/firestore.rules.test.ts"`
141
+
142
+ ## Migration Strategies
143
+
144
+ ### Backfill (Add New Field)
145
+
146
+ Firestore cannot query for missing fields. Query all, skip docs that already have the field:
147
+
148
+ ```typescript
149
+ for (const doc of snapshot.docs) {
150
+ if (doc.data().status !== undefined) { skipped++; continue; }
151
+ batch.update(doc.ref, { status: "active" });
152
+ }
153
+ ```
154
+
155
+ ### Transform (Modify Existing Field)
156
+
157
+ ```typescript
158
+ const TRANSFORMS: Record<string, string> = {
159
+ "free": "free_tier", "Free": "free_tier", "premium": "premium_tier", "enterprise": "enterprise_tier",
160
+ };
161
+ for (const doc of snapshot.docs) {
162
+ const newType = TRANSFORMS[doc.data().type];
163
+ if (!newType || doc.data().type === newType) { skipped++; continue; }
164
+ batch.update(doc.ref, { type: newType });
165
+ }
166
+ ```
167
+
168
+ ### Collection Move
169
+
170
+ ```typescript
171
+ async function moveCollection(source: string, target: string) {
172
+ let lastDoc: admin.firestore.DocumentSnapshot | null = null;
173
+ while (true) {
174
+ let query = db.collection(source).orderBy("__name__").limit(500);
175
+ if (lastDoc) query = query.startAfter(lastDoc);
176
+ const snapshot = await query.get();
177
+ if (snapshot.empty) break;
178
+ const batch = db.batch();
179
+ for (const doc of snapshot.docs) {
180
+ batch.set(db.collection(target).doc(doc.id), doc.data());
181
+ batch.delete(doc.ref);
182
+ }
183
+ await batch.commit();
184
+ lastDoc = snapshot.docs[snapshot.docs.length - 1];
185
+ }
186
+ }
187
+ ```
188
+
189
+ **Warning**: Not atomic across batches. Run reconciliation after failures.
190
+
191
+ ## Emulator Setup
192
+
193
+ ```json
194
+ { "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } } }
195
+ ```
196
+
197
+ Connect Admin SDK:
198
+
199
+ ```typescript
200
+ process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";
201
+ process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099";
202
+ admin.initializeApp({ projectId: "demo-test" });
203
+ ```
204
+
205
+ Persist data between restarts:
206
+
207
+ ```bash
208
+ firebase emulators:start --export-on-exit=./emulator-data --import=./emulator-data
209
+ ```
210
+
211
+ Add `emulator-data/` to `.gitignore`.
212
+
213
+ ---
214
+ *[Tons of Skills](https://tonsofskills.com) by [Intent Solutions](https://intentsolutions.io) | [jeremylongshore.com](https://jeremylongshore.com)*
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # setup-firestore.sh - Setup Firestore for Firebase/GCP project
3
+
4
+ set -euo pipefail
5
+
6
+ PROJECT_ID="${1:-${GCP_PROJECT_ID:-}}"
7
+
8
+ if [[ -z "$PROJECT_ID" ]]; then
9
+ echo "Usage: $0 <PROJECT_ID>"
10
+ echo "Setup Firestore database with security rules"
11
+ exit 1
12
+ fi
13
+
14
+ echo "Setting up Firestore"
15
+ echo "Project: $PROJECT_ID"
16
+ echo ""
17
+
18
+ # Enable Firestore API
19
+ echo "Enabling Firestore API..."
20
+ gcloud services enable firestore.googleapis.com --project="$PROJECT_ID"
21
+
22
+ # Create Firestore database (Native mode)
23
+ echo "Creating Firestore database..."
24
+ gcloud firestore databases create \
25
+ --location=us-central1 \
26
+ --project="$PROJECT_ID" || echo "Database may already exist"
27
+
28
+ # Create security rules file
29
+ cat > firestore.rules <<'EOF'
30
+ rules_version = '2';
31
+ service cloud.firestore {
32
+ match /databases/{database}/documents {
33
+ // User documents
34
+ match /users/{userId} {
35
+ allow read, write: if request.auth != null && request.auth.uid == userId;
36
+ }
37
+
38
+ // Public documents
39
+ match /public/{document=**} {
40
+ allow read: if true;
41
+ allow write: if request.auth != null;
42
+ }
43
+
44
+ // A2A agent communication
45
+ function isServiceAccount() {
46
+ return request.auth.token.email.matches('.*@.*\\.iam\\.gserviceaccount\\.com$');
47
+ }
48
+
49
+ match /agent_sessions/{sessionId} {
50
+ allow read, write: if isServiceAccount();
51
+ }
52
+
53
+ match /agent_memory/{agentId}/{document=**} {
54
+ allow read, write: if isServiceAccount();
55
+ }
56
+ }
57
+ }
58
+ EOF
59
+
60
+ echo "✓ Security rules created: firestore.rules"
61
+ echo ""
62
+ echo "Deploy security rules with:"
63
+ echo " firebase deploy --only firestore:rules"