← Back to overview
Status

Date: 2026-07-16  |  Status: architecture decision; Zero viability gates pending  |  Product shape: Discord-like chat + Skool-like courses/community + live rooms, with independent multi-server administration, billing and feature gating

1. Executive Recommendation

Build Boja as a new TypeScript monorepo on PostgreSQL, with:

Recommendation in One Sentence

Boja uses Rocicorp Zero for query-driven partial sync over PostgreSQL. Offline clients read cached state, resume instantly, and durably queue message sends through the message-only outbox.

Sync architecture

Zero accepts optimistic writes during its short connecting grace period and rejects new writes in disconnected, error and needs-auth. Message sends go through an app-owned durable outbox with stable nonce/dedupe receipts, including sends initiated while fully offline. Automatic retry lasts seven days, after which the entry becomes an editable unsent draft. All other commands require connectivity.

Product stance on "offline-first"

Boja's offline contract has three parts:

  1. Offline-readable: previously synced servers, channels, messages, courses and composer state render immediately without a network round trip.
  2. Instant resume: reconnect starts from durable Zero state and catches up without clearing valid local data or showing a blocking loader.
  3. Durable message sends: the message-only outbox persists and retries complete send commands with authoritative server receipts. Every other domain command requires connectivity, including lesson completion and rewards.

2. What TRW Teaches Us

TRW has solved almost every product feature, but its accumulated architecture shows what a greenfield system must avoid.

Chat primitives worth preserving

Platform primitives worth preserving

What TRW makes expensive — The state sandwich

TRW chat currently crosses:

TanStack infinite cache
  → MobX entity maps
  → Jotai cache mirror
  → derived atoms
  → imperative ChatViewRuntime
  → React virtualized list

A message page is written into more than one state system. Resets must clear multiple layers in the correct order. The rewrite must have one persisted reactive product store: the sync engine's local database.

Hand-built partial sync

TRW already contains many sync-engine components: ready snapshots, replay cursors, a bounded event history, Redis stream replay, client entity maps, cache hydration, per-channel subscriptions, and recovery modes. The problem is binary validity: replay succeeds or caches are invalidated.


3. Target System Architecture

┌──────────────────────────┐        ┌──────────────────────────┐
│ Expo / React Native      │        │ React DOM web app        │
│ native components       │        │ owned Base UI components │
└────────────┬─────────────┘        └────────────┬─────────────┘
             └──────── shared contracts/domain/tokens ────────┘
                                  │
             Zero store + message-only durable local outbox
                                  │
┌─────────────────────────────────▼─────────────────────────────────┐
│ Elysia application/API plane self-hosted                              │
│ Better Auth identity/session │ Zero query/mutate │ OpenAPI/admin │
│ shared pure-TS policy        │ files control plane │ billing service│
│ generated MCP/CLI            │ bojastack        │ search/history │
└──────────────┬───────────────────┬───────────────────┬────────────┘
               │                   │                   │
     ┌─────────▼─────────┐  ┌──────▼────────┐  ┌──────▼────────────┐
     │ PostgreSQL self-hosted │  │ pg-boss/self-hosted infrastructure  │  │ Ephemeral WS plane│
     │ source of truth   │  │ workers       │  │ presence + typing │
     │ server_id tenancy │  │ Graphile f/b  │  │ Redis/Valkey TTL  │
     └─────────┬─────────┘  └──────┬────────┘  └───────────────────┘
               │ outbox             │ scan/process/deliver
      ┌────────▼─────────────────────▼──────────────────────────────┐
      │ Cloudflare enterprise plane                               │
      │ private R2 │ Images/Workers/CDN │ Stream broadcast + VOD  │
      └────────────────────────────────────────────────────────────┘

Interactive media: clients ↔ self-hosted LiveKit self-hosted;
LiveKit Egress → RTMPS → Cloudflare Stream for broadcast/recording.

Architectural invariants

  1. PostgreSQL is authoritative for product state.
  2. Zero is the only app-wide persisted reactive entity store; do not build a second replay/entity sync protocol.
  3. React components do not implement product authorization.
  4. Shared domain modules contain no React, DOM, RN, Zero, DB or network imports.
  5. Client capability decisions improve UX; server decisions enforce security.
  6. External billing is never called on login, sync-read or mutation authorization paths.
  7. Side effects are emitted transactionally through an outbox.
  8. Every retryable client command carries a stable idempotency key.
  9. Cold history is bounded and queryable; no client syncs an entire channel's lifetime.
  10. Ordinary network reconnect never clears valid local data. Destructive reset is an explicit, instrumented recovery path and it never deletes app-owned drafts or outbox storage.

4. TypeScript Monorepo and Shared Business Logic

Proposed structure

apps/
  web/
  native/

packages/
  contracts/               # schemas, IDs, DTOs, errors
  domain/
    permissions/
    entitlements/
    chat/
    servers/
    courses/
    streams/
    billing/
  zero-schema/             # Zero/Postgres schema and relationships
  zero-queries/            # named shared query definitions
  zero-mutators/           # shared optimistic mutation core
  app-runtime/
  react-bindings/
  design-tokens/           # shared tokens/contracts; no rendering
  ui-web/                  # React DOM components
  ui-native/               # React Native components
  platform/

services/
  api/
  workers/
  media/

The spiritual successor to client.js

TRW's big client is valuable because it gives developers a coherent vocabulary. Preserve that ergonomics:

app.chat.sendMessage(...)
app.chat.retryMessage(...)
app.servers.createChannel(...)
app.courses.completeLesson(...)
app.streams.join(...)
app.billing.openPortal(...)

But do not preserve class-owned entity maps or observability.

export class AppRuntime {
  readonly chat: ChatCommands;
  readonly servers: ServerCommands;
  readonly courses: CourseCommands;
  readonly streams: StreamCommands;
  readonly billing: BillingCommands;

  constructor(
    readonly sync: AppSyncClient,
    readonly platform: PlatformCapabilities,
    readonly actor: ActorSession,
  ) {
    this.chat = createChatCommands(sync, platform);
    this.servers = createServerCommands(sync);
    this.courses = createCourseCommands(sync);
    this.streams = createStreamCommands(sync, platform);
    this.billing = createBillingCommands(platform);
  }
}

AppRuntime owns lifecycle and capabilities. It does not own Map<MessageId, Message>.

Pure domain policy

export const calculateChannelCapabilities = (
  input: ChannelCapabilityInput,
): CapabilityDecisionSet => {
  // deterministic, explicit inputs
};

The same function runs: in Web, in React Native, during optimistic mutation prediction, in the authoritative server mutation, and in fixture/property tests. The server supplies trusted identity and authoritative rows. The client result is never a security boundary.


5. Data Model

Use normalized Postgres tables. Every tenant-owned table carries server_id unless it is global or belongs through an unambiguous parent.

Identity and tenancy

users
accounts / sessions                 # Better Auth-owned or mapped
servers
server_memberships                  # PK (server_id, user_id)
roles                               # server-owned rows
membership_roles                    # PK (server_id, user_id, role_id)
channels
channel_role_overrides
channel_member_overrides            # optional; add only if product requires

Chat

conversations/channels
messages
message_revisions                   # optional audit/edit history
message_reactions                   # PK (message_id, user_id, emoji)
thread_subscriptions
channel_read_states                 # PK (channel_id, user_id)
message_mentions                    # optional normalized mention index
attachments
message_attachments
message_send_receipts               # server idempotency/application receipts

Message identity and ordering

Use client-generated UUIDv7 or ULID IDs and a stable ordered cursor. Keep id and sort_id conceptually separate even if they start equal.

Required unique constraint:

UNIQUE (channel_id, client_nonce)

This is non-negotiable. Client retries must not duplicate sends.

Replies and threads

Read state

One row per (user_id, channel_id):

last_read_sort_id
last_seen_sort_id
last_mention_sort_id or mention counters
updated_at

No per-user JSON map and no sharding workaround. Local updates should be immediate and coalesced naturally by the sync/write path.

Courses

course_spaces
course_sections
courses
course_modules
lessons
lesson_assets
course_enrollments
lesson_progress
course_progress
course_prerequisites
completion_rewards
content_versions / drafts

Keep TRW's strongest loop:

lesson/course completion
  → completion reward
  → role or entitlement grant
  → channel/course access changes

Model rewards as typed rows, not arbitrary JSON side effects.

Billing and entitlements

billing_events
payment_intents / provider_transactions
billing_subscriptions / subscription_items
ledger_accounts / journal_entries / postings
refunds / disputes
payouts / affiliate_commissions / reserve_movements
products
plans
plan_entitlements
entitlements / entitlement_grants
usage_counters / quota_periods

Separate: Permission (may this actor perform this action?), Entitlement (has this server/user acquired this product capability?), Feature rollout (should this code path be enabled for this cohort?). Do not collapse these into one flag system.

Live rooms

live_rooms
live_room_sessions
live_room_participants
live_room_roles
live_room_recordings
live_room_events

A live room references its ordinary chat channel. Provider IDs remain behind an adapter and never leak into domain field names.


6. Permission and Entitlement System

Capability manifest

export type Capability =
  | "server.manage"
  | "channel.view"
  | "channel.manage"
  | "message.send"
  | "message.moderate"
  | "thread.create"
  | "course.view"
  | "course.manage"
  | "stream.join"
  | "stream.start"
  | "server.billing.manage";

Permission authority is stored as human-readable capability_key + effect rows, with effect equal to allow or deny. Roles remain the primary in-server grant mechanism and are applied in rank order.

Evaluation order

  1. Validate global account state.
  2. Validate server membership.
  3. Start from server.default_permissions.
  4. Apply each assigned server-role capability/effect set in rank order. Reordering roles can intentionally change the result; admin UI must warn before such changes.
  5. Apply applicable user-attribute capability/effect rows in configured order.
  6. Apply channel default_permissions, then channel role overrides in rank order, then channel attribute overrides.
  7. Apply the timeout/moderation mask (ALLOW_IN_TIMEOUT) after ordinary grants.
  8. Apply the narrowly defined owner/privileged bypass. It never bypasses account disable, ban, server suspension, legal hold, or capabilities excluded by typed bypass policy.
  9. Apply required product entitlements and quotas after permission grants; permissions cannot manufacture a paid entitlement.
  10. Return the result, user-safe reason and a structured server-only explanation trace.
export type CapabilityDecision = {
  allowed: boolean;
  reason:
    | "allowed"
    | "not-member"
    | "role-denied"
    | "channel-denied"
    | "missing-entitlement"
    | "timed-out"
    | "server-suspended";
};

Sync visibility

Permission enforcement happens twice: query definitions only sync rows the actor may currently read; mutators independently authorize every write against authoritative rows. When access is revoked, the sync engine must remove inaccessible rows from the local replica. The spike must explicitly test this; merely hiding them in UI is not sufficient.


7. Chat Architecture: The Hardest Feature

7.1 Message-window model

The local database may contain several disjoint windows for a channel: recent live tail, a window around the first unread, a window around a selected permalink, and older windows loaded by scrolling. The UI window is a query over stored rows, not the query cache itself.

Coverage is derived from explicit Zero query facts:

Zero-exposed facts
  query identity and arguments
  query completion/status
  returned rows and boundary cursors

App-owned local facts
  navigation intent
  persisted anchor message/cursor and pixel offset
  transient coverage derived from the retained query result

7.2 Query primitives

recent(channelId, limit)
before(channelId, cursor, limit)
after(channelId, cursor, limit)
around(channelId, messageId or sortId, before, after)
byIds(messageIds)
threadRoot(threadId)

The around operation must preserve TRW's two-sided stitching behavior and support a missing/deleted hot anchor through a sort cursor. Archived rows are fetched through an authorized read-only history API into a distinct transient HistoryWindow; they are never promoted into fake Zero rows.

7.3 Landing intents

type ChannelEntryIntent =
  | { type: "present" }
  | { type: "first-unread"; sortId: SortId }
  | { type: "selected-message"; messageId: MessageId };

Do not bury this choice in component effects. Route/navigation code computes the intent; the message feature executes it.

7.4 Present-tail semantics

7.5 Virtualization and anchoring

Use platform-appropriate list implementations behind a shared feature interface. Share: item grouping, window/anchor model, entry intent, message rendering policy, follow-present state machine. Keep platform-specific: measurement, scroll commands, native keyboard handling, browser DOM behavior.

Required measures: reserve attachment dimensions from metadata before media loads; reserve embed space or animate controlled changes; restore using (anchorMessageId, offsetWithinItem), not raw scroll pixels.

7.6 React <Activity/>

Retain a bounded warm-channel pool, but use it only to preserve expensive presentation state: scroll anchor, text selection, composer focus, native/web list measurements. Do not keep channels alive because data would otherwise disappear. Data lives in the local sync database.

7.7 Sends and offline outbox

Every send includes:

message_id       # client-generated
client_nonce     # stable across retries
channel_id
content
reply_to_id
attachment_ids
created_at_client

States:

draft → queued → sending → acknowledged
                       ↘ retrying
                       ↘ rejected

For message send:

  1. Write the complete, schema-versioned command to encrypted local durable storage before invoking Zero.
  2. Render a local pending projection.
  3. On reconnect, submit through the authoritative Zero mutator. Never acknowledge or delete the outbox entry because the optimistic mutation resolved locally.
  4. In the same PostgreSQL transaction as the message, insert a message_send_receipts row with unique (channel_id, client_nonce).
  5. Acknowledge only after observing the authoritative receipt or canonical message. Keep acknowledged entries through a short compaction window.
  6. After any crash, retry. If the server already committed, the uniqueness conflict returns the existing canonical message as success.
  7. Show queued on this device while pending. Retry automatically for seven days, then convert the command to an editable unsent draft.

7.8 Attachments

Attachments are uploaded separately before the message command becomes sendable. Draft stores a stable local asset reference; upload waits for connectivity; message remains waiting-for-attachments; once uploads resolve to server attachment IDs, enqueue/send the message. Do not embed large blobs in the sync database.

7.9 Search

Search is a server feature, not a reason to sync all history. PostgreSQL full-text may be enough initially. Results return message IDs and context metadata. Selecting a hot result invokes Zero around(...); selecting an archived result opens an authorized read-only HistoryWindow.


8. Zero Viability Gates and Operating Requirements

Rocicorp Zero has been generally available and fully supported since March 2026; 1.0 is the first stable release. The following delivery risks are mandatory viability gates:

PostgreSQL foundation

Boja is a greenfield PostgreSQL system. Reasons: memberships, roles, channel overrides, entitlements, reactions and course progression are relational; transactions and uniqueness constraints are central to chat correctness; logical replication supplies Zero's authoritative change stream; Better Auth has a natural Postgres path; shared-schema server_id tenancy supports consistent migrations, authorization and cross-server users.


9. Authentication

Better Auth is the selected identity/session infrastructure, subject to mandatory security, upgrade and physical-device mobile integration release gates.

Better Auth owns

Application owns

Better Auth Organization plugin rows are not canonical for product servers, memberships, invitations, owners or roles. App tables own those records and their lifecycle.

Startup behavior

  1. Open cached Better Auth identity and local sync database concurrently.
  2. Render the last navigation state immediately.
  3. Revalidate auth in the background.
  4. Refresh sync credentials without recreating the local database for the same user.
  5. On account switch, isolate by user ID and apply an explicit local-data deletion policy.

Never wrap the application in a global sessionPending spinner.

Security posture


10. Billing, Feature and Server Gating

Billing service boundary

Billing runs on NMI + value.io with the custom TSYS processor deal through an in-repo service. The service owns tokenized payment methods, payment/refund/dispute state machines, subscriptions, the versioned hu2-baseline dunning policy, 27-day account-updater eligibility, seller payouts, affiliate commissions/reserves, verified billing events, reconciliation and the immutable double-entry ledger. Boja is the default merchant of record.

Flow

NMI / value.io callback or reconciled billing source event
  → signature/authenticity verification
  → idempotent billing_events insert (source event id)
  → payment/commercial state + balanced ledger posting
  → subscription projection + transactional outbox
  → entitlement grants/revocations
  → PostgreSQL commit
  → Zero updates affected clients

No login or message query calls NMI/value.io.

Typed entitlement examples

type EntitlementKey =
  | "server.max-members"
  | "server.max-channels"
  | "course.premium"
  | "stream.host"
  | "storage.bytes"
  | "custom-branding";

Independent server billing

Support both: (1) platform subscription paid by the server owner, and (2) member access products sold by a server/community. These are different ledgers and entitlement flows. Do not force both into one subscription_status field.


11. Courses and Learning Community

Course catalog and progress are locally persisted and instant on reopen. Text and metadata remain available offline when previously synced. Video is streamed/downloaded according to product policy. Lesson completion and rewards are online-authoritative.

Courses may: require a role or entitlement, award roles or entitlements, unlock channels, trigger certificates, schedule cohort/live-room sessions. All of these are typed domain effects processed transactionally or through the outbox — not arbitrary client-side automation.


12. Live Streams and Calls

Use one product abstraction: Live Room. Room modes may include interactive call, stage/webinar, one-to-many broadcast, and audio room.

Use self-hosted LiveKit self-hosted for native and web SDKs, low-latency interactive rooms, participant permissions, and recording/egress options. Cloudflare Stream owns recorded broadcast and VOD. For broadcast mode, LiveKit Egress composites the room and sends RTMPS to Stream; the audience watches Stream HLS/DASH while speakers remain in LiveKit.

Sync only: room metadata, schedule and status, hosts and access policy, recording metadata, and chat channel relationship. Do not sync media packets, viewer heartbeats or high-frequency presence through the durable product database.


13. Instant-Start and Resumability Contract

Cold launch with local data

Reconnect

Schema incompatibility, client-store corruption, account switch, publication change or an engine-requested reset may invoke an explicit instrumented destructive recovery path. That path never deletes the separate app-owned drafts or message outbox.

Fresh install hydration priorities

  1. Auth/session.
  2. Server list and active server metadata.
  3. Active channel tail and unread state.
  4. Remaining navigation metadata.
  5. Media and secondary course content.

14. Delivery Stages

Stage 0 — Architecture spike

Do not build the full product before proving the hard part. Run the following narrow vertical slice as Zero viability gates:

Seed enough data to exercise millions-row query behavior without syncing it all.

Required benchmark evidence

Stage 1 — Chat foundation

Stage 2 — Platform foundation

Stage 3 — Live rooms

Stage 4 — Scale and hardening


15. Acceptance Criteria

Architecture

Chat

Authorization

Resumability


16. Major Risks and Mitigations

RiskMitigation
Zero has no general disconnected writesKeep connectivity-required commands online-only; use the narrow app-owned write-ahead outbox for message sends
Zero maturity/operational riskProduction-like spike, pinned versions, failure drills, Zero-specific packages, Postgres authority, explicit export and documented recovery/migration boundaries
Permission-filtered sync leaks revoked rowsExplicit revocation tests; inspect local DB after access removal
Shared logic becomes a god packageDomain-owned packages, explicit inputs, no framework dependencies
AppRuntime becomes MobX Client 2.0Ban entity maps and observability in runtime; lifecycle/commands only
Universal UI harms native qualityKeep ui-web and ui-native separate; share only tokens, contracts, semantic vocabulary and domain behavior
Chat virtualization remains jankyDedicated performance milestone and real-device test corpus before feature expansion
Better Auth plugin churn/securityMinimal plugin surface, lockfile audits, aggressive patching, independent auth tests
Billing internals leak into authorization pathsInternal idempotent subscription and entitlement projections
Syncable dataset grows without boundMeasure both client working sets and the full published zero-cache replica; enforce a physical hot/archive publication boundary and authorized read-only history API

17. Final Decision

Recommended stack to take into the spike

Language/runtime:     TypeScript + Bun-compatible server runtime
Monorepo:             Bun workspaces
Infrastructure:       self-hosted production cluster + Cloudflare enterprise plane; other SaaS prototype-only except the tax API
Database/tenancy:     PostgreSQL source of truth; shared schema with server_id
Client:               Two frontends — Expo/React Native (native) + React DOM (web)
UI/theming:           @app/design-tokens (DTCG + Terrazzo) → Tailwind v4/DaisyUI-vocab themes (web) + Unistyles 3 typed themes (native)
Sync:                 Rocicorp Zero
Offline:              Cached reads + instant resume + durable message-send outbox only
Authentication:       Better Auth for identity/session only; Boja owns servers, memberships, invitations and roles
Authorization:        Shared pure-TS layered calculator over capability_key/effect rows; optional derived bitset cache
API/surfaces:         Elysia OpenAPI; threads-as-channels; webhook-first service-principal bots; generated MCP/CLI; bojastack
Ephemeral realtime:   Bun-compatible WebSocket gateway + Redis/Valkey TTL for presence/typing
Billing:              In-repo NMI + value.io service; double-entry ledger; payouts/affiliates/reserves
Tax:                  Typed adapter to Anrok/Avalara candidate, vendor pending finance review
Files:                Private R2 + Cloudflare Images/Workers/CDN + thin TS control plane
Video/live:           Cloudflare Stream broadcast/VOD + self-hosted LiveKit self-hosted + Egress→Stream handoff
Background work:      Transactional outbox + pg-boss pending Bun spike; Graphile Worker fallback
Search:               Postgres initially; dedicated engine when justified

Recommendation confidence

The Design Principle to Retain

Zero owns synced reactive data; the app-owned outbox owns pending message-send commands. Shared domain modules own meaning. AppRuntime owns commands and lifecycle. React owns rendering. PostgreSQL and the server own authority.


18. Research Documents

  1. 01-trw-chat-spec.md — TRW message model, pagination, jump, scrolling, outbox and lessons.
  2. 02-trw-platform-spec.md — realtime, permissions, courses, streams, auth and billing.
  3. 03-sync-engine-landscape.md — sync-engine landscape and historical alternatives.
  4. 04-better-auth-evaluation.md — Better Auth fit, boundaries and security caveats.
  5. 05-shared-typescript-domain-architecture.md — shared business-logic and AppRuntime design.
  6. 06-ui-theming-frameworks.md — DTCG/Terrazzo token pipeline and separate web/native implementations.