14 KiB
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
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/:
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
pnpm install
This installs all workspace packages (apps/api and apps/pwa) in a single pass.
2. Start the dev database and Redis
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:
# 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.
# 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
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.
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.recommendedTypeCheckedwithprojectService: true(type-aware rules, auto-discovers alltsconfig.jsonfiles) 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-injectiondisabled 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 viadisableTypeChecked(these files are outside the main tsconfig projects) - Prettier integration —
eslint-config-prettierlast 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.
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:
# 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 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:
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
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:
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
# 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/srcinto 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).