Events & real time

CQRS: Cross-App Read Models

Apps in Saaslivery never share databases. When App B needs to display data that App A owns, it doesn't query App A's tables — it listens to App A's events and builds its own local read model (a projection). App A commands, App B projects.

This is the CQRS pattern adapted for Saaslivery's modular architecture.


When to use CQRS

Use CQRS when your app needs to display or react to data owned by another app. The pattern applies any time you'd be tempted to query another app's database directly (which is never allowed).

Situation Example
App B shows a rich card based on App A's data Chat shows a call card when Calls starts a call
App B needs a counter or summary of App A's records Account shows notification counts from Tasks events
App B blocks a date range based on App A's approval Calendar blocks dates when Vacations approves leave

If you just need to link to another app's resource, use an MRN (mrn:calls:call:{id}) — no CQRS needed. CQRS is for when you need to store and render the other app's data locally.


The pattern

1. App A does its work and emits an event

The "command" side. App A mutates its own database, then emits an event describing what happened. This is the normal event emission pattern — App A doesn't know or care who's listening.

# apps/calls/apis.py — Calls app creates a call
call_id = generate_id("call")
await db.insert("calls",
    id=call_id,
    source_mrn=f"mrn:chat:chat:{chat_id}",
    status="active",
    ...)

ctx_emit(ctx, "core.calls.call.started",
    call_id=call_id,
    join_url=join_url,
    source_mrn=f"mrn:chat:chat:{chat_id}",
    creator_mrn=member_mrn,
    participant_mrns=participant_mrns)

2. App B listens to the event

The "projection" side. App B declares a scan in events.yml and registers a handler route. The event bus delivers the event as an HTTP POST webhook.

events.yml:

scans:
  - action: core.calls.call.started
    handler: /events/calls-call-started
    max_retries: 3

plugin.py:

api.POST("/events/calls-call-started", "apps.chat.apis.on_call_started")

3. App B projects the event into its own schema

The handler receives the event payload, extracts what it needs, and writes to its own tables — never App A's. This local copy is the read model.

async def on_call_started(req: Request, res: Response, ctx: Context):
    payload = (req.json or {}).get("payload", {})
    source_mrn = payload.get("source_mrn", "")
    workspace_id = payload.get("workspace_id")

    # Only project calls that originated from chat
    if not source_mrn.startswith("mrn:chat:chat:"):
        return res.out(HTTPStatus.OK, {"received": True})

    chat_id = source_mrn.split(":")[-1]
    call_id = payload.get("call_id")
    join_url = payload.get("join_url")
    creator_mrn = payload.get("creator_mrn")

    db = ScopedDB(req.app.peek("db"), workspace_id)

    # Project: create a system message in Chat's own messages table
    await db.insert("messages",
        id=generate_id("msg"),
        chat_id=chat_id,
        sender_mrn=creator_mrn,
        content=f"__call__:{call_id}:{join_url}",
        is_deleted=False)

    return res.out(HTTPStatus.OK, {"received": True})

4. App B renders from its own data

The UI reads from Chat's messages table — never from Calls' calls table. The template detects the sentinel format and renders a rich card.

{% if msg.content.startswith('__call__:') %}
  {% set call_parts = msg.content.split(':', 2) %}
  <div class="chat-call-card">
    <span class="material-symbols-outlined">video_call</span>
    <span>{{ msg.sender.name }} started a call</span>
    <a href="{{ call_parts[2] }}" target="_blank">Join call</a>
  </div>
{% endif %}

Handling state changes

When App A's resource changes state, it emits another event. App B listens and updates its local projection to match.

# Calls emits core.calls.call.ended
# Chat listens and updates its call card message

async def on_call_ended(req: Request, res: Response, ctx: Context):
    payload = (req.json or {}).get("payload", {})
    call_id = payload.get("call_id")
    duration = payload.get("duration_seconds", 0)
    # ...

    m = Table("messages")
    await (
        db.raw.UPDATE(m)
        .SET(m.content == f"__call_ended__:{call_id}:{duration}")
        .WHERE(m.workspace_id == workspace_id)
        .AND(m.chat_id == chat_id)
        .AND(m.content.LIKE(f"__call__:{call_id}:%"))
        .run()
    )

Each state transition is a separate event → separate handler → separate projection update. The local read model stays in sync without ever touching the source database.


Sentinel content format

When projecting data into an existing table (like Chat's messages), use a sentinel prefix so the UI can distinguish projected records from normal ones.

__call__:{call_id}:{join_url}          ← active call card
__call_ended__:{call_id}:{duration}    ← ended call card

Rules for sentinels:

  • Double-underscore prefix and suffix on the type name: __typename__
  • Colon-separated fields after the prefix
  • Keep the last field as the "rest" (URLs contain colons — split with a limit)
  • Templates detect sentinels with msg.content.startswith('__call__:') and render accordingly

Triggering via the event bus

Sometimes App B initiates the cross-app flow by requesting an action from App A. App B emits a request event, App A handles it, and then App A emits a result event that App B projects.

Example: Chat requests a call

Chat                    Event Bus               Calls
────                    ─────────               ─────
 │  POST /chats/:id/                              │
 │  call-requests                                  │
 │                                                 │
 │── core.chat.call     ──────────────────────────→│
 │   .requested                                    │
 │                       Calls creates call +      │
 │                       call_participants          │
 │                                                 │
 │←─────────────────── core.calls.call.started ────│
 │                                                 │
 │  Chat projects:                                 │
 │  insert system msg                              │
 │  broadcast via WS                               │

Chat never calls the Calls API directly. It emits a request, Calls picks it up, does its work, and emits a result. Chat projects the result. Both apps remain fully decoupled.


Checking if the source app is installed

Since CQRS relies on a soft dependency (enhances, not requires), the source app might not be installed. Before showing UI that depends on the projection, check installation status:

from core.apps import is_app_installed

calls_installed = await is_app_installed(ctx.db.raw, ctx.db.workspace_id, "calls")

Pass this as a template variable and conditionally render:

{% if calls_installed %}
<button @click="requestCall('video')">
  <span class="material-symbols-outlined">video_call</span>
</button>
{% endif %}

The event handlers themselves are harmless when the source app isn't installed — they're registered but never fire, because the events are never emitted.


Manifest and events.yml setup

manifest.py — declare the soft dependency and the events:

manifest = {
    "enhances": ["core.calls"],    # soft — works without it
    "events": {
        "emits": [
            "core.chat.call.requested",
        ],
        "listens": [
            "core.calls.call.started",
            "core.calls.call.ended",
        ],
    },
}

events.yml — declare the request event schema and the scans:

emits:
  - action: core.chat.call.requested
    description: A user requested a call from within a chat
    schema:
      type: object
      properties:
        chat_id: {type: string}
        participant_mrns:
          type: array
          items: {type: string}
        mode: {type: string, enum: [video, audio]}
        requested_by: {type: string}
      required: [chat_id, participant_mrns, mode, requested_by]

scans:
  - action: core.calls.call.started
    handler: /events/calls-call-started
    max_retries: 3

  - action: core.calls.call.ended
    handler: /events/calls-call-ended
    max_retries: 3

Rules

  1. Never query another app's tables. The whole point. If you need another app's data, listen to its events.
  2. Project into your own schema. The read model lives in your tables, under your 10-table budget.
  3. Handlers must be idempotent. Events are delivered at-least-once. Use upserts or existence checks — don't blindly insert.
  4. Always scope to workspace_id. The event payload includes it. Your projection must include it. No exceptions.
  5. Use enhances, not requires. CQRS integrations are soft dependencies. Your app must work without the source app installed. Handlers sit idle; UI hides the feature.
  6. Always return 200 from event handlers. Even on error. A non-200 response triggers retries, which you want for transient failures but not for permanent issues (like a missing chat). Log the error, return OK.
  7. Broadcast after projecting. If the projection affects a live UI (like chat messages), broadcast the change via WebSocket after writing to the database.
  8. MRNs are the bridge. The source app stores a reference back via MRN (source_mrn=mrn:chat:chat:{id}). This lets the projecting app know where to project the data. Without a source MRN, there's nothing to project into.

Checklist

When adding a CQRS projection to your app:

  • [ ] Source app emits event with source_mrn pointing to your resource
  • [ ] Your manifest.py lists the source app in enhances
  • [ ] Your manifest.py lists the events in listens
  • [ ] Your events.yml declares scans with handler paths and retry config
  • [ ] Your plugin.py registers POST routes for each event handler
  • [ ] Handlers create ScopedDB from the event's workspace_id
  • [ ] Handlers are idempotent (safe to replay)
  • [ ] UI checks is_app_installed() before showing dependent features
  • [ ] Template detects sentinel content format for rich rendering
  • [ ] WebSocket broadcast after projection for real-time updates