@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.
- package/.claude-plugin/plugin.json +26 -0
- package/LICENSE +21 -0
- package/README.md +615 -0
- package/agents/firebase-operations-agent.md +411 -0
- package/agents/firestore-security-agent.md +478 -0
- package/commands/firestore-setup.md +543 -0
- package/package.json +48 -0
- package/skills/firestore-operations-manager/ARD.md +215 -0
- package/skills/firestore-operations-manager/PRD.md +106 -0
- package/skills/firestore-operations-manager/SKILL.md +67 -0
- package/skills/firestore-operations-manager/references/errors.md +85 -0
- package/skills/firestore-operations-manager/references/examples.md +211 -0
- package/skills/firestore-operations-manager/references/implementation.md +214 -0
- package/skills/firestore-operations-manager/scripts/setup-firestore.sh +63 -0
|
@@ -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"
|