Skip to content

DB Schema & Notify-Affected

This subsystem covers the relational source of truth (src/db/schema.ts) and the server-side fan-out that decides which users a write should notify (src/api/lib/notify.ts).

The notify helpers read the schema to compute recipients, then dispatch signals off the response path to each user’s inbox:

flowchart LR
  ROUTES["write routes"] --> NL["notifyList()"]
  ROUTES --> NU["notifyUsers()"]
  ROUTES --> NM["notifyMembership()"]
  NL --> AU["affectedUsers(db, listId)"]
  NM --> AU
  AU --> SCHEMA[("D1: lists + listMembers<br/>src/db/schema.ts")]
  NL --> DISP["dispatch()"]
  NU --> DISP
  NM --> DISP
  DISP --> ORP["offResponsePath()<br/>waitUntil"]
  ORP --> FO["fanOut() — env.SYNC_INBOX"]
  FO --> DO[["SyncInbox<br/>Durable Object"]]

The Drizzle tables: users, lists, groups, tasks, steps, myDayEntries, listMembers, idempotencyKeys. See the Domain Model for columns and rules. Notable cross-cutting columns: rank (fractional ordering), assigneeId (task assignment, spec 012), and per-viewer order/group on listMembers (per-member List placement, spec 011).

After a successful write, the Worker must signal the right users (ADR-011). src/api/lib/notify.ts:

  • affectedUsers(db, listId) — returns the owner ∪ active (non-tombstoned) members of a list: the existing accessible() set. This is the authorization-aligned recipient set.
  • notifyList(c, listId, surfaces) — fan a signal to everyone with access to a list, for the given surface tags.
  • notifyUsers(c, userIds, surfaces) — signal a specific set of users (e.g. the acting user for owner-scoped surfaces like My Day).
  • notifyMembership(c, …) — handle membership add/remove, which must also notify the user whose access just changed.

The sequence below traces a list-scoped write: the handler returns immediately while dispatch resolves recipients and RPCs each inbox under waitUntil.

sequenceDiagram
  participant Route as "write route"
  participant Notify as "notifyList()"
  participant Dispatch as "dispatch() / offResponsePath()"
  participant AU as "affectedUsers()"
  participant D1 as "D1: lists + listMembers"
  participant DO as "SyncInbox DO"
  Route->>Notify: notifyList(c, listId, surfaces)
  Notify->>Dispatch: build signal, waitUntil(...)
  Route-->>Route: respond to client (committed write)
  Dispatch->>AU: resolve recipients
  AU->>D1: owner ∪ active members
  D1-->>AU: userIds
  Dispatch->>DO: fanOut() — notify(signal) per user
  Note over Dispatch,DO: failures logged, never fail the write (F-17/F-18)

Fan-out runs off the request path and is bounded by member count; a notify failure never fails the already-committed write (constitution F-17/F-18). The internal dispatch(...) helper sends via offResponsePath(c, …), which calls c.executionCtx.waitUntil(...) in the Worker runtime and falls back to a detached run under a test harness that has no ExecutionContext — so a notify never breaks the handler. The recipient set deliberately mirrors the read-authorization rule, so a user is only ever signalled about data they can actually read.

Related: Worker API & Clerk Auth (the write routes), SyncInbox Durable Object (the delivery target).