Files
familysync/docs/DEVELOPMENT.md
Lucas BergerandClaude Opus 4.8 6ade8d59bf
Publish / publish (push) Successful in 11s
docs: correct rrule drift (code uses ical.js RecurExpansion) + minor fixes
Recurrence expansion uses ical.js ICAL.RecurExpansion, not the rrule
library (never installed/imported). Also fix playwright-cli path
(/usr/bin), prod-compose service count (two, post-Redis-removal), and
CI job count (six). Found via /gsd-docs-update --verify-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:29:03 -04:00

290 lines
15 KiB
Markdown

<!-- 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 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
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb -d
```
The dev override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306`.
### 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 six jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
### CI dependency caches
CI uses two persistent cache paths inside job containers:
| Path | Content |
| ---------------- | ----------------------------------------------------------------------- |
| `/pnpm-store` | pnpm content-addressable store (`--store-dir /pnpm-store`) |
| `/ms-playwright` | Playwright browser binaries (`PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`) |
These paths must be bind-mounted from host directories in the act_runner `config.yaml`
`container.options` field — that is a **host-side change, not tracked in this repo**. Without the
mounts, CI still works correctly — pnpm creates an ephemeral store at `/pnpm-store` inside the
container and Playwright downloads browsers fresh each run. The mounts only eliminate repeat
downloads across runs.
## 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, 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 -d
```
The dev override:
- Exposes MariaDB on `localhost:3306`
- 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`).