Chapter 11 · Subcollections, Collection Groups, Hierarchies, Deletion, and Data Lifecycle
Collection Group Queries and Index / Rules Requirements Across Parent Documents
Secure and index AtlasMart collection-group queries across parent documents while proving tenant isolation and query-rule compatibility.
Learning outcomes
Explain exactly how a collection-group query crosses parent documents and why identical collection IDs become one query surface.
Write rules_version 2 Security Rules that authorize the collection group itself rather than assuming parent collection rules act as filters.
Derive a collection-group composite index from the AtlasMart tenant-message query contract.
Validate query results, tenant isolation and missing-index/rules boundaries without overclaiming emulator equivalence to production.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart continues the same mandatory environment used in
Chapters 01–10: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database, Firestore
emulator 127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 with
@google-cloud/firestore 9.1.0, and Node.js 22+.
Mandatory deletion/lifecycle work remains local and isolated.
Managed bulk delete, managed export/import, PITR, production
IAM, Cloud Storage and legal/compliance procedures are
discussed but are not falsely claimed to have run in the
emulator.
The Emulator Suite is appropriate for proving path structure, document-delete versus surviving-subcollection behavior, collection-group query results, Security Rules behavior, recursive-cleanup logic and deterministic post-delete verification. It does not establish production delete throughput, billed read/delete cost, managed bulk-delete progress, export consistency, IAM behavior, backup/PITR recovery, legal-retention compliance or p95/p99 latency. Recursive deletion is multi-operation work rather than one atomic cascade; every production run needs a scoped target, project/database guard, durable audit evidence and post-delete verification.
1. The AtlasMart problem: support needs messages across many orders
Messages are stored under each order because the order owns
their lifecycle. Support nevertheless needs “the 20 newest
messages for tenant t-acme.” Reading every order and then every
messages subcollection would create read
amplification and complex client logic. A
collection-group query targets every collection
named messages, then applies normal
filters/order/limits.
import { collectionGroup, query, where, orderBy, limit, getDocs } from "firebase/firestore";const q = query( collectionGroup(db, "messages"), where("tenantId", "==", "t-acme"), orderBy("sentAt", "desc"), limit(20));const snap = await getDocs(q);console.table(snap.docs.map(d => ({ path: d.ref.path, ...d.data() })));
The query does not care whether a matching
messages collection sits under an order, a support
case, or some future path. That is useful and dangerous: reusing
the same collection ID elsewhere automatically expands the
collection group.
2. Security Rules must secure the group, not post-filter it
Security Rules are not row filters. Firestore evaluates whether a query could return forbidden documents; it does not fetch everything and remove unauthorized documents afterward. Collection-group queries require Security Rules version 2 recursive-wildcard behavior and an explicit rule for the group.
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /{path=**}/messages/{messageId} { allow read: if request.auth != null && resource.data.tenantId == request.auth.token.tenantId; allow write: if false; } }}
The AtlasMart query includes
where("tenantId", "==", signed-in tenant) so the
query contract is compatible with the rule. A broad query over
all messages cannot rely on the rule to hide another tenant’s
documents.
Fetching a broad collection group and filtering tenant IDs in application code is both a security design error and unnecessary read amplification. Repair it by making the authorized tenant constraint part of the server-evaluated query and by testing the rule against adversarial broad queries.
3. Index the query contract
{ "indexes": [ { "collectionGroup": "messages", "queryScope": "COLLECTION_GROUP", "fields": [ { "fieldPath": "tenantId", "order": "ASCENDING" }, { "fieldPath": "sentAt", "order": "DESCENDING" } ] } ], "fieldOverrides": []}
In Standard Native mode, compound collection-group queries can require an appropriate collection-group index. Production can return a missing-index error with a creation path; the emulator should not be treated as proof that production has the same index state. Keep the index definition in source control and deploy it deliberately.
4. Make cross-parent results observable
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();
async function seedHierarchy() { const batch = db.batch(); batch.set(db.doc("tenants/t-acme"), { name: "Acme Seller", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/users/u-1001"), { tenantId: "t-acme", uid: "u-1001", displayName: "Ava", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1001"), { tenantId: "t-acme", customerId: "u-1001", status: "PAID", totalMinor: 12990, lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1002"), { tenantId: "t-acme", customerId: "u-1002", status: "PAID", totalMinor: 4950, lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1001/messages/m-001"), { tenantId: "t-acme", orderId: "o-1001", authorId: "u-1001", text: "Please leave at reception", sentAt: Timestamp.fromMillis(1789570800000), schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1002/messages/m-002"), { tenantId: "t-acme", orderId: "o-1002", authorId: "u-1002", text: "Thanks", sentAt: Timestamp.fromMillis(1789570860000), schemaVersion: 4 }); batch.set(db.doc("userDirectory/u-1001"), { uid: "u-1001", tenantId: "t-acme", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("activity/a-001"), { subjectUid: "u-1001", tenantId: "t-acme", type: "ORDER_CREATED", schemaVersion: 4 }); await batch.commit();}
await seedHierarchy();const snap = await db.collectionGroup("messages") .where("tenantId", "==", "t-acme") .orderBy("sentAt", "desc") .limit(20) .get();const paths = snap.docs.map(d => d.ref.path);console.log(paths);if (paths.length !== 2) throw new Error(`expected 2 t-acme messages, got ${paths.length}`);if (!paths.every(p => p.includes("/messages/"))) throw new Error("unexpected path");
The trusted Admin query proves the collection-group result shape. A separate client/rules test must prove authorization. Do not confuse the two: Admin/server libraries bypass client Security Rules in production and use IAM.
5. Adversarial rules test
import { initializeTestEnvironment, assertFails, assertSucceeds } from "@firebase/rules-unit-testing";import { collectionGroup, query, where, orderBy, getDocs } from "firebase/firestore";const env = await initializeTestEnvironment({ projectId: "demo-atlasmart-firestore", firestore: { rules: /* load firestore.rules text */ rulesText }});const acme = env.authenticatedContext("u-1001", { tenantId: "t-acme" }).firestore();await assertSucceeds(getDocs(query( collectionGroup(acme, "messages"), where("tenantId", "==", "t-acme"), orderBy("sentAt", "desc"))));await assertFails(getDocs(query( collectionGroup(acme, "messages"), orderBy("sentAt", "desc"))));
The second query intentionally omits the tenant constraint. Its denial demonstrates that rules are evaluated against the query’s possible result set. A passing emulator test is necessary regression evidence, not proof of production IAM, billing or index-build readiness.
6. Collection ID is a security namespace
If AtlasMart later creates
supportCases/{id}/messages/{id}, those documents
join the same messages collection group. If their
security semantics differ, either make the shared group rule
safe for both, include discriminating fields that the
query/rules can prove, or choose a different collection ID. A
collection ID is therefore not just a naming preference—it can
become a cross-hierarchy query and authorization namespace.
| Risk surface | Observable failure | Design response | |
|---|---|---|---|
| ['Reused collection ID', 'Unexpected documents enter collection-group results', 'Use a distinct ID or add provable discriminator fields'] | ['Missing tenant filter', 'Client query is denied by rules', 'Put authorization constraint into the query itself'] | ['Missing composite group index', 'Production query returns missing-index failure', 'Keep COLLECTION_GROUP index in source control and deploy it'] | ['Admin query assumed to test Rules', 'Trusted query succeeds despite bad client rule', 'Add explicit rules-unit-testing coverage'] |
Production judgment
Use collection groups when ownership belongs under parents but cross-parent access is a first-class requirement. Evaluate the design through index count, rules maintainability, tenant/subject isolation, collection-ID reuse risk, query selectivity and delete discoverability. Enterprise Native indexing differs from Standard, so repeat plan/cost reasoning there; Pipeline capabilities should not be assumed to share client Security Rules or realtime/offline surfaces. MongoDB compatibility has its own query/authorization model.
Lesson 3 now tests the lifecycle consequence: deleting an order
document does not remove its messages descendants.
Knowledge check
- Why can a collection-group query see messages under different orders?
- Why must the query include the tenant constraint?
- What Security Rules version is required for the recursive wildcard collection-group pattern?
- What can change if a new hierarchy reuses the messages collection ID?
- Does a successful emulator query prove the production composite index is ready?
Review the answers
1. Because all subcollections with the ID messages belong to the same collection group.
2. Rules are not filters; the query must be provably compatible with the authorization condition.
3. rules_version 2.
4. Those documents join the same collection group and may fall under the same group rule/index surface.
5. No. Production index state/build status must be verified separately.
Summary and next step
Collection groups recover cross-parent queryability without flattening ownership, but they create an explicit rules/index namespace. Next we deliberately delete a parent and observe the orphaned descendants that remain.
Authoritative references
- Cloud Firestore data model — subcollections, hierarchy depth and the non-cascading document-delete warning.
- Collection group queries — querying same-ID subcollections across parents.
- Securely query data — rules are not filters and rules_version 2 collection-group patterns.
- Manage indexes — collection-group/composite index configuration.
- Delete data — collection deletion guidance and server-side recursive deletion.
- Delete collections and subcollections — recursive deletion, orphan discovery and non-atomic behavior.
- Managed bulk delete — production collection-group deletion, billing and operation behavior.
- Export and import data — billing, Cloud Storage, collection-group exports and recovery implications.
- Delete User Data extension — configured paths, recursive mode and auto-discovery considerations.
- Firebase CLI release notes — pinned CLI baseline.
- Admin Node.js release notes — pinned trusted-server baseline.