<!-- generated-by: gsd-doc-writer -->

# Development Guide

Local development setup and workflows for FamilySync — a pnpm monorepo with two workspaces: `apps/api` (Hono + Node.js) and `apps/pwa` (React + Vite).

## Repo Layout

```text
familysync/
├── apps/
│   ├── api/          # Hono API server — Node.js 22, TypeScript, Drizzle/MariaDB
│   └── pwa/          # React 19 PWA — Vite, TanStack Query, Schedule-X
├── docker-compose.yml
├── docker-compose.dev.yml
├── package.json      # Root workspace scripts
└── pnpm-workspace.yaml
```

Key paths inside `apps/api/src/`:

```text
src/
├── db/
│   ├── schema.ts       # Drizzle table definitions (source of truth for migrations)
│   ├── client.ts       # mysql2 pool + drizzle instance
│   └── migrations/     # Generated SQL migration files
├── routes/             # Hono route files (events, lists, push, sse, me, health)
├── auth/               # OIDC middleware
├── broker/             # CalDAV broker (tsdav + ical.js)
└── lib/                # Shared utilities
```

## Prerequisites

- **Node.js 22 LTS** — the Dockerfile base is `node:22-alpine`; match this locally
- **pnpm 11.5.1** — managed via corepack (`corepack enable pnpm`)
- **Docker + Docker Compose** — for MariaDB and Redis in dev
- **TypeScript 5.x** — installed per-workspace as a dev dependency

## Local Setup

### 1. Install dependencies

```bash
pnpm install
```

This installs all workspace packages (`apps/api` and `apps/pwa`) in a single pass.

### 2. Start the dev database and Redis

```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
```

The dev override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`.

### 3. Configure environment variables

Copy the root `.env.example` to `.env` and fill in the required values. The root `.env` is sourced by docker-compose for container env vars. When running the API server directly on the host (outside Docker), you must override `DB_HOST`:

```bash
# Source the root .env, then override DB_HOST for host-side execution
DB_HOST=127.0.0.1 node dist/index.js
```

Or set `DB_HOST=127.0.0.1` in your local `.env` for the dev workflow. The docker-compose production config sets `DB_HOST: mariadb` (the service name); that value does not resolve on the host.

For auth bypass during local UI development, set `DEV_AUTH_BYPASS=true` — this skips OIDC and authenticates as the dev user (id 1).

## Development Workflow

### API dev loop

The `dev` script runs the compiled output via `node --watch`. **A build must exist before starting the dev server** — the watcher restarts `dist/index.js` on file changes, but it does not recompile TypeScript. You must rebuild when source changes.

```bash
# From apps/api — or use the root shortcut
pnpm --filter @familysync/api build   # compile src/ → dist/
pnpm --filter @familysync/api dev     # node --watch dist/index.js

# Root shortcuts
pnpm dev:api   # runs dev in apps/api (requires dist/ to already exist)
```

Recommended inner loop: run `pnpm --filter @familysync/api build` after each change, the `--watch` process restarts automatically.

### PWA dev server

```bash
pnpm --filter @familysync/pwa dev
# or from root:
pnpm dev:pwa
```

Vite serves the PWA with HMR on the configured dev port. The PWA's API calls target the backend; set `VITE_API_URL` (or the Vite proxy config) to point at the running API.

## Build Commands

### Root workspace scripts

| Command                  | Description                                                   |
| ------------------------ | ------------------------------------------------------------- |
| `pnpm dev:api`           | Start API dev watcher (`node --watch dist/index.js`)          |
| `pnpm dev:pwa`           | Start Vite dev server for the PWA                             |
| `pnpm build`             | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test`              | Run API test suite (`vitest run` in `apps/api`)               |
| `pnpm test:e2e`          | Run Playwright e2e harness (`apps/pwa`)                       |
| `pnpm lint`              | ESLint across all workspaces (`pnpm -r --if-present lint`)    |
| `pnpm format`            | Reformat all files with Prettier (`prettier --write .`)       |
| `pnpm format:check`      | Check formatting without writing (`prettier --check .`)       |
| `pnpm typecheck`         | `tsc --noEmit` in all workspaces                              |
| `pnpm md:lint`           | Markdown lint (`markdownlint-cli2`) across the repo           |
| `pnpm generate-secrets`  | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |

### `apps/api` scripts

| Command                                     | Description                                     |
| ------------------------------------------- | ----------------------------------------------- |
| `pnpm --filter @familysync/api build`       | Compile TypeScript (`tsc`) → `dist/`            |
| `pnpm --filter @familysync/api dev`         | Start `node --watch dist/index.js`              |
| `pnpm --filter @familysync/api start`       | Start `node dist/index.js` (no watch)           |
| `pnpm --filter @familysync/api test`        | Run vitest once (`vitest run`)                  |
| `pnpm --filter @familysync/api test:watch`  | Run vitest in watch mode                        |
| `pnpm --filter @familysync/api lint`        | ESLint `src/` and `tests/` (`--max-warnings 0`) |
| `pnpm --filter @familysync/api typecheck`   | `tsc --noEmit`                                  |
| `pnpm --filter @familysync/api db:generate` | Generate SQL migrations from schema changes     |
| `pnpm --filter @familysync/api db:migrate`  | Apply pending migrations to the database        |

### `apps/pwa` scripts

| Command                                         | Description                                                 |
| ----------------------------------------------- | ----------------------------------------------------------- |
| `pnpm --filter @familysync/pwa dev`             | Start Vite dev server with HMR                              |
| `pnpm --filter @familysync/pwa build`           | `tsc && vite build` → `dist/`                               |
| `pnpm --filter @familysync/pwa preview`         | Serve the production build locally                          |
| `pnpm --filter @familysync/pwa lint`            | ESLint `src/` and `e2e/` (`--max-warnings 0`)               |
| `pnpm --filter @familysync/pwa typecheck`       | `tsc --noEmit` + `tsc --project tsconfig.e2e.json --noEmit` |
| `pnpm --filter @familysync/pwa test`            | Run vitest once                                             |
| `pnpm --filter @familysync/pwa test:e2e`        | Run Playwright e2e tests                                    |
| `pnpm --filter @familysync/pwa test:e2e:headed` | Playwright in headed mode (visible browser)                 |
| `pnpm --filter @familysync/pwa test:e2e:ui`     | Playwright UI mode                                          |

## Code Quality — Run Before Every Push

CI gates every PR to `main` on these checks. Run them locally before pushing to avoid a CI round-trip.

```bash
pnpm lint          # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/)
pnpm format:check  # Prettier formatting check (use `pnpm format` to auto-fix)
pnpm typecheck     # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json)
pnpm md:lint       # Markdown lint (also runs in CI fast-checks)
```

### ESLint

Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers:

- **All `apps/**/*.{ts,tsx}`** — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (type-aware rules, auto-discovers all `tsconfig.json` files)
- **`apps/pwa/**/*.{ts,tsx}` additionally** — `eslint-plugin-react` + `eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
- **All `apps/**/*.{ts,tsx}`** — `eslint-plugin-security` (14 of 15 rules at error; `detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys)
- **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects)
- **Prettier integration** — `eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier

`--max-warnings 0` is enforced: warnings count as failures. **Blanket `eslint-disable` comments are not permitted** — every suppression requires a justification comment.

### Prettier

Config: `.prettierrc` (root). Settings: `singleQuote: true`, `semi: true`, `tabWidth: 2`, `trailingComma: "all"`, `printWidth: 100`.

```bash
pnpm format        # write fixes in place
pnpm format:check  # check only (used in CI)
```

The `.prettierignore` file at the repo root excludes build output and generated files.

## TypeScript Strict Checks

Both workspaces use `"strict": true` in their `tsconfig.json`. The build step (`tsc`) catches type errors in `apps/api` (since it emits output). For `apps/pwa`, Vite uses esbuild to transpile and does not perform type checking — **vitest will pass even when there are type errors in the PWA**. Always run the typecheck script explicitly:

```bash
# Check both workspaces
pnpm typecheck

# Or individually
pnpm --filter @familysync/api typecheck
pnpm --filter @familysync/pwa typecheck   # also checks tsconfig.e2e.json
```

Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite builds will silently miss.

## CI Pipeline Overview

Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filter job determines whether code files changed; the `api` and `harness` jobs are skipped entirely for doc-only PRs (changes only to `.planning/**`, `.gitea/**`, or `*.md` files).

| Job           | Runs on             | Checks                                                                                                      |
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Every PR            | `pnpm lint` → `pnpm format:check` → `pnpm md:lint` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` |
| `api`         | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container)      |
| `harness`     | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
| `security`    | Every PR            | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs |
| `gate`        | Always              | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped    |

All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.

## Drizzle Migration Workflow

Schema changes follow a strict two-step process. **`drizzle-kit push` is not available** — it has been removed from the scripts because it emits a false destructive diff (table truncation) on populated MariaDB databases.

### Step 1 — Generate the migration

After editing `apps/api/src/db/schema.ts`:

```bash
pnpm --filter @familysync/api db:generate
```

This runs `drizzle-kit generate` and writes a new `.sql` file to `apps/api/src/db/migrations/`. Review the generated SQL before proceeding — confirm it matches the intended schema change with no unexpected `DROP` or truncation statements.

### Step 2 — Apply the migration

```bash
pnpm --filter @familysync/api db:migrate
```

This runs `drizzle-kit migrate` and applies any pending migration files to the target database. Drizzle reads `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, and optionally `DB_PORT` (default `3306`) from the environment, as defined in `apps/api/drizzle.config.ts`.

When running migrations from the host against the Docker database:

```bash
DB_HOST=127.0.0.1 pnpm --filter @familysync/api db:migrate
```

Migration files live in `apps/api/src/db/migrations/` and are committed to version control.

## Docker Compose Dev Stack

```bash
# Bring up the full dev stack (API in Docker + MariaDB + Redis, with ports exposed)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Bring up only backing services (run API on host for faster iteration)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
```

The dev override:

- Exposes MariaDB on `localhost:3306`
- Exposes Redis on `localhost:6379`
- Mounts `apps/api/src` into the container for live source access
- Sets `NODE_ENV=development`

The production `docker-compose.yml` builds the API and PWA into a single image (`production` target in `apps/api/Dockerfile`). The PWA `dist/` is copied into the API image's `./public` directory and served on port `3000`.

## Common Issues

**`dev` script fails with "Cannot find module"**
The `dev` script runs `node --watch dist/index.js`. If `dist/` does not exist or is stale, run `pnpm --filter @familysync/api build` first.

**DB connection refused when running API on host**
`DB_HOST` defaults to `mariadb` (the Docker service name). When running the API outside Docker, override it: `DB_HOST=127.0.0.1`. The dev compose override exposes port 3306 on the host.

**drizzle-kit migrate says "cannot connect"**
Same `DB_HOST` issue. Prepend `DB_HOST=127.0.0.1` to the migrate command when running from the host.

**API integration tests fail with FK errors**
The vitest config sets `fileParallelism: false` to prevent concurrent test files from conflicting via the shared MariaDB. Ensure you are not overriding this. Tests require a running MariaDB — set `DB_HOST=127.0.0.1` and ensure the dev database is up.

**TypeScript errors missed during development**
Vite/esbuild strips types; type errors will not surface in `vitest run` or `vite build` output. Run `pnpm typecheck` explicitly to catch them.

**`pnpm lint` warns about ESLint version**
ESLint is pinned to 9.39.4. Do not upgrade to ESLint 10 until `eslint-plugin-react` resolves the `getFilename is not a function` incompatibility (`jsx-eslint#3977`).
