Chapter 11 · Subcollections, Collection Groups, Hierarchies, Deletion, and Data Lifecycle
Archiving, Soft Delete, Legal Retention, Tenant Deletion, Export, and Lifecycle State Machines
Design AtlasMart soft-delete, archive, retention-hold and purge states with explicit query behavior and cloud-export boundaries.
Learning outcomes
Distinguish hard delete, soft delete, archive, retention hold, purge eligibility and verified purge as separate lifecycle states.
Design normal queries so soft-deleted data cannot silently leak back into application screens.
Explain why managed export/import is a billed cloud recovery/processing tool rather than a local emulator feature or exact transaction snapshot.
Create a retention-exception manifest that makes legal/business holds explicit before tenant or subject purge.
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: “delete” can conflict with retention
A tenant closes its store and requests deletion. AtlasMart also
has invoices that finance policy says must be retained, a fraud
case under a temporary hold, and ordinary chat/activity data
that can be purged. A one-bit deleted=true field
cannot represent these different obligations safely. Lifecycle
must be a state machine with explicit query behavior and
transition authority.
| State | Meaning | Query behavior | Deletion consequence |
|---|---|---|---|
| ACTIVE | Normal application data | Included in normal reads | No purge action |
| SOFT_DELETED | Hidden from normal UX but retained | Every normal query must exclude it | Still exists and is billable/queryable unless constrained |
| ARCHIVED | Moved or copied to an archive contract | Separate operational query surface | Original may be purged only after verified archive policy |
| LEGAL_HOLD | Deletion suspended by organization policy | Usually excluded from normal UX; retained for authorized workflows | Must not be hard-deleted until hold is released |
| PURGE_ELIGIBLE | Retention/hold checks passed | Should be absent from normal UX | May enter controlled deletion job |
| PURGED | Target data removed and verified | No application result expected | Audit evidence remains according to its own policy |
2. Soft delete is a query contract, not a disappearance
await db.doc("tenants/t-acme/orders/o-1001").update({ lifecycleState: "SOFT_DELETED", deletedAt: FieldValue.serverTimestamp(), deletionReason: "TENANT_CLOSURE", schemaVersion: 4});
Soft-deleted data still exists, can be read by trusted code, participates in indexes according to configuration, and may be included by any query that forgets the lifecycle predicate. Every normal access pattern needs a test that excludes non-active states. For sensitive data on client caches, a server-side soft delete also does not instantly erase bytes from an already-populated local persistent cache; offline/cache policy must be handled separately.
A forgotten filter can expose logically deleted data. Repair the model by centralizing query builders or materialized active collections, testing lifecycle predicates, tightening client rules where possible, and minimizing client access to retained/held records.
3. Retention holds override purge transitions
{ "targetType": "ORDER", "targetPath": "tenants/t-acme/orders/o-1002", "tenantId": "t-acme", "reasonCode": "FINANCE_RETENTION", "state": "ACTIVE", "releaseAfter": "2027-09-16T00:00:00Z", "policyVersion": "finance-retention-v3", "schemaVersion": 4}
This is an application governance pattern, not legal advice or a Firestore compliance feature. Your organization must define retention periods, legal bases, hold authority, export requirements and audit retention with appropriate counsel/policy owners. The database implementation should make those decisions enforceable and reviewable rather than inventing them.
4. Archive can mean several different things
“Archive” might mean a separate Firestore collection with stricter access, a managed export in Cloud Storage, a warehouse/archive system, or merely a lifecycle state in the same document. Each has different query, security, cost and deletion implications. Copy-then-delete is not atomic across arbitrary destinations, so record archive verification before marking source data purge-eligible.
| Archive form | Benefit | Risk/limitation | Verification evidence |
|---|---|---|---|
| Same Firestore document, state=ARCHIVED | Simple transition | Still in same storage/query surface | State + rules/query tests |
| Separate archive collection/database | Operational isolation | Copy/delete workflow and duplicate risk | Source/target counts + checksum/IDs |
| Managed Firestore export to Cloud Storage | Recovery/offline processing | Billing required; export is not an exact start-time snapshot | Completed operation + output URI + collection groups |
| External warehouse/object archive | Long-term analytics/retention flexibility | Separate IAM/encryption/retention system | Destination manifest + access/retention policy |
5. Managed export/import: cloud-only and billed
Firestore managed export/import requires billing and Cloud Storage. Exporting data incurs document-read charges; import incurs writes, and the export is not an exact database snapshot taken at the operation start. You can export selected collection groups, but exporting a parent collection group does not automatically include its subcollections unless those collection groups are also selected. Therefore “archive tenant” cannot be implemented safely by assuming one parent collection export contains every descendant.
# Requires a real billing-enabled project, Cloud Storage bucket and appropriate IAM.gcloud config set project REAL_PROJECT_IDgcloud firestore export gs://REAL_BUCKET/atlasmart-archive-2026-09-16 \ --collection-ids=orders,messages \ --database='(default)'# Verify operation completion and manifest before source purge.
Do not run this against the demo/emulator project as if it were a local feature. For the mandatory no-cost exercise, create a deterministic JSON manifest from the emulator instead.
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();}
const orderSnap = await db.collection("tenants/t-acme/orders").get();const messageSnap = await db.collectionGroup("messages").where("tenantId","==","t-acme").get();const manifest = { kind: "LOCAL_TRAINING_MANIFEST", tenantId: "t-acme", orders: orderSnap.docs.map(d => d.ref.path).sort(), messages: messageSnap.docs.map(d => d.ref.path).sort()};console.log(JSON.stringify(manifest, null, 2));
6. Lifecycle transition guard
const allowed = { ACTIVE: new Set(["SOFT_DELETED", "LEGAL_HOLD"]), SOFT_DELETED: new Set(["ACTIVE", "ARCHIVED", "LEGAL_HOLD", "PURGE_ELIGIBLE"]), ARCHIVED: new Set(["LEGAL_HOLD", "PURGE_ELIGIBLE"]), LEGAL_HOLD: new Set(["ACTIVE", "SOFT_DELETED", "ARCHIVED"]), PURGE_ELIGIBLE: new Set(["PURGED"]), PURGED: new Set()};function assertLifecycleTransition(from, to) { if (!allowed[from]?.has(to)) throw new Error(`INVALID_LIFECYCLE_${from}_TO_${to}`);}
Keep lifecycle transitions in trusted code and preserve who/why/correlation evidence. A hard-delete command should require a prior eligibility decision rather than determining policy while deleting.
7. Reproducible AtlasMart lab
{ "name": "atlasmart-firestore-ch11", "private": true, "type": "module", "engines": { "node": ">=22" }, "dependencies": { "firebase": "12.19.0", "firebase-admin": "14.4.0" }, "devDependencies": { "firebase-tools": "15.30.0", "@firebase/rules-unit-testing": "5.0.2" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function sameTenant(tenantId) { return signedIn() && request.auth.token.tenantId == tenantId; } match /tenants/{tenantId} { allow read: if sameTenant(tenantId); allow write: if false; match /users/{uid} { allow read: if sameTenant(tenantId); allow write: if sameTenant(tenantId) && request.auth.uid == uid; } match /orders/{orderId} { allow read: if sameTenant(tenantId); allow write: if false; match /messages/{messageId} { allow read: if sameTenant(tenantId); allow write: if false; } } } // Required for collection-group queries over every messages collection. match /{path=**}/messages/{messageId} { allow read: if signedIn() && resource.data.tenantId == request.auth.token.tenantId; allow write: if false; } match /deletionAudits/{id} { allow read, write: if false; } match /retentionHolds/{id} { allow read, write: if false; } match /{document=**} { allow read, write: if false; } }}
{ "indexes": [ { "collectionGroup": "messages", "queryScope": "COLLECTION_GROUP", "fields": [ { "fieldPath": "tenantId", "order": "ASCENDING" }, { "fieldPath": "sentAt", "order": "DESCENDING" } ] } ], "fieldOverrides": []}
mkdir atlasmart-firestore-ch11 && cd atlasmart-firestore-ch11npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from this lesson.npx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth
Seed the tenant. Soft-delete o-1001 and prove an
“active orders” query excludes it. Put o-1002 under
a retention hold and prove the purge planner returns it as an
exception. Generate the local archive manifest. Transition only
eligible paths to PURGE_ELIGIBLE; do not
hard-delete held data.
const plan = { tenantId: "t-acme", delete: ["tenants/t-acme/orders/o-1001/messages/m-001", "tenants/t-acme/orders/o-1001"], retain: [{ path:"tenants/t-acme/orders/o-1002", reason:"FINANCE_RETENTION" }], externalCopiesToReview: ["userDirectory/u-1001", "activity/a-001"], schemaVersion: 4};console.log(JSON.stringify(plan, null, 2));
Production judgment
Lifecycle design is a policy-to-data contract. Make normal-query behavior explicit, centralize transition authority, separate retention exceptions from ordinary data, and require verified archive/hold evidence before purge. Backups, PITR and exports are recovery/governance tools but do not automatically satisfy a data-subject deletion or legal-retention program; later chapters treat backup/recovery in detail.
Lesson 5 turns the lifecycle policy into an executable tenant/data-subject deletion manifest that searches every known copy and verifies both removals and documented exceptions.
Knowledge check
- Does soft delete remove stored data?
- Why is a retention hold a separate state?
- Is managed export a free emulator capability?
- Does exporting orders automatically export messages subcollections?
- What should precede hard deletion?
Review the answers
1. No. The document remains stored and must be excluded by query/rules/application policy.
2. It must block ordinary purge progression and preserve an explicit reason/policy/release process.
3. No. It is a billing-enabled production service using Cloud Storage and IAM.
4. Not simply by naming the parent collection group; required subcollection groups must be included explicitly.
5. A verified lifecycle decision showing the data is purge-eligible and not subject to an active retention exception.
Summary and next step
Hard delete is only one terminal lifecycle action. AtlasMart now distinguishes active, soft-deleted, archived, held and purge-eligible data. Next we implement deletion as a manifest that can prove what was removed and what was intentionally retained.
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.