Skip to content

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 metadata

Note: Characters are stored within structure/current alongside acts and scenes, not as a separate shard. This consolidation (implemented Jan 2026) groups all show-structural data together.

Why Sharding?

BenefitExplanation
Reduced BandwidthOnly changed features sync, not the entire project
Parallel WritesMultiple users can edit different features simultaneously
Granular ListenersComponents subscribe only to relevant data
Offline ResilienceLocal persistence per shard reduces corruption risk

Sync Lifecycle

1. Authentication

typescript
// 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):

typescript
// 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:

text
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

typescript
// 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:

typescript
import * as Y from 'yjs';

const ydoc = new Y.Doc();
const ytext = ydoc.getText('content');

// Automatic conflict resolution—no manual merge needed

Last-Write-Wins for State

All other features use Last-Write-Wins with Firestore server timestamps:

typescript
{
  data: {...},
  updatedAt: serverTimestamp(), // Firestore server time
  updatedBy: userId
}

Security Rules

Key Patterns

javascript
// 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:

typescript
persist(
  (set, get) => ({ /* store slices */ }),
  {
    name: 'run-sheet-storage',
    storage: createJSONStorage(() => localStorage),
  }
)

Firestore Offline Cache

Firebase SDK automatically caches data:

typescript
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:

typescript
// No manual retry needed—Firebase SDK handles it
await setDoc(docRef, data); // Queues if offline, sends when online

Common Patterns

Initialize Cloud Sync

typescript
import { initializeCloudSync } from '@/features/sync/cloudSync';

// In app initialization
useEffect(() => {
  if (user && projectId) {
    initializeCloudSync(projectId, user.uid);
  }
}, [user, projectId]);

Create New Project

typescript
import { createCloudProject } from '@/features/sync/projectActions';

const newProjectId = await createCloudProject({
  name: 'Hamlet',
  ownerId: user.uid,
  createdAt: new Date().toISOString(),
});

Share Project

typescript
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:

  1. Verify project exists in Firestore
  2. Check that meta/info document has ownerId or includes user in collaborators
  3. Ensure user is authenticated

Issue: "Presence writes fail for new projects"

Cause: Trying to write presence before project document exists.

Solution: Guard presence writes:

typescript
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 onSnapshot listeners without debounced auto-save

Example: Social Feed

The Social Feed uses projects/{projectId}/feed/ and feedComments/ subcollections with a dedicated API layer:

typescript
// 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/setComments

Store actions are two-tiered:

  1. If cloud.projectId exists → write to Firestore, snapshot updates the store
  2. 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)