Menu
OpenCount docs

Development

#Requirements

  • mise, which pins Bun, Go (for the pinact hook), Tesseract and Poppler. Optional but the shortest path.
  • Or Bun alone, with Tesseract and Poppler installed yourself if you work on receipt reading (brew install tesseract tesseract-lang poppler, or apt install tesseract-ocr poppler-utils).
  • Docker, to build the image or run the contributor compose file.

#Setup

mise run bootstrap   # bun install, then .env with a fresh AUTH_SECRET
bun run dev          # dev server on :5173, worker included

Without mise: bun install, copy .env.example to .env, and replace AUTH_SECRET with the output of openssl rand -base64 32.

Through mise, Tesseract comes from conda-forge with every language pack, about 460 MB, which is why mise's experimental setting is on in mise.toml.

The dev server migrates the SQLite file and starts the worker itself, as bun --watch src/worker/index.ts, so a change to a job handler reloads on its own. Stopping the dev server stops the worker. The database lands at ./data/opencount.sqlite; delete it to start from a fresh instance, setup code included.

compose.yaml builds the image from your checkout and runs it on port 3000, for checking the real container. It is not the self-hosting file: that is compose.prod.yaml, which pulls the published image.

docker compose up --build

#Scripts

bun run dev          # Vite dev server on :5173, with the worker
bun run build        # build/server and build/worker
bun run start        # run build/server, which spawns build/worker
bun run check        # svelte-check (fails on warnings) and tsc
bun run lint         # every prek hook over every file
bun run format       # bun fmt over the repo, Markdown included
bun run circular     # fails on an import cycle
bun run test         # unit tests
bun run db:generate  # write a migration from the schema

The package manager is bun; bunfig.toml makes every script run on Bun, never Node. bun run start uses ORIGIN from .env, since the compiled server otherwise assumes HTTPS.

#mise tasks

  • mise run bootstrap: install, and write .env with a fresh secret if there is none.
  • mise run verify: every prek hook over the whole repo at the commit and push stages, which is what CI runs.
  • mise run screenshots: retakes every screenshot in docs/images/, desktop screens in the dark theme at 1440 by 900 and phone screens in the light theme at 390 by 844.
  • mise run showcase: lays out feature.webp, the hero at the top of the README, from those screenshots.

Review the image diff and commit it with the change that caused it.

#Git hooks

bun install wires them through prek, installed as a dev dependency. They are the same set CI runs, so a green commit locally is a green quality job.

  • pre-commit: file hygiene, gitleaks, typos, actionlint, zizmor, pinact, hadolint, oxlint, bun fmt, markdownlint, agnix, tailwint, the append-only migration check, and grep guards for the conventions no linter knows.
  • commit-msg: Conventional Commits. releaser computes versions from commit subjects, so a malformed one silently costs a release.
  • pre-push: check, circular and the unit tests.
  • post-checkout: bun install.

#Conventions

The full list, every one a hard rule, is in CLAUDE.md. The ones that bite first:

  • Route files are typed by ./$types, never by hand.
  • No raw Drizzle in routes. Each table has a DTO class; a load returns .toJSON().
  • Form input goes through a zod schema and parseForm(). FormData values are never cast.
  • Money is an integer of minor units plus the group's currency code, never a float.
  • Group data is scoped to members. A group id from a URL or a form is never trusted until membership is checked.
  • Slow work is a job. JobDTO.enqueue() with a typed payload, handled by the worker. A request never awaits it.
  • Environment variables are declared once, in src/env.ts, with a zod schema and a description, and read only through the Config the server builds. process.env, Bun.env and import.meta.env fail the hooks.
  • Logging goes through Logger; console.* is a lint error.
  • No comments in code, except one required JSDoc block above every class method and exported function.
  • Mobile first: every screen works at 375px, respects the safe-area insets and keeps touch targets at 44px. Desktop gets real layouts, not the phone column stretched.

#Changing the database

Edit the schema, then bun run db:generate writes a migration. Commit both. Migrations apply at boot, before the worker starts; never by hand.

Committed migrations are append-only. A hook refuses a commit that edits, renames or deletes one, and the server refuses to boot on a database migrated with a migration it no longer has unchanged. Fix a bad migration with a new one. A local dev database that got out of step can simply be deleted.

#Releases

Every merge to main publishes a canary: X.Y.Z-canary.N and canary, for linux/amd64 and linux/arm64. releaser keeps a chore(release): X.Y.Z pull request open; merging it retags the last canary as X.Y.Z and latest without rebuilding. Images are built on ghcr.io and published tags are copied to Docker Hub.

This guide lives in the project repo: edit it there, and this page follows within a day.