Skip to content

System Architecture

Tasuku is an offline-first Progressive Web App on the Cloudflare edge. The server-side D1 database is the single source of truth; the client is a cache plus an outbox, never a second source of truth (Product Brief §1.1, constitution §7.14).

At the highest level Tasuku is one system used by a single human actor. Clerk is the only external dependency on the critical path (the authentication boundary); the rest are off-path — Sentry for observability, the Web Push services that deliver closed-app notifications, and an external mailbox that receives transactional support email. Everything else is internal to the Cloudflare deployment (see Infrastructure).

C4Context
  title Tasuku — System Context
  Person(user, "User", "Manages tasks across devices, online or offline")
  System(tasuku, "Tasuku PWA", "Offline-first task manager on the Cloudflare edge")
  System_Ext(clerk, "Clerk", "Identity provider — the single auth boundary")
  System_Ext(sentry, "Sentry", "Error / trace / log / metric monitoring (PII-scrubbed, ADR-018)")
  System_Ext(push, "Web Push services", "Browser push endpoints — closed-app notifications (ADR-016)")
  System_Ext(mail, "Support mailbox", "External address receiving transactional team email (ADR-017)")
  Rel(user, tasuku, "Manages lists & tasks", "HTTPS / WebSocket")
  Rel(user, clerk, "Signs in", "HTTPS")
  Rel(tasuku, clerk, "Verifies session", "HTTPS")
  Rel(tasuku, sentry, "Reports errors & telemetry", "HTTPS")
  Rel(tasuku, push, "Sends notifications", "HTTPS / VAPID")
  Rel(tasuku, mail, "Sends support email", "Email Routing")
  UpdateRelStyle(user, tasuku, $offsetY="-30")
  UpdateRelStyle(tasuku, sentry, $offsetX="-40")

Zooming one level in, the containers — the separately runnable/deployable units — split across the user’s device (the browser PWA and the tasuku command-line client) and the Cloudflare edge. They are joined by HTTPS for the request/write path and a WebSocket for real-time signals. Each container is independently replaceable; what stays stable between them is the contract — the versioned /v1 API and the tiny SyncSignal. Both first-party clients (web PWA and CLI) go through the same /v1 API and the same Clerk auth boundary (S14, ADR-004/005/014).

C4Container
  title Tasuku — Container View
  Person(user, "User", "Manages tasks across devices, online or offline")

  Container_Boundary(device, "User's device — browser") {
    Container(spa, "PWA Client", "Astro + React islands, TypeScript", "Static pages hydrate small interactive islands; reads, search and filtering run fully offline")
    Container(sw, "Service Worker", "Workbox", "Precaches the app shell and assets for offline launch")
    ContainerDb(dexie, "Client Store", "Dexie / IndexedDB", "Read cache plus the mutation outbox")
  }

  Container_Boundary(terminal, "User's device — terminal") {
    Container(cli, "tasuku CLI", "Node, TypeScript (npm)", "Scriptable first-class client; OAuth PKCE; same /v1 API; no local data")
  }

  Container_Boundary(edge, "Cloudflare edge") {
    Container(app, "Edge Application", "Cloudflare Worker — Astro SSR + Hono API /v1", "Serves the app shell and the versioned API; the single Clerk auth boundary; src/worker.ts")
    Container(do, "SyncInbox", "Durable Object — hibernatable WebSockets", "Per-user inbox that pushes change signals")
    ContainerDb(d1, "Database", "Cloudflare D1 (SQLite) + Drizzle", "Relational source of truth")
    ContainerDb(kv, "Session Store", "Cloudflare KV", "Astro server sessions")
    ContainerDb(r2, "Attachment Store", "Cloudflare R2", "Private bucket for task file bytes (ADR-019)")
    Container(cron, "Cron Trigger", "scheduled()", "Due-reminder scan + daily completed-task cleanup")
  }

  System_Ext(clerk, "Clerk", "Identity provider — the single auth boundary")
  System_Ext(sentry, "Sentry", "Monitoring (ADR-018)")
  System_Ext(push, "Web Push services", "Closed-app notifications (ADR-016)")
  System_Ext(mail, "Support mailbox", "Transactional email (ADR-017)")

  Rel(user, spa, "Uses", "HTTPS")
  Rel(user, cli, "Runs commands", "terminal")
  Rel(user, clerk, "Signs in", "HTTPS")
  Rel(cli, app, "Calls the API with a bearer token", "HTTPS /api/v1")
  Rel(cli, clerk, "OAuth Auth-Code + PKCE", "HTTPS")
  Rel(sw, spa, "Precaches shell & assets", "Cache Storage")
  Rel(spa, dexie, "Caches reads / queues writes", "IndexedDB")
  Rel(spa, app, "Loads shell, replays outbox, delta reads", "HTTPS /api/v1")
  Rel(spa, do, "Receives change signals", "WSS")
  Rel(app, clerk, "Verifies session", "HTTPS")
  Rel(app, d1, "Reads & writes", "Drizzle")
  Rel(app, kv, "Stores sessions", "KV")
  Rel(app, r2, "Streams attachment bytes", "R2 binding")
  Rel(app, do, "Fans out signals off the request path", "RPC / waitUntil")
  Rel(app, mail, "Sends support email", "send_email binding")
  Rel(cron, d1, "Scans & sweeps", "Drizzle")
  Rel(cron, push, "Sends reminders", "HTTPS / VAPID")
  Rel(app, push, "Sends assignment pushes", "HTTPS / VAPID")
  Rel(spa, sentry, "Reports errors & telemetry", "HTTPS")
  Rel(app, sentry, "Reports errors & telemetry", "HTTPS")
  UpdateRelStyle(spa, do, $offsetY="-40")
  UpdateRelStyle(app, do, $offsetX="-40", $offsetY="-10")
  • PWA Client — the Astro pages and React islands that render the UI; all reads, search, and filtering resolve against the local store, so the app is usable with no network.
  • Service Worker — a Workbox precache that lets the app shell launch offline (ADR-009).
  • Client Store (Dexie) — the only client store: a read cache of the user’s data plus the write outbox.
  • tasuku CLI — the user-facing command-line client (a separate npm package); a thin, scriptable consumer of the same /v1 API with no business logic and no local data store. It authenticates with OAuth 2.0 Authorization Code + PKCE (Clerk as IdP) and presents a bearer token verified at the same boundary as a web session (S14; ADR-004/005/014). See the CLI guide.
  • Edge Application — Astro SSR and the Hono /v1 API in a single Worker (src/worker.ts); the only container that talks to D1 and the only place Clerk sessions are verified.
  • SyncInbox — the per-user Durable Object that holds hibernatable WebSockets and pushes change signals (ADR-011).
  • Database (D1) — the relational source of truth, reached only through Drizzle repositories.
  • Session Store (KV) — Cloudflare KV backing Astro’s server-side sessions.
  • Attachment Store (R2) — a private Cloudflare R2 bucket holding task file bytes; reached only through the Worker (no public access), keyed by an opaque storageKey (ADR-019).
  • Cron Trigger — the Worker’s scheduled() entry point: a ~1-minute due-reminder scan plus the once-daily completed-task cleanup, both writing D1 through the same repositories (ADR-015, spec 038).

Clerk is the only critical-path external dependency. The off-path externals — Sentry (monitoring), the Web Push services (notifications), and the support mailbox (email) — are reached over HTTPS / Email Routing, never on the read/write critical path. For where each container is actually deployed and how the Worker is bound to D1, KV, R2, and the Durable Object, see Infrastructure.

  • Astro app + React islands — static Astro pages hydrate small interactive React islands (src/islands, src/pages, src/layouts). Interactivity is opt-in per island to keep the JS budget small (constitution §8.1–§8.2).
  • Service worker (Workbox) — built post-build over dist/client and paired with a prerendered app-shell route; precaches the shell and assets for offline launch (ADR-009, constitution §7.14.2).
  • Client store (Dexie/IndexedDB) — the sole client store: a read cache of the user’s data plus the mutation outbox. Reads, search, and filtering work offline; writes are optimistic and queued (Product Brief §1.1).
  • Worker API (Hono) — a Cloudflare Worker serving the HTTP API under /api, built by createApp() in src/api/index.ts. Contract-first via @hono/zod-openapi, versioned under /v1 (ADR-001, ADR-003).
  • D1 + Drizzle — the relational source of truth; typed access through repository functions in src/db/repositories (constitution §7.10).
  • SyncInbox Durable Object — the project’s first Durable Object; a per-user inbox holding hibernatable WebSockets that pushes tiny change signals (ADR-011, src/durable-objects/sync-inbox.ts).
  • Clerk — the single authentication boundary; the Worker derives the user identity from the verified session (constitution §7.12).
  • Scheduled jobs (Cron Trigger) — the Worker’s scheduled() handler runs a ~1-minute due-reminder scan (ADR-015, src/worker.tssrc/lib/server/notify.ts) and, behind a once-daily wall-clock gate, the auto-deletion sweep of Tasks completed over 30 days ago (spec 038, src/lib/server/completed-cleanup.ts) — both independent of any open client. Stateless per the isolate model; reads/writes D1 through repositories; the reminder scan no-ops when Web Push is unconfigured.
  • Web Push delivery — closed-app OS notifications over the Web Push protocol with VAPID + aes128gcm on WebCrypto (ADR-016, src/lib/server/webpush.ts); fan-out reuses the same accessible() set as sync. Reminders fire from the cron; assignments fire on the write path via waitUntil.
  • Outbound email — transactional team email via Cloudflare Email Routing’s send_email binding, pinned to a verified destination (ADR-017, src/api/lib/support-send.ts). Online-only, free-tier, no API-key secret.
  • Observability (Sentry) — error, performance-trace, log, and metric monitoring over the official SDKs, behind a vendor-neutral facade (src/lib/obs/report.ts) with two adapters (Worker + browser); PII-scrubbed (ADR-018). See Observability.

One level deeper still — a component view that breaks the edge application into its internal layers (SSR, the Hono API, and the Drizzle repositories). Solid arrows are the request/write path, the dashed arrow is the real-time signal that the client turns back into a delta read:

flowchart LR
  user([User])
  subgraph browser["Browser (PWA)"]
    UI["Astro pages + React islands<br/>src/islands, src/pages"]
    SW["Service worker<br/>Workbox precache"]
    DX[("Dexie / IndexedDB<br/>read cache + outbox")]
  end
  subgraph worker["Cloudflare Worker — src/worker.ts"]
    SSR["Astro SSR"]
    API["Hono API /api/v1<br/>createApp()"]
    SCHED["scheduled()<br/>reminder scan + completed cleanup"]
    REPO["Drizzle repositories<br/>src/db/repositories"]
    DO[["SyncInbox<br/>Durable Object"]]
  end
  D1[("D1 — source of truth")]
  R2[("R2 — attachment bytes")]
  clerk{{"Clerk"}}
  cron(["Cron Trigger"])
  push{{"Web Push (VAPID)"}}

  user --> UI
  SW -. precache shell .-> UI
  UI -->|optimistic write| DX
  UI -->|read / search / filter| DX
  DX -->|replay outbox| API
  UI -->|"delta read ?since"| API
  UI -. initial load .-> SSR
  API -->|authorize| clerk
  API --> REPO --> D1
  API -->|"attachment bytes (stream)"| R2
  API -->|"fan-out signal (waitUntil)"| DO
  DO -. "SyncSignal (WS)" .-> UI
  cron --> SCHED --> REPO
  SCHED -. "reminder push" .-> push
  API -. "assignment push" .-> push

A user action follows the offline-first path (Product Brief §5.2, “Sharing and sync rules”):

  1. Optimistic local write — the island applies the change to the Dexie cache and enqueues it in the outbox; the UI updates immediately.
  2. Replay to the server — when online, the outbox replays the mutation to the Worker API; an idempotency key lets the server deduplicate replays (idempotencyKeys table, src/db/schema.ts).
  3. Authoritative write — the Worker validates (Zod), authorizes (Clerk session + the ownership/membership rule), and writes to D1 through a repository function.
  4. Fan-out signal — after a successful write, the Worker computes the affected users (owner ∪ active members) and, off the request path (waitUntil), RPC-calls each user’s SyncInbox to broadcast a SyncSignal naming the changed surface (ADR-011; affectedUsers()/notifyList() in src/api/lib/notify.ts).
  5. Client reacts — a connected client runs its existing ?since delta read for that surface and reconciles into Dexie. The WebSocket replaces the polling timer, not the data path; while disconnected, a slow polling fallback + a catch-up read on reconnect preserve convergence (ADR-006 → ADR-011).

The same five steps, end to end across the origin client, the server, and a second connected client:

sequenceDiagram
  actor User
  participant Island as React island
  participant Dexie as Dexie (cache + outbox)
  participant API as Worker API (Hono)
  participant D1 as D1
  participant DO as SyncInbox DO
  participant Peer as Other device

  User->>Island: edit task
  Island->>Dexie: optimistic write + enqueue outbox
  Island-->>User: UI updates immediately
  Note over Island,API: when online
  Dexie->>API: replay mutation (+ idempotency key)
  API->>API: validate (Zod) + authorize (Clerk)
  API->>D1: write via repository
  API-->>Dexie: ack (deduped on replay)
  API-)DO: notify affected users (waitUntil)
  DO-)Peer: SyncSignal "surface changed"
  Peer->>API: delta read ?since
  API-->>Peer: changed rows
  Peer->>Peer: reconcile into Dexie cache

For the connection lifecycle, coalescing, and the polling fallback in depth, see Sync & Real-Time Architecture.

Real-time propagation began as periodic polling for the MVP (ADR-006) and was upgraded to signal-only WebSocket push over the SyncInbox Durable Object (ADR-011, constitution §7.14.6). Polling remains the documented fallback. Offline-first is unchanged throughout: the server stays the source of truth and the client degrades to cached reads + outbox replay.

Concrete code paths are covered in the module guides: Worker API & Clerk Auth, Sync-Core Reconcilers, SyncInbox Durable Object, LiveConnection WS Client, and Offline Outbox & Checklists.