Skip to content

Codebase Onboarding

This is a course, not a reference. Work through it top to bottom — each step builds on the previous one, and by the end you’ll have run the app, traced a real request through every layer, and know exactly where your first change goes. Budget roughly an afternoon. Things repeat here on purpose: hearing the same idea in a second context is how it sticks.

The reference pages (Architecture, Glossary) go deeper on everything mentioned; this page tells you in what order to absorb them.

Don’t read code you can’t run. Follow Run Musubi locally until PostgreSQL, the API, docs, and a native development client work independently. Expo Go is not supported because the app includes custom native modules.

Terminal window
pnpm install
cp .env.example .env # fill DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, ENVIRONMENT=dev
docker compose up -d db
pnpm db:migrate
pnpm check # establish the verified repository baseline

Then, in the app: create an account, create a calendar, create an event, drag it around in day view. Everything you just did is the exact flow you’ll trace in Step 2.

Musubi has a single load-bearing idea, and the whole codebase is a consequence of it:

An event is not owned by a calendar. It is linked into one or more calendars.

Every other calendar app nests events inside calendars. Musubi (結び, the knot) inverts that — one dinner, linked into your calendar and your partner’s, edited once, visible in both. Concretely, there is no calendarId column on events. Instead, a junction table:

// packages/db/src/schema.ts — the heart of the design
export const calendarEvents = pgTable("calendar_events", {
id: uuid("id").primaryKey().defaultRandom(),
eventID: uuid("event_id").references(() => events.id, { onDelete: "cascade" }).notNull(),
calendarID: uuid("calendar_id").references(() => calendars.id, { onDelete: "cascade" }).notNull(),
});

Three consequences you must internalise before touching anything:

  1. The home calendar governs editing. events.originCalendarID is the one calendar whose editors/owners may edit the event. You can see an event through any calendar it’s linked into; you can only edit it through its home. Server gate: assertCanEditEvent (apps/api/src/permissions.ts).
  2. Link vs Fork. Link = the same row gains another junction entry (one truth, more places). Fork = a new independent copy with a new owner and home. Details.
  3. Roles live on membership. calendar_members holds (user, calendar, role); the permission matrix is one shared function both apps call:
// packages/types/src/permissions.ts — the single source of truth
const PERMISSIONS: Record<CalendarRole, CalendarAction[]> = {
owner: ["editCalendar", "deleteCalendar", "manageMembers", "editEvents", "invite"],
editor: ["editEvents", "invite"],
viewer: [],
};
export function can(role, action): boolean { … }

The server calls can() to gate handlers; the client calls the same can() to hide buttons. Change the matrix in one place, both sides follow.

Checkpoint: you should be able to answer — “I’m a viewer on calendar A, an editor on B, and an event is linked into both with its home on A. Can I edit it?” (No — edit rights come from the home calendar A, where you’re a viewer. You could fork it into B.)

This is the most valuable hour of your onboarding. We’ll follow “user creates an event” through every layer. Open the files as you go.

2.1 The screen (apps/client/components/calendar/AddEventModal.tsx)

Section titled “2.1 The screen (apps/client/components/calendar/AddEventModal.tsx)”

The composer is a docked bottom sheet (dock) fed by a draft from the calendar grid. On Save it validates with a pure function and hands off to the store:

const { ok, errors } = validateEventForm({ title: newTitle, calendarCount: selectedCals.size, … });
await onSave(eventConstruct); // ← the screen doesn't know about HTTP at all

2.2 The store (apps/client/store/useEventsStore.ts)

Section titled “2.2 The store (apps/client/store/useEventsStore.ts)”

Stores are API-first: they call the server and only write state + cache after it succeeds — offline failures are loud, never silent data loss:

addEvent: async (event, api) => {
const result = await api.createEvent(event); // 1. server first
set(state => ({ events: [...state.events.filter(e => e.id !== result.id), result] })); // 2. then memory
await cacheUpsertEvents([result]); // 3. then the offline SQLite mirror
},

Note the sibling localAddEvent — the same mutation without the API call, used when the change already happened elsewhere (an SSE frame, cache hydration). This x / localX pairing repeats across the stores.

2.3 The API client (apps/client/services/api.ts)

Section titled “2.3 The API client (apps/client/services/api.ts)”

A thin useApi() hook — one method per endpoint over Better Auth’s $fetch. Two idioms to notice:

function throwOnError(error): asserts error is null { … } // type-narrowing error check
return { ...data, start: new Date(data.start), end: new Date(data.end) }; // ISO strings → Dates

(And one advanced wrinkle you can ignore for now: calendars shared from another Musubi server route to their origin — Federation.)

app.post("/api/v1/events", requireAuth, wrap(handlerCreateEvent));

requireAuth resolves the session into req.user; wrap turns a rejected promise into next(err) so handlers can just throw.

2.5 The handler (apps/api/src/handlers/events.ts)

Section titled “2.5 The handler (apps/api/src/handlers/events.ts)”

Most mutating handlers cover the same five concerns. This event path uses the common order:

export async function handlerCreateEvent(req, res) {
const event = EventSchema.parse(req.body); // 1. validate → 400
event.creatorID = req.user!.id; // identity is server-set
for (const cal of event.calendars)
await assertCan(req.user!.id, cal, "editEvents"); // 2. authorise → 403
const created = await createEvent(event, event.calendars); // 3. persist (packages/db)
await pushEventToProviders(created, "create"); // 4. sync outward (Google/Outlook/CalDAV)
notifyCalendarMembers(memberIds, "event_created", created); // 5. broadcast (SSE)
res.status(201).json(created);
}

validate → authorise → persist → sync outward → broadcast. The server must re-establish every client-side guarantee; client checks are UX only (details).

2.6 The query (packages/db/src/queries/events.ts)

Section titled “2.6 The query (packages/db/src/queries/events.ts)”

Handlers never touch tables — all DB access goes through query modules re-exported from @musubi/db. Query functions return data or undefined; throwing NotFound is the handler’s job.

notifyCalendarMembers writes an event_created frame to every connected member of every linked calendar. Their apps (hooks/useEventsStream.ts) route it to localAddEvent — and the loop closes: your event appears on their screens without a refresh.

Exercise: run two clients (or one + curl -N -H "Authorization: Bearer <token>" http://localhost:7531/api/stream), create an event on one, watch the frame arrive on the other. Then read handlerUpdateEvent and handlerRemoveEvent and find the same five beats.

Open packages/db/src/schema.ts top to bottom — it’s organised into six commented domains; the Data Model page annotates each. Reading order:

  1. Auth (skim — Better Auth owns these; how sign-in actually works and the iOS setup/gotchas are on the Authentication page)
  2. Calendars & events — note title (not name!), recurrence (iCal RRULE text), deletedAt
  3. Link tables — you met these in Step 1
  4. External sync — mirrors of Google/Outlook/CalDAV calendars
  5. Federation — shadow users + tokens (skim for now)

Two mechanics to understand now because everything depends on them:

  • Soft delete → delta sync. Events are tombstoned (deletedAt), never hard-deleted on write. The client asks “what changed since T?” and gets { events, deletedIds, serverTime } — tombstones are how offline clients learn about deletions. (Tombstone)
  • Recurrence is expanded at read time. events.recurrence stores RRULE text (FREQ=WEEKLY;BYDAY=MO + EXDATE:… lines); expandRecurringEvents(events, windowStart, windowEnd) from @musubi/calendar produces occurrences for the visible window only. Never store or expand unbounded instances.

Five patterns explain almost every client file. Read them here, verify each in the code:

  1. Stores are thin, API-first, and paired (x / localX) — Step 2.2. Plus one hard rule: merge, don’t replace calendars ({ ...existing, ...incoming }) — SSE payloads omit per-user fields like role, and replacing once shipped a bug where editing a calendar locked its owner out.
  2. Two databases. PostgreSQL on the server (packages/db), SQLite on the device (apps/client/db/ + drizzle/) as an offline mirror. They have separate schemas and separate migrations. Boot order: settings seed synchronously from SQLite (theme correct on the very first frame — an async read flashes), then events/calendars hydrate on tabs mount, then a delta refresh reconciles with the server.
  3. The calendar UI is hand-built and gesture-heavy (components/cal/). Its rules: positions live in Reanimated shared values, never React state; gestures read a live ref so their deps stay empty; anything that must happen after an animation runs on a timer, not the animation callback (interrupted animations drop callbacks — this has bitten twice). Geometry constants live in layout.ts. Read this subsystem before touching it; it’s intentionally dense. (drill, ghost)
  4. UI primitives, not ad-hoc. Tap (never bare Pressable), Btn, Empty, Toast, confirm(), YearStamp, and ModalPortal for any modal (never RN <Modal> directly — nested native modals break touch on iOS; everything renders through one root Portal host). Theme colours are read at render time from constants/theme.ts — never captured into module-level constants (the palette object is swapped in place on theme change).
  5. Realtime is a nicety, delta sync is the truth. SSE frames patch stores live; the durable path is always the next delta refresh. The external_sync frame (server’s scheduled provider poll found changes) triggers a silent refresh.

Exercise: find where the app decides which theme to render on the very first frame (hint: it’s not in a component — store/useSettingsStore.ts seeds itself synchronously). Then find why AddEventModal docked mode lifts with the keyboard but the calendar-detail sheet doesn’t (keyboardAware in useModalAnimation).

Skim these now so you know they exist; deep-read when a task touches them:

One rule from Shared Packages worth repeating because it orders your work: when you change a data shape, change the Zod schema in packages/types first. Both apps infer their types from it; there is no duplicated interface to keep in sync.

The recipe (end-to-end feature touching data)

Section titled “The recipe (end-to-end feature touching data)”
  1. Types first: add the field to the Zod schema in packages/types.
  2. Server data: column in packages/db/src/schema.tspnpm db:generate → review → pnpm db:migrate; thread it through the relevant query module.
  3. Server API: extend the handler (five-beat rhythm) or add a route in index.tschecklist.
  4. Client API: the useApi method (throwOnError after the fetch, revive dates).
  5. Client state: store action (+ localX twin if SSE should carry it); if it’s cached, add it to the client SQLite schema + generate a client migration too.
  6. UI: wire it with the primitives; theme colours at render time.
  7. Verify: typecheck both apps, run relevant self-checks, test on both platforms, test offline and after a cold restart.
Terminal window
pnpm check # typecheck + twelve self-checks + lint + production docs build

Lint passes with a locked budget for known React Compiler/hook warnings, so a net increase fails the command. When you extract pure logic, leave a self-check behind in the same style — lib/rrule.test.ts and lib/eventForm.test.ts are the pattern.

The condensed checklist (full version in Contributing):

  • Data shapes changed in packages/types first
  • Handlers follow the five beats; identity fields set server-side; new DB access goes through a query module
  • Stores API-first; calendars merged, not replaced
  • UI primitives used; no module-level colour constants; no magic numbers (named constants, layout.ts-style)
  • Reanimated v4; shared values for gesture-driven positions; timers, not animation callbacks
  • Migrations committed with the schema change
  • One focused thing per PR, and the description explains why

Check the GitHub issues, the roadmap, and the codebase audit. Good first shapes: a scoped UI polish task inside components/calendar/, a documented user setting threaded end to end, or a small test around an existing invariant. Coordinate before taking an audit item that changes security or data semantics.

When Read
First hour This page, top to bottom, running the app
Before any change Architecture Overview — the map
Touching the backend API Server
Touching the app Client App
Touching sign-in / an iOS build Authentication
Touching data Data Model + Shared Packages
Touching sync/providers Sync Engine
Touching invites/servers Federation
A term confuses you Glossary
Evaluating debt/risk Codebase Audit
Ready to open a PR Contributing

Stuck or unsure about an approach? Open a GitHub issue or discussion before investing the time — that’s what they’re for. Welcome aboard. 結び