Data Model
Everything in Musubi hangs off the database schema in packages/db/src/schema.ts (Drizzle ORM, PostgreSQL). This page is the reference for every table and — more importantly — the why behind the junction-table design.
The domains
Section titled “The domains”The schema is grouped into six domains by comment banners. Read them in this order.
1. Auth (managed by Better Auth)
Section titled “1. Auth (managed by Better Auth)”| Table | Purpose | Notes |
|---|---|---|
user |
Core identity | email unique; cascades to everything the user owns. isExternal + homeServer mark a federated shadow account — a member whose real account lives on another Musubi server (Federation) |
session |
Bearer/session tokens | token unique, indexed on userId |
account |
OAuth + email/password credentials | providerId e.g. "google"; holds accessToken/refreshToken |
verification |
Email verify + password-reset tokens | indexed on identifier |
You rarely edit these — Better Auth’s Drizzle adapter owns them. See Auth in Shared Packages.
2. User settings & profile
Section titled “2. User settings & profile”| Table | Purpose | Notes |
|---|---|---|
userSettings |
Preferences (theme, formats, onboarded, calendarOrder jsonb) |
id is the user’s FK + primary key; lazy-created conflict-safely on first read |
userAvatars |
Avatar bytes | Stored as a custom bytea type — see below |
userStatus |
isSponsor / isPremium flags |
3. Calendars & events
Section titled “3. Calendars & events”| Table | Purpose | Key columns |
|---|---|---|
calendars |
A calendar | creatorID, color, isDefault (one auto-created per user, undeletable) |
events |
An event | title, start/end, isAllDay, hasAttendees, recurrence, creatorID, originCalendarID, deletedAt |
calendarInvites |
Share links | calendarID, expiresAt, maxUses |
4. Link tables — the heart of the design
Section titled “4. Link tables — the heart of the design”This is where “events beyond calendars” is implemented. There is no calendarId column on events. Instead:
erDiagram
user ||--o{ calendarMembers : "member + role"
calendars ||--o{ calendarMembers : ""
calendars ||--o{ calendarEvents : ""
events ||--o{ calendarEvents : "linked into (M:N)"
events ||--o{ eventUsers : "attendees"
user ||--o{ eventUsers : ""
calendars ||--o{ events : "originCalendarID (home)"
| Table | Meaning | Cascade / constraint |
|---|---|---|
calendarMembers |
who is in a calendar + their role |
cascades from user & calendar; role defaults "viewer"; unique (userID, calendarID) — re-joins hit onConflictDoNothing instead of duplicating |
calendarEvents |
which calendars an event is linked into (M:N) | cascades both sides; unique (eventID, calendarID) so retries cannot duplicate a link |
eventUsers |
attendees of an event (creator added on create; presence = attending) | cascades both sides; unique (eventID, userID) |
calendarInvites |
invite links — the row’s uuid id IS the token |
cascades from calendar; expiresAt null = never expires, maxUses null = unlimited, uses counter checked (with expiry) at every token read |
Consequences you must respect:
- To find a user’s events, you join
user → calendarMembers → calendars → calendarEvents → events. There is no shortcut column. - Deleting a calendar kills the events homed in it (
originCalendarID) everywhere — they’re tombstoned before the calendar row goes, because the FK would otherwiseset nulltheir origin and hide them. Events merely linked into the deleted calendar survive in their other calendars; only fully orphaned ones (no remainingcalendarEventslinks) are removed. SeeremoveCalendar()inqueries/calendars.ts. - A shared event linked into 3 calendars is one row. Editing it edits it everywhere. That’s the feature.
5. External sync (provider-agnostic)
Section titled “5. External sync (provider-agnostic)”| Table | Purpose | Unique constraint |
|---|---|---|
externalCalendars |
maps a Musubi calendar ↔ a provider calendar | (provider, accountID, externalCalendarID) and (calendarID) |
externalEvents |
maps a Musubi event ↔ a provider event | (provider, calendarID, externalEventID) |
caldavAccounts |
CalDAV credentials | (userID, serverUrl, username) — multiple accounts per user |
The (calendarID) scoping on externalEvents is deliberate: two users can mirror the same Google calendar (e.g. a shared team calendar) and each keeps an independent mapping. CalDAV passwords in caldavAccounts.encryptedPassword are AES-GCM encrypted at the app layer — the DB never sees plaintext. Full detail in the Sync guide.
6. Federation
Section titled “6. Federation”| Table | Purpose | Notes |
|---|---|---|
memberTokens |
Bearer tokens for federated shadow members (origin side) | tokenHash unique (SHA-256 — raw value never stored); createdAt enforces the 90-day lifetime. Rotation is compare-and-swap; removing the final membership deletes the rows transactionally. Authorization remains in calendar_members. |
musubiAccounts |
This user’s connections to other Musubi servers (home side) | member token AES-GCM encrypted (same key as CalDAV passwords); unique(userID, server) — accept once, every device inherits. See Federation. |
The custom bytea type
Section titled “The custom bytea type”Drizzle has no built-in bytea, so schema.ts defines a one-line custom type for userAvatars.data:
const bytea = customType<{ data: Buffer }>({ dataType() { return "bytea"; } });Avatars live in the database on purpose — client-side compression keeps them ~10–20 KB, so pg_dump backs them up and self-hosting stays “just docker-compose”. If large media (attachments) ever lands, swap S3/MinIO behind the avatar endpoints — nothing else changes.
Soft delete → delta sync
Section titled “Soft delete → delta sync”Events are never hard-deleted on write. removeEvent() sets deletedAt = now(). This is what makes offline delta sync possible:
client: "give me everything changed since <lastSync>"server: returns { events: [...changed...], deletedIds: [...tombstoned...], serverTime }client: upserts the changed events, drops the deletedIds from its cache, stores serverTimeWithout since, the server returns the full active set (deletedAt IS NULL) and the client replaces its cache wholesale. Tombstones older than ~30 days are hard-deleted by an hourly job (purgeDeletedEvents). See useRefreshData.
Query modules
Section titled “Query modules”packages/db/src/index.ts creates the Drizzle client and re-exports every query module plus the schema:
export const db = drizzle(config.db.databaseUrl, { schema });export * from './queries/events';export * from './queries/calendars';// … users, settings, invites, sessions, external, caldav, oauthexport * as schema from './schema';Query functions are the backend’s whole DB vocabulary. Two conventions that matter:
- Keep a complete database invariant inside one query function. Calendar
creation, event creation/link reconciliation/deletion, calendar deletion, and
ownership transfer use
db.transaction(). Handlers should not coordinate a dependent sequence of raw query calls. - Most queries return data or
undefined/[]; handlers map that to HTTP errors. Token and direct calendar lookups are the deliberate exceptions:getCalendarIDFromToken()andgetCalendar()throwNotFoundError. - Delta-aware reads take an optional
since: Date.getUsersEvents(userID, since?)returns the full set or the changed-since set accordingly.
Ownership transfer additionally selects the calendar FOR UPDATE. The row lock
serializes competing transfers before the transaction updates the new owner’s
membership, the previous owner’s membership, and calendars.creatorID.
Provider mirrors cannot transfer ownership: external_calendars.userID must
remain the user who owns the corresponding OAuth or CalDAV credentials.
Migration workflow
Section titled “Migration workflow”Migrations are generated by drizzle-kit from the schema diff and committed as SQL.
-
Edit
packages/db/src/schema.ts(add/change a column, table, index, constraint). -
Generate the migration from the diff:
Terminal window pnpm db:generateThis writes a numbered
packages/db/drizzle/00XX_*.sqlplus a snapshot underdrizzle/meta/. -
Review the generated SQL. Check constraints, cascade rules, and indexes are what you intended.
-
Apply it to your local database:
Terminal window pnpm db:migrate -
Commit the generated SQL and snapshot together with your schema change.
If you’re adding a data field, do this in order
Section titled “If you’re adding a data field, do this in order”- Add the column in
packages/db/src/schema.ts. pnpm db:generate→ review SQL →pnpm db:migrate.- Add it to the Zod schema in
packages/typesso both apps get the type (why). - Thread it through the relevant query function, API handler, client
useApimethod, and store. - If the client caches it, add it to
apps/client/db/schema.ts(the local SQLite mirror) and generate a client migration too.