Pajama App: Shared Experiences System Documentation
1. Overview
The Shared Experiences system in the Pajama app allows multiple users in a real-time communication (RTC) call to participate in a synchronized activity, such as co-reading a story. The system is designed to be extensible, allowing new types of shared experiences to be added in the future.
Core Goals:
- Real-time Synchronization: Ensure all participants have a consistent view of the experience state.
- Persistence: Allow experiences to be re-joined if a user disconnects and reconnects, or even across call sessions (though full cross-session persistence might be a future enhancement depending on
ExperienceSessionlifecycle). - Resilience: Handle network issues, participant joins/leaves, and owner changes gracefully.
- Extensibility: Provide a clear framework for defining and integrating new shared experiences.
2. Core Concepts & Key Components
- Experience: A shared activity (e.g., co-reading, co-drawing).
- Experience Definition (
ExperienceDefinition): A blueprint for a specific type of experience. It defines how to create the necessary components (Actor, ViewModel) and manage its state. - Experience Registry (
ExperienceRegistry): A singleton that holds all registeredExperienceDefinitions, allowing the system to look them up by ID. - Experience Manager (
ExperienceManager): The central orchestrator for shared experiences. It manages the lifecycle, activation/deactivation, and UI creation for the active experience. - Experience Initializer (
ExperienceInitializer): Handles the complex, multi-step process of setting up a new or existing experience session. - Experience Session Actor (
ExperienceSessionActor): An actor (in the Swift actor model sense) responsible for managing the specific state of an active experience instance (e.g.,CoReadingSessionActorfor co-reading). - Experience View Model (
ExperienceViewModel): AnObservableObjectthat bridges theExperienceSessionActor's state to the SwiftUI views. - Experience System Context (
ExperienceSystemContext): An actor that manages shared system-level context for an experience, primarily participant state and owner ID. - State Sync Engine (
StateSyncEngine): Handles the low-level transmission and reception of state change events between participants. Uses a dual PubSub (Stream Video) and Firestore approach. - State Sync Event (
StateSyncEvent): A message representing a change in the experience's state. - State Snapshot (
ExperienceStateSnapshot,StateSnapshotManager): A complete representation of an experience's state at a point in time, used for persistence and for new participants to catch up. - Experience Session (
ExperienceSession): A Firestore document representing an active shared experience tied to a call. - Experience Session Discoverer (
ExperienceSessionDiscoverer): Listens for activeExperienceSessiondocuments on Firestore to allow users to join ongoing experiences.
3. Architecture Diagram
graph TD
subgraph AppUI
ExperienceContentView["Experience Content View (SwiftUI)"]
CallView["Call View (Hosts Experience)"]
end
subgraph ExperienceManagerScope["ExperienceManager (ObservableObject)"]
EM["ExperienceManager"]
EM_LS["lifecycleState: ExperienceLifecycleState"]
EM_Actor["activeSessionActor: ExperienceSessionActor?"]
EM_VM["activeViewModel: ExperienceViewModel?"]
EM_SE["activeSyncEngine: StateSyncEngine?"]
EM_SSM["stateSnapshotManager: StateSnapshotManager?"]
EM_SysCtx["systemContext: ExperienceSystemContext?"]
EM_ESD["sessionDiscoverer: ExperienceSessionDiscoverer"]
EM_EI["initializer: ExperienceInitializer"]
EM --- EM_LS
EM --> EM_Actor
EM --> EM_VM
EM --> EM_SE
EM --> EM_SSM
EM --> EM_SysCtx
EM --> EM_ESD
EM --> EM_EI
end
subgraph ExperienceDefinitionScope["Experience Definition"]
ED_Registry["ExperienceRegistry"]
ED_Def["AnyExperienceDefinition (Wrapper)"]
ED_CoReadingDef["CoReadingExperienceDefinition (Concrete)"]
ED_Registry -- "1..* has a" --> ED_Def
ED_Def -- "wraps" --> ED_CoReadingDef
end
subgraph ActorScope["ExperienceSessionActor (Actor)"]
ESA_Protocol["ExperienceSessionActor (Protocol)"]
ESA_CoReading["CoReadingSessionActor (Concrete)"]
ESA_State["actorSpecificState (e.g., CoReadingState)"]
ESA_SysCtxRef["systemContext: ExperienceSystemContext"]
ESA_CoReading -- "implements" --> ESA_Protocol
ESA_CoReading --> ESA_State
ESA_CoReading -- "uses" --> ESA_SysCtxRef
end
subgraph ViewModelScope["ExperienceViewModel (ObservableObject)"]
EVM_Protocol["ExperienceViewModel (Protocol)"]
EVM_CoReading["CoReadingViewModel (Concrete)"]
EVM_RTC["rtcViewModel: RTCViewModel"]
EVM_CoReading -- "implements" --> EVM_Protocol
EVM_CoReading -- "uses" --> EVM_RTC
end
subgraph StateSyncScope["State Synchronization"]
SSE["StateSyncEngine (Protocol)"]
DSSE["DefaultStateSyncEngine (Actor)"]
SSE_Events["events: Publisher<StateSyncEvent, Never>"]
SSE_SeqVal["sequenceValidator: StateSyncEventSequenceValidator"]
SSE_PubSub["pubSubService: PubSubService (Stream)"]
SSE_FirestoreDep["firestore: Firestore"]
SSM_Manager["StateSnapshotManager (Actor)"]
SSR["StateSnapshotReader (Protocol)"]
SSW["StateSnapshotWriter (Protocol)"]
FSSR["FirestoreStateSnapshotReader (Concrete)"]
FSSW["FirestoreStateSnapshotWriter (Concrete)"]
SSEP["StateSyncEvent (Protocol)"]
BaseSSEP["BaseStateSyncEvent (Struct)"]
FSER["FirestoreStateSyncEventReader (Concrete)"]
DSSE -- "implements" --> SSE
DSSE --> SSE_Events
DSSE -- "has a" --> SSE_SeqVal
DSSE -- "uses" --> SSE_PubSub
DSSE -- "uses" --> SSE_FirestoreDep
SSM_Manager -- "uses" --> SSR
SSM_Manager -- "uses" --> SSW
FSSR -- "implements" --> SSR
FSSW -- "implements" --> SSW
BaseSSEP -- "implements" --> SSEP
end
subgraph ContextScope["System Context"]
SysCtx["ExperienceSystemContext (Actor)"]
SysCtx_Participants["participants: Set<UserID>"]
SysCtx_Owner["ownerId: UserID"]
SysCtx --> SysCtx_Participants
SysCtx --> SysCtx_Owner
end
subgraph Services
UserSessionSvc["UserSessionService"]
ContactSvc["ContactService"]
ExperienceSessionSvc["ExperienceSessionService"]
RTCSvc["RTCService / RTCViewModel"]
StorySvc["StoryService (for CoReading)"]
end
FirestoreDb["Firestore Database"]
%% Connections
CallView --> EM
EM_VM --> ExperienceContentView
EM --> ED_Registry
EM_EI --> ED_Def
ED_Def -- "creates" --> ESA_Protocol
ED_Def -- "creates" --> EVM_Protocol
EM_Actor --> ESA_Protocol
EM_VM --> EVM_Protocol
ESA_Protocol -- "updates" --> ESA_State
EVM_Protocol -- "observes" --> ESA_State
EVM_Protocol -- "observes" --> SysCtx_Participants
ESA_Protocol -- "uses" --> SSE
ESA_Protocol -- "uses" --> UserSessionSvc
ESA_Protocol -- "uses" --> ContactSvc
ESA_Protocol -- "uses" --> SysCtx
EM_EI -- "uses" --> SSE
EM_EI -- "uses" --> SSR
EM_EI -- "uses" --> FSER
EM_EI -- "uses" --> UserSessionSvc
EM_EI -- "uses" --> ContactSvc
EM_EI -- "uses" --> ExperienceSessionSvc
EM_SE --> SSE
EM_SSM --> SSM_Manager
EM_SysCtx --> SysCtx
EM_ESD -- "uses" --> ExperienceSessionSvc
EM -- "uses" --> RTCSvc
SSM_Manager -- "uses" --> ESA_Protocol
DSSE -- "uses" --> UserSessionSvc
DSSE -- "uses" --> RTCSvc
FSSR -- "reads from" --> FirestoreDb
FSSW -- "writes to" --> FirestoreDb
SSE_FirestoreDep -- "accesses" --> FirestoreDb
FSER -- "reads from" --> FirestoreDb
ExperienceSessionSvc -- "accesses" --> FirestoreDb
4. Detailed Component Breakdown
4.1. ExperienceManager
- Responsibilities:
- Acts as the primary interface for the UI to interact with shared experiences.
- Manages the overall lifecycle of a shared experience (
ExperienceLifecycleState). - Initiates activation of new experiences or re-activation of existing ones (via
ExperienceInitializer). - Coordinates the creation of
ExperienceSessionActorandExperienceViewModelusingExperienceRegistryandExperienceDefinition. - Handles deactivation and cleanup of active experiences.
- Listens to
RTCViewModelfor call state changes and participant updates, propagating relevant information to the active experience (primarily viaExperienceSystemContext). - Uses
ExperienceSessionDiscovererto find ongoing experiences in the current call. - Subscribes to the
StateSyncEngine's event stream to process system-level events (participant join/left, experience ended). - Manages the
StateSnapshotManagerfor the active experience. - Publishes UI-relevant state like
isFullScreenMode(derived from actor state) andisOwner.
- Key Properties/Methods:
lifecycleState: ExperienceLifecycleState: Published state of the experience.isFullScreenMode: Bool: Published, derived from actor.isOwner: Bool: Published, indicates if the current user is the owner.activateExperience(withId: String): Initiates a new experience.deactivateCurrentExperience(): Stops the current experience.createContentView() -> AnyView: Returns the SwiftUI view for the current experience.processLifecycleAction(_: ExperienceLifecycleAction): Core method to drive state changes.handleSideEffects(...): Manages tasks that need to occur upon state transitions.
- Interactions:
ExperienceRegistry: To get definitions.ExperienceInitializer: To set up new/existing sessions.ExperienceSessionActor: Holds the active actor.ExperienceViewModel: Holds the active view model.StateSyncEngine: Subscribes to its events.StateSnapshotManager: Manages it.ExperienceSystemContext: Manages it and passes it to actors.RTCViewModel: Observes call state and participant list.ExperienceSessionDiscoverer: Receives discovery notifications.
4.2. ExperienceRegistry & ExperienceDefinition
ExperienceRegistry:- Responsibilities: Singleton that stores and provides access to all available
ExperienceDefinitions. - Key Methods:
register(_: ExperienceDefinition),getDefinition(withId: String) -> AnyExperienceDefinition?.
- Responsibilities: Singleton that stores and provides access to all available
ExperienceDefinition(Protocol):- Responsibilities: Defines the contract for a specific type of experience.
- Key Properties/Methods:
static var id: String: Unique identifier (e.g., "co_reading").createSessionActor(context: ExperienceInitializationContext) -> Result<Actor, ExperienceError>: Factory method for the experience's state actor.createViewModel(sessionActor: Actor, rtcViewModel: RTCViewModel, manager: ExperienceManager) -> Result<ViewModel, ExperienceError>: Factory method for the experience's view model.decodeSnapshotData(...) -> AnyExperienceStateSnapshot: Decodes raw snapshot data into the specific experience's typed snapshot.
AnyExperienceDefinition: Type-erased wrapper to store heterogeneous definitions in the registry.
4.3. ExperienceInitializer
- Responsibilities: Orchestrates the complex setup process for an experience. This is crucial for both starting a new experience and joining an existing one.
- Key Steps in
initialize():- (Conditional) Create Firestore Session Document: If it's a new experience, create an
ExperienceSessiondocument in Firestore viaExperienceSessionService. - Create
StateSyncEngine: InstantiatesDefaultStateSyncEngineand connects it. - Create
ExperienceSystemContext: Initializes with session ID, experience ID, and owner ID. - Fetch Snapshot & Deltas:
- Uses
StateSnapshotReader(e.g.,FirestoreStateSnapshotReader) to get the latestExperienceStateSnapshot. - Uses the
ExperienceDefinitionto decode this snapshot into a typed one. - Updates
ExperienceSystemContextparticipants from the snapshot. - Uses
StateSyncEventReader(e.g.,FirestoreStateSyncEventReader) to fetch historical events (deltas) that occurred after the snapshot's sequence number. - Buffers these delta events into the
StateSyncEngine.
- Uses
- Perform Self-Join: Adds the current user to
ExperienceSystemContextand sends a "participant_joined"StateSyncEventvia the engine. - Create
ExperienceSessionActor: Uses theExperienceDefinition'screateSessionActormethod, passing the engine, context, and potentially the loaded snapshot. - Wait for Actor Readiness: Subscribes to the actor's
readinessStreamto ensure the actor has completed its internal setup (e.g., applying snapshot, setting up its own event listeners). - Engine Go Live: Calls
syncEngine.goLive(snapshotSequenceNumber), which processes buffered historical events and starts emitting live events to the actor. - Setup
StateSnapshotManager: Creates the manager, which will handle periodic state saving if the current user is the owner.
- (Conditional) Create Firestore Session Document: If it's a new experience, create an
- Returns:
InitializedExperienceComponentscontaining the ready-to-use components.
4.4. ExperienceLifecycleState & ExperienceLifecycleReducer
ExperienceLifecycleState(Enum):- Defines the various states an experience can be in:
inactive,initializing,active,degraded,deactivating,error. - Provides computed properties like
isActive,sessionId,experienceId,ownerId.
- Defines the various states an experience can be in:
ExperienceLifecycleAction(Enum):- Defines all possible actions that can trigger a state transition (e.g.,
activate,deactivate,syncConnected,participantJoined,error).
- Defines all possible actions that can trigger a state transition (e.g.,
experienceLifecycleReducer(Function):- A pure function:
(state: ExperienceLifecycleState, action: ExperienceLifecycleAction) -> ExperienceLifecycleState. - Takes the current state and an action, and returns the new state.
- This is the core logic dictating how the
ExperienceManagertransitions itslifecycleState.
- A pure function:
4.5. ExperienceSessionActor (Protocol) & Concrete Implementations (e.g., CoReadingSessionActor)
- Responsibilities:
- Manages the specific state for an instance of an experience (e.g.,
CoReadingStateforCoReadingSessionActor). - Processes incoming
StateSyncEvents (filtered by theExperienceManagerand passed from theStateSyncEngine) relevant to its experience type, updating its internal state. - Handles local user actions (e.g.,
selectStoryinCoReading), updates its state, and creates correspondingStateSyncEvents to be sent via theStateSyncEngine. - Exposes its state and any errors via
AsyncStreams (stateStream,errorStream). - Signals its readiness via
readinessStreamafter internal setup. - Provides its current state snapshot via
getState()for persistence. - Performs cleanup of its resources.
- Manages the specific state for an instance of an experience (e.g.,
- Key Interactions:
StateSyncEngine: Receives events from and sends events to.ExperienceSystemContext: Observes participant changes and owner ID.ExperienceViewModel: The ViewModel subscribes to the actor's state streams.
- Example (
CoReadingSessionActor):- Manages
CoReadingState(stories, selectedStory, currentPageIndex). - Uses
CoReadingReducerto processCoReadingActions. - Handles
CoReadingEffects (e.g., loading stories fromStoryService). - Converts
CoReadingActionto/fromStateSyncEvent.
- Manages
4.6. ExperienceSystemContext (Actor)
- Responsibilities:
- Provides a centralized, thread-safe store for system-level information shared across an experience instance.
- Manages the
participants: Set<UserID>andownerId: UserID. - Streams changes to participants (
participantsStream()). - Ensures data consistency for snapshots.
- Interactions:
ExperienceInitializer: Creates and populates it.ExperienceSessionActor: Observes it for participant changes and owner ID.StateSnapshotManager: Uses it to get participant/owner info when creating a snapshot.
4.7. StateSyncEngine (Protocol) & DefaultStateSyncEngine (Actor)
StateSyncEngine(Protocol):- Defines the contract for sending and receiving
StateSyncEvents. - Key methods:
connect,disconnect,send(event:),eventspublisher,bufferHistoricalEvents,goLive.
- Defines the contract for sending and receiving
DefaultStateSyncEngine(Actor):- Dual Transport: Uses PubSub (via Stream Video's custom event mechanism,
StreamPubSubService) for low-latency event delivery and Firestore for persistent event storage and resilience. - Event Sequencing & Deduplication: Internally uses a
StateSyncEventSequenceValidatorto ensure events are processed in order and only once, even if received from both PubSub and Firestore. - Lifecycle:
connect(): Sets up listeners for PubSub and Firestore. Entersbufferingstate.bufferHistoricalEvents(): Called byExperienceInitializerto feed historical events fetched from Firestore (deltas after a snapshot).goLive(snapshotSequenceNumber): Primes thesequenceValidatorwith the last sequence number from a snapshot, processes buffered events, and transitions tolivestate. Inlivestate, incoming events are immediately processed by thesequenceValidatorand emitted via theeventspublisher if valid.send(event): Applies event locally (viasequenceValidator), then sends via PubSub and persists to Firestore.disconnect(): Cleans up listeners and resets state.
- Dual Transport: Uses PubSub (via Stream Video's custom event mechanism,
StateSyncEventSequenceValidator(Actor):- Maintains
lastAppliedSeq: Int64andprocessedEventIds: SetQueue<String>. shouldProcessEvent(_:source:): Checks if an event is a duplicate or out of order. Updates internal state if the event is valid and new.
- Maintains
4.8. State Snapshots
ExperienceStateSnapshotProtocol&ExperienceStateSnapshot<T: Codable>:- Represents a full state of an experience at a specific
lastEventSequenceNumber. IncludesexperienceState: T,participants, andownerId. AnyExperienceStateSnapshotis a type-erased version.
- Represents a full state of an experience at a specific
StateSnapshotReader(Protocol) &FirestoreStateSnapshotReader:fetchLatestSnapshotData(): Retrieves the rawDataandsequenceNumberof the latest snapshot from Firestore (experience_sessions/{sessionId}/state_snapshots/latest).
StateSnapshotWriter(Protocol) &FirestoreStateSnapshotWriter:writeSnapshot(_: AnyExperienceStateSnapshot): Serializes the snapshot and writes it to Firestore. Only used by the experience owner.
StateSnapshotManager(Actor):- Responsibilities:
- Facade for
StateSnapshotReaderandStateSnapshotWriter. - Instantiated by
ExperienceInitializer. - If the current user is the owner, it creates a
StateSnapshotWriterand starts a periodic task (startPeriodicSaving()) to save snapshots. saveSnapshotIfNeeded(force:): Called periodically or forcefully (e.g., on deactivation) to save state. It checks iflastSavedSequenceNumberhas changed since the last save.
- Facade for
- Responsibilities:
StateSyncComponentFactory: Provides static factory methods to create snapshot and event reader/writer components.
4.9. State Sync Events
StateSyncEvent(Protocol) &BaseStateSyncEvent(Struct):- Defines the structure of an event:
id,sequenceNumber,type,senderId,data: [String: String]. sequenceNumberis crucial for ordering and is typically a timestamp combined with a client-specific component to break ties.
- Defines the structure of an event:
StateSyncEventReader(Protocol) &FirestoreStateSyncEventReader:fetchEvents(afterSequence: Int64): Fetches all events from Firestore (experience_sessions/{sessionId}/events) that have a sequence number greater than the one provided. Used to bridge the gap between a loaded snapshot and the current live state.
4.10. Session Discovery
ExperienceSession(Struct):- The Codable model for documents stored in the
experience_sessionsFirestore collection. - Fields:
id(document ID),callId,experienceId,ownerId,status(active/inactive),lastEventId,lastEventSequenceNumber.
- The Codable model for documents stored in the
ExperienceSessionDiscoverer(Class):- Listens to the
experience_sessionscollection in Firestore, filtered by the currentcallIdandstatus: .active. - When it finds an active session (that isn't the one the
ExperienceManagermight already be running), it notifies its delegate.
- Listens to the
ExperienceSessionDiscovererDelegate(Protocol):- Implemented by
ExperienceManager. discovererDidFindSession(session: ExperienceSession): Called when a joinable session is found. TheExperienceManagerthen initiates the.reactivatelifecycle action.
- Implemented by
ExperienceSessionService:- Handles CRUD operations for
ExperienceSessiondocuments in Firestore (e.g., creating a new session document, updating its status).
- Handles CRUD operations for
4.11. RTCViewModel Integration
- The
ExperienceManagerobservesRTCViewModel.$callingState.- When a call becomes
.inCall, theExperienceSessionDiscovererstarts listening for thatcallId. - If the call ends or changes state away from
.inCall, the discoverer stops, and any active experience is deactivated.
- When a call becomes
- The
RTCViewModelis passed toExperienceViewModelimplementations (e.g.,CoReadingViewModel) to allow experiences access to RTC features like participant video streams or participant information (though participant IDs are primarily managed byExperienceSystemContext). DefaultStateSyncEngineusesRTCViewModel.streamCallViewModelto get aCallobject for the Stream PubSub service.
5. Data Flow & Key Scenarios
5.1. Activating a New Experience (User A starts CoReading)
- UI Interaction: User A taps "Start Co-Reading" button in the call UI.
ExperienceManager.activateExperience(withId: "co_reading"):lifecycleState->initializing.isOwnerset totrue.- Calls
ExperienceInitializer.initialize(existingExperience: false).
ExperienceInitializer.initialize():ExperienceSessionService.create(): Createsexperience_sessions/{new_session_id}document in Firestore withstatus: .active,ownerId: UserA_ID.- Creates
DefaultStateSyncEngine, connects to PubSub (Stream) channel{new_session_id}and Firestore event subcollection. - Creates
ExperienceSystemContext(ownerId: UserA_ID, empty participants initially). - Fetches snapshot: (Returns
nilas it's a new session).snapshotSequenceNumber = 0. - Fetches deltas: (Returns empty array).
syncEngine.bufferHistoricalEvents([]). - Performs Self-Join:
- Adds User A to
systemContext.participants. - Sends
participant_joinedevent viasyncEngine.
- Adds User A to
ExperienceRegistry.getDefinition(withId: "co_reading")->CoReadingExperienceDefinition.CoReadingExperienceDefinition.createSessionActor():- Creates
CoReadingSessionActor(passes engine, systemContext, initialCoReadingState, seq 0). - Actor initializes, sets up its internal event/context listeners, loads initial library (based on User A), signals readiness via
readinessStream.
- Creates
syncEngine.goLive(snapshotSequenceNumber: 0): Engine processes empty buffer, transitions tolivestate.- Creates
StateSnapshotManager(with writer, since User A is owner). Starts periodic saving. CoReadingExperienceDefinition.createViewModel(): CreatesCoReadingViewModel(passes actor, RTCViewModel, manager).
ExperienceManager(continues frominitializecompletion):- Stores created components (actor, VM, engine, etc.).
- Subscribes to actor's
stateStreamanderrorStream. - Subscribes to
syncEngine.events(for system events). processLifecycleAction(.experienceReady)->lifecycleState->active.
- UI:
ExperienceManager.createContentView()now returnsCoReadingContainerView.
5.2. Joining an Existing Experience (User B joins User A's CoReading)
- Call Active: User B is in the same call as User A.
RTCViewModel.callingState == .inCall. ExperienceManager(User B's instance):ExperienceSessionDiscovereris listening to Firestore for active sessions in this call.
ExperienceSessionDiscoverer:- Detects the
experience_sessions/{new_session_id}document created by User A. - Calls
ExperienceManager.discovererDidFindSession(session: foundSession).
- Detects the
ExperienceManager.discovererDidFindSession():processLifecycleAction(.reactivate(experienceId: "co_reading", sessionId: foundSession.id, ownerId: foundSession.ownerId)).lifecycleState->initializing.isOwnerset tofalse(assumingfoundSession.ownerIdis User A's ID).- Calls
ExperienceInitializer.initialize(existingExperience: true).
ExperienceInitializer.initialize()(for User B):- Skips creating Firestore session document.
- Creates
DefaultStateSyncEngine, connects. - Creates
ExperienceSystemContext(ownerId fromfoundSession). - Fetches snapshot:
FirestoreStateSnapshotReader.fetchLatestSnapshotData()gets latest snapshot for{new_session_id}(might be empty if User A hasn't done anything yet, or might have some state if A interacted).CoReadingExperienceDefinition.decodeSnapshotData()decodes it.systemContext.participantsupdated from snapshot.
- Fetches deltas:
FirestoreStateSyncEventReader.fetchEvents(afterSequence: snapshot.lastEventSequenceNumber)gets events that occurred since snapshot.syncEngine.bufferHistoricalEvents(deltaEvents).
- Performs Self-Join:
- Adds User B to
systemContext.participants. - Sends
participant_joinedevent for User B.
- Adds User B to
- Creates
CoReadingSessionActor(with snapshot state, systemContext). Actor loads snapshot, signals readiness. syncEngine.goLive(snapshot.lastEventSequenceNumber): Processes buffered deltas, actor's state catches up.- Creates
StateSnapshotManager(reader-only, User B is not owner). - Creates
CoReadingViewModel.
ExperienceManager(User B):- Stores components.
- Subscribes to streams.
processLifecycleAction(.experienceReady)->lifecycleState->active.
- UI (User B): Shows
CoReadingContainerView, reflecting the state synchronized from User A.
5.3. State Synchronization (e.g., Owner selects a story in CoReading)
- UI (Owner): Owner taps a story in
CoReadingLibraryView. CoReadingViewModel.selectStory(selectedStory):- Calls
CoReadingSessionActor.selectStory(selectedStory).
- Calls
CoReadingSessionActor.handleCoReadingAction(.selectStory(selectedStory)):action.shouldSyncistrue.- Creates
story_selectedStateSyncEvent(senderId: Owner's ID). - Calls
syncEngine.send(event)(async). The event hub tracks sequence numbers internally. - Applies
coReadingReducer(state, .selectStory):newState.selectedStory = selectedStory,newState.isFullScreenMode = true. - Updates
self.state = newState. stateSubject.send(newState).
CoReadingViewModel(Owner):- Receives new state from
stateSubjectsubscription. - Updates its
@Published var state. - UI re-renders to show
CoReadingStoryView.
- Receives new state from
DefaultStateSyncEngine.send(event)(Owner):sequenceValidator.shouldProcessEvent(event, source: .local)(processes locally, updates itslastAppliedSeq).- Sends event via PubSub (Stream).
- Persists event to Firestore.
DefaultStateSyncEngine(Participant):- Receives event from PubSub (or Firestore listener if PubSub missed).
- If
engine.state == .live:sequenceValidator.shouldProcessEvent(event, source: .pubsub/.firestore): Checks for duplicates/order. If valid, updates itslastAppliedSeq.eventSubject.send(event).
ExperienceManager(Participant):- Subscribed to
engine.events. - Passes the event to
activeSessionActor.processReceivedEvent(event).
- Subscribed to
CoReadingSessionActor.processReceivedEvent(event)(Participant):- Ignores system events (sequence tracking happens in the event hub).
CoReadingAction.from(syncEvent: event)->CoReadingAction.applyRemoteStorySelection(storyId).- Calls
self.handleCoReadingAction(.applyRemoteStorySelection(storyId)).
CoReadingSessionActor.handleCoReadingAction(.applyRemoteStorySelection(storyId))(Participant):action.shouldSyncisfalse(it's a remote event).- Applies
coReadingReducer(state, .applyRemoteStorySelection):- If story found locally:
newState.selectedStoryupdated. - If story not found:
effect = .loadAndSelectStory(storyId).
- If story found locally:
- Updates
self.state, sends tostateSubject. - If effect is
.loadAndSelectStory, it triggersloadAndSelectStorySideEffect():- Loads story from
StoryService. - Adds to
state.stories. - Recursively calls
handleCoReadingAction(.applyRemoteStorySelection(storyId))to now select the loaded story.
- Loads story from
CoReadingViewModel(Participant): Receives new state, UI updates.
6. Lifecycle Management
- States (
ExperienceLifecycleState):inactive: No experience active.initializing: Setting up (fetching data, creating components).ExperienceManagershows a loading UI.active: Fully operational.degraded: Partially functional (e.g., sync lost, owner left). UI might show warnings.deactivating: Shutting down.error: A non-recoverable (or retryable) error occurred.
- Actions (
ExperienceLifecycleAction): Drive transitions. Examples:.activate: User initiates a new experience..reactivate: User joins an existing experience discovered viaExperienceSessionDiscoverer..deactivate: User or system requests shutdown..experienceReady: Internal signal fromExperienceInitializerthat all components are set up..syncConnected/.syncDisconnected: FromStateSyncEngine..participantJoined/.participantLeft: FromStateSyncEngine(system events)..ownerLeft: Special system event..error: An error occurred..retry: Attempt to re-initialize after an error.
- Reducer (
experienceLifecycleReducer):- A pure function that determines the next
ExperienceLifecycleStatebased on the current state and the dispatchedExperienceLifecycleAction. - The
ExperienceManagercalls this reducer and then handles side effects based on the transition.
- A pure function that determines the next
7. Implementing a New Shared Experience
- Define State & Actions:
- Create a
struct YourExperienceState: Codable, Equatablefor your experience's specific data. - Create an
enum YourExperienceAction: Equatablefor local and remote actions. IncludetoSyncEvent()andfrom(syncEvent:)methods.
- Create a
- Create Reducer & Effects:
- Implement
yourExperienceReducer(_ state: YourExperienceState, _ action: YourExperienceAction) -> (YourExperienceState, YourExperienceEffect). - Define
enum YourExperienceEffectif your actor needs to perform side effects (like network calls).
- Implement
- Implement
ExperienceSessionActor:- Create
class YourSessionActor: ExperienceSessionActor. - Manage
YourExperienceState. - Implement
handleYourExperienceActionwhich uses your reducer and effect handler. - Process incoming
StateSyncEvents (convert toYourExperienceAction). - Implement
stateStream(),errorStream(),readinessStream(),getState(),cleanup().
- Create
- Implement
ExperienceViewModel:- Create
class YourViewModel: ObservableObject, ExperienceViewModel. - Observe
YourSessionActor's state stream. - Provide methods for UI to dispatch actions to the actor.
- Implement
createContentView() -> AnyViewto return your experience's main SwiftUI view.
- Create
- Implement
ExperienceDefinition:- Create
struct YourExperienceDefinition: ExperienceDefinition. static let id = "your_experience_id".- Implement
createSessionActor()to return an instance ofYourSessionActor. - Implement
createViewModel()to return an instance ofYourViewModel. - Implement
decodeSnapshotData()to decodeExperienceStateSnapshot<YourExperienceState>.
- Create
- Register Definition:
- In
AppServices.setupExperiences()(or similar app setup location):ExperienceRegistry.shared.register(YourExperienceDefinition(dependencies...))
- In
- Create UI:
- Develop the SwiftUI views for your experience, which will be hosted by your
YourViewModel.
- Develop the SwiftUI views for your experience, which will be hosted by your
8. Error Handling & Edge Cases
- Initialization Timeout:
ExperienceInitializeruseswithTimeoutto prevent indefinite blocking. If timeout occurs,ExperienceManagertransitions to an error state. - Sync Disconnection:
StateSyncEngine(implicitly) orExperienceManagercan detect sync issues.ExperienceManagercan transition to adegradedstate. - Owner Leaves: If the owner of the experience leaves the call (detected via
RTCViewModelparticipant updates or a specific "owner_left" event),ExperienceManagercan transition to adegradedstate or end the experience. - Actor Errors:
ExperienceSessionActorimplementations can publish errors via theirerrorStream.ExperienceManagersubscribes and transitions to an error state. - Snapshot/Event Fetch Failures:
ExperienceInitializerhandles errors fromStateSnapshotReaderandStateSyncEventReader, leading to an error state inExperienceManager. - Event Send Failures:
StateSyncEngine.send()can return an error. Actors should handle this, potentially publishing to theirerrorStream.
9. Threading Model
ExperienceManager,ExperienceViewModels, UI: Primarily@MainActoras they interact with SwiftUI.ExperienceInitializer: Uses@MainActorfor its maininitializemethod but can dispatch work to background tasks.ExperienceSessionActor,ExperienceSystemContext,DefaultStateSyncEngine,StateSnapshotManager,StateSyncEventSequenceValidator: These are Swiftactors, ensuring thread-safe access to their mutable state. Operations on them areasyncand can be called from any context.- Service Calls (Firestore, etc.): Typically performed on background threads/tasks within actors or service methods. Callbacks or continuations then switch back to the appropriate actor or
@MainActorcontext if needed. - Combine Publishers:
receive(on: RunLoop.main)is used extensively to ensure that updates from Combine pipelines that will affect the UI are delivered on the main thread.
10. Configuration (DebugConfig)
The DebugConfig.swift file contains several flags relevant to shared experiences:
enablePubSub: Toggles the use of PubSub for real-time events. Iffalse, relies solely on Firestore.enableFirestorePersistence: Toggles saving experience state snapshots and events to Firestore.pubSubLatency: Simulates latency for PubSub messages for testing.
These flags are crucial for debugging synchronization issues and testing resilience under different conditions.