Cloud Sync Architecture
Real-time collaboration with Firebase and granular sharding.
Overview
On Book Pro uses Firebase Firestore for real-time cloud synchronization with a granular sharding strategy that optimizes performance and enables offline-first operation.
Core Concepts
1. Sharded Document Architecture
Instead of storing the entire project in a single document, we shard data by feature:
projects/{projectId}/
├── data/ # Sharded content documents
│ ├── structure # Acts, scenes, characters, rows, notes
│ ├── cast # Personnel, characters, tracking columns
│ ├── props # Props inventory
│ ├── schedule # Rehearsal events & scenes
│ ├── sets # Set models
│ ├── blocking # Blocking & choreography
│ ├── sound # Sound clips & cues
│ ├── lighting # Lighting cues, follow spots, follow spot cues
│ └── meta # Project metadata & assets
├── feed/{postId} # Social feed posts (subcollection)
├── feedComments/{commentId} # Feed comments (subcollection)
├── emailHistory/{emailId} # Sent email records (subcollection)
├── members/{userId} # RBAC membership records
├── roles/{roleId} # Permission role definitions
└── files/{fileId} # File repository metadataNote: Characters are stored within
structure/currentalongside acts and scenes, not as a separate shard. This consolidation (implemented Jan 2026) groups all show-structural data together.
Why Sharding?
| Benefit | Explanation |
|---|---|
| Reduced Bandwidth | Only changed features sync, not the entire project |
| Parallel Writes | Multiple users can edit different features simultaneously |
| Granular Listeners | Components subscribe only to relevant data |
| Offline Resilience | Local persistence per shard reduces corruption risk |
Sync Lifecycle
1. Authentication
// User signs in with Firebase Auth
const user = await signInWithPopup(auth, googleProvider);2. Project Discovery
Projects are discovered via direct path URLs (not collection group queries):
// URL structure: /:projectId/:token
const projectRef = doc(db, `projects/${projectId}/meta/info`);
const projectSnap = await getDoc(projectRef);This enables:
- Strict data siloing between projects
- Future subscriber permissions (multi-tenant support)
- Security rule simplicity (no broad collection access)
3. Presence Tracking
Real-time presence uses a dedicated collection:
presence/{projectId}/users/{userId}
lastSeen: Timestamp
displayName: string
status: "online" | "idle"Important: Presence writes are guarded—never sync presence for non-existent projects.
4. Bi-Directional Sync
// Component subscribes to Firestore
useEffect(() => {
const unsubscribe = onSnapshot(
doc(db, `projects/${projectId}/show/scenes`),
(snapshot) => {
const cloudData = snapshot.data();
// Merge into Zustand store
updateScenes(cloudData.scenes);
}
);
return unsubscribe;
}, [projectId]);
// Local changes push to Firestore
const saveScenes = async (scenes) => {
await setDoc(
doc(db, `projects/${projectId}/show/scenes`),
{ scenes, updatedAt: serverTimestamp() }
);
};Conflict Resolution
CRDT for Rich Text
Notes use Y.js (CRDT) for conflict-free collaborative editing:
import * as Y from 'yjs';
const ydoc = new Y.Doc();
const ytext = ydoc.getText('content');
// Automatic conflict resolution—no manual merge neededLast-Write-Wins for State
All other features use Last-Write-Wins with Firestore server timestamps:
{
data: {...},
updatedAt: serverTimestamp(), // Firestore server time
updatedBy: userId
}Security Rules
Key Patterns
// Firestore Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Project-level access control
match /projects/{projectId}/meta/info {
allow read: if request.auth != null &&
(resource.data.ownerId == request.auth.uid ||
request.auth.uid in resource.data.collaborators);
allow write: if request.auth != null &&
resource.data.ownerId == request.auth.uid;
}
// Feature shards inherit project permissions
match /projects/{projectId}/{feature}/{document} {
allow read, write: if
request.auth != null &&
exists(/databases/$(database)/documents/projects/$(projectId)/meta/info) &&
(get(/databases/$(database)/documents/projects/$(projectId)/meta/info).data.ownerId == request.auth.uid ||
request.auth.uid in get(/databases/$(database)/documents/projects/$(projectId)/meta/info).data.collaborators);
}
}
}Offline Support
Local Persistence
Zustand store persists to LocalStorage:
persist(
(set, get) => ({ /* store slices */ }),
{
name: 'run-sheet-storage',
storage: createJSONStorage(() => localStorage),
}
)Firestore Offline Cache
Firebase SDK automatically caches data:
enableIndexedDbPersistence(db)
.catch((err) => {
if (err.code === 'failed-precondition') {
console.warn('Multiple tabs open—persistence only in first tab');
}
});Sync on Reconnect
Queued writes push automatically when connection restores:
// No manual retry needed—Firebase SDK handles it
await setDoc(docRef, data); // Queues if offline, sends when onlineCommon Patterns
Initialize Cloud Sync
import { initializeCloudSync } from '@/features/sync/cloudSync';
// In app initialization
useEffect(() => {
if (user && projectId) {
initializeCloudSync(projectId, user.uid);
}
}, [user, projectId]);Create New Project
import { createCloudProject } from '@/features/sync/projectActions';
const newProjectId = await createCloudProject({
name: 'Hamlet',
ownerId: user.uid,
createdAt: new Date().toISOString(),
});Share Project
import { generateShareToken } from '@/features/sync/shareTokens';
const token = await generateShareToken(projectId);
const inviteLink = `${window.location.origin}/${projectId}/${token}`;Troubleshooting
Issue: "Missing or insufficient permissions"
Cause: Security rules rejecting the request.
Solution:
- Verify project exists in Firestore
- Check that
meta/infodocument hasownerIdor includes user incollaborators - Ensure user is authenticated
Issue: "Presence writes fail for new projects"
Cause: Trying to write presence before project document exists.
Solution: Guard presence writes:
if (projectExists) {
updatePresence(projectId, userId);
}Subcollection Persistence Pattern
Some features use dedicated Firestore subcollections instead of shards. This is preferred for data that:
- Grows unboundedly (unlike finite props/cast lists)
- Needs individual document CRUD (add/edit/delete single documents)
- Benefits from real-time
onSnapshotlisteners without debounced auto-save
Example: Social Feed
The Social Feed uses projects/{projectId}/feed/ and feedComments/ subcollections with a dedicated API layer:
// src/features/feed/feed-api.ts — Firestore CRUD
createFeedPost(projectId, params) → addDoc to feed subcollection
updateFeedPost(projectId, id, data) → updateDoc
deleteFeedPost(projectId, id) → soft-delete (deleted: true)
subscribeToPosts(projectId, cb) → onSnapshot listener
// src/features/feed/hooks/useFeedSync.ts — Real-time hook
// Subscribes to both feed/ and feedComments/ subcollections
// Pushes incoming data into the Zustand store via setPosts/setCommentsStore actions are two-tiered:
- If
cloud.projectIdexists → write to Firestore, snapshot updates the store - If no project → fall back to local-only Zustand mutations
Feed data is excluded from partialize (LocalStorage) to avoid stale-data conflicts with Firestore as the source of truth.
Other features using this pattern: emailHistory (write-only from Cloud Functions).
Further Reading
Last updated: February 8, 2026 (Added lighting shard for cues and follow spots)