Repository navigation
Contributing to Contrack
Contrack is a self-hosted personal CRM. One Node process serves a React app and
an Express API over one SQLite file. AI is optional, and search runs on local
models. This page says how to set up, the rules a review checks, and how a
change ships. The user docs are in docs/, and
Architecture explains the system.
You need Node.js 26.10 or later (.nvmrc pins it) and npm 11.19 or later. You
need no API key.
git clone https://github.com/arvarik/contrack.git
cd contrack
npm install
npm run models:fetch # optional: the two local search models, about 29 MB
npm run db:seed # optional: about 30 fictional demo contacts
npm run dev # http://localhost:3210, with hot reload- Node runs the TypeScript itself.
npm run devisnode server.ts, with Vite as middleware on the same port. - A dev container does all of this for you. Open the repository in a GitHub
Codespace, or in VS Code with the Dev Containers extension.
.devcontainer/devcontainer.jsoninstalls the Node version in.nvmrc, runsnpm ci, adds 400 fictional people taggeddemowithnpm run db:enrich, and startsnpm run dev. Port 3210 opens in your browser, with sign-in off. The container adds.app.github.devtoALLOWED_HOSTSand to Vite's allowed hosts, so a Codespace's forwarded address gets through the host checks. - Copy
.env.exampleto.envonly to change a default or add a key. The Configuration reference lists every variable. - Your data is
curator.dbin the project root. For a second, test instance, setDATA_DIRto a scratch folder andPORTto a free port. Each dev server keeps its hot reload on its own port.
| Path | What it holds |
|---|---|
server/ |
Routes, services, the AI layer, connectors, the MCP server, db.ts
|
shared/ |
Code that the server and the app both import |
src/ |
The React app: views/, components/, api/ hooks, db/schema.ts
|
scripts/ |
Seeds, model fetch, eval recorders, brand icons, docs tools |
tests/ |
Unit, integration, eval, contract and browser tests |
.agent/ |
Notes for coding agents: architecture, style, testing, status |
Repository layout has the full map.
| Command | What it does |
|---|---|
npm run dev |
Start the dev server on port 3210 |
npm run build |
Build the app into dist/
|
npm test |
Run the unit, integration and eval tests (test:watch to watch) |
npm run test:coverage |
The same, with the coverage floor enforced |
npm run test:e2e |
Run the browser suite against dist/
|
npm run test:contract |
Call real provider APIs. Each block skips without its key |
npm run lint |
Oxlint, tsc --noEmit and the tenant lint |
npm run knip |
Find files, exports and types that nothing uses |
npm run api:openapi |
Write docs/openapi.json from the route contracts |
npm run format |
Format with Prettier. CI runs format:check
|
npm run db:new <name> |
Start the next schema migration from the template |
npm run db:enrich -- --apply |
Make a test network of 5,000 people. Stop the server first |
npm run brand:icons |
Redraw every icon and brand file from the corvid's paths |
npm run docs:wiki -- <folder> |
Write the docs as a GitHub wiki |
A pre-commit hook runs Oxlint and Prettier on the staged files.
- Branch from
main, and targetmainwith the pull request. - Keep one change per pull request.
- Add or change tests with the code. A bug fix comes with a test that fails without it.
- Update the docs page the change affects. When users or operators notice
the change, add one short line under
[Unreleased]inCHANGELOG.md. Say what changes for them, not how the code does it. The pull request holds the details. - Run
npm run lint,npm run format:checkandnpm test. After a change to a page, a dialog, a form or sign-in, also runnpm run build && npm run test:e2e.
These break most often. .agent/ARCHITECTURE.md and .agent/STYLE.md have
the full lists.
-
Routes are thin. Validate with the route's contract, wrap the handler in
asyncHandler, and call a service. A service throws anAppErrorsubclass, never a plainError. -
Accounts stay apart. A function that touches owned data takes a
Scope, and the id and the owner go in the same SQL statement. Another account's row answers404. Every route has a row inserver/tenancy/routeManifest.ts, a scoped route has an isolation test, and an admin route mountsrequireAdminitself. -
Writes are atomic. Multi-step writes run in
sqlite.transaction(...). -
AI goes through the gateway. Call
generateFororstreamForinserver/ai/gateway.ts. Onlyserver/ai/adapters/imports a provider SDK. -
Outside data is untrusted. Fetch URLs through
safeFetch, and resolve stored upload paths withresolveUploadPath(). -
The image ships only what the server loads. A package that only the
browser uses goes in
devDependencies, as the build bundles it intodist/.tests/unit/repo/dockerRuntime.test.tsfails on a dependency the server never loads, and on a package the server loads that the image would not install. -
Imports name their file. A relative import in
server/,shared/orscripts/carries the extension, such as./geo.ts. -
The app fetches with React Query. Use the hooks in
src/api/, never auseEffectfetch. -
The UI uses the design system. Use the primitives in
src/components/ui/and the tokens insrc/lib/styles.ts: no borders for sections, one focus ring, 44 px touch targets, no text under 11 px, sentence case, and no period at the end of a statement. Tests scan for these. Every key binding is a row insrc/lib/shortcuts.ts. -
Types are strict.
anyis an error. Narrowunknowninstead. -
Errors are logged. Use
log.info,log.warnandlog.error. Noconsole.login app code and no empty.catch(() => {}).
A route has a contract: one Zod schema for what it reads and one for what it answers. API contract explains who reads it.
- Add the route's row to
server/tenancy/routeManifest.ts. - In
shared/contracts/<area>.ts, declare it withroute(): its method, its path as the manifest writes it, a one-linesummary, itsqueryorbody, and itsresponse. A response object is az.strictObjectthat lists every field the route sends. Add a new area's routes toCONTRACTSinshared/contracts/index.ts. - In the route, check the body with
validateBody(contract.body)and the query withparseQuery(contract.query, req.query). - In
src/api/, call it withapiJson(contract, path, init). The answer takes the contract's type, and the method comes from the contract. - Run
npm run api:openapiand commitdocs/openapi.json. - Add a line to
docs/api-reference.md. A scoped route also needs a case intests/integration/tenancy.isolation.test.ts.
Every integration test that uses makeTestApp() checks a contracted route's
answers against its contract, so a field the contract does not declare fails
the test that sent it. UNCONTRACTED in shared/contracts/index.ts lists the
older routes with no contract yet. Take a route off that list when you give
it a contract, and lower UNCONTRACTED_CEILING by one in the same change.
npm test needs no key and no network, and it never opens your database.
-
Unit tests in
tests/unit/follow the source tree (see its README). - Integration tests run routes, SQL and triggers on real SQLite.
-
Eval tests hold search, duplicate and answer quality to a baseline. A
gate fails when a number moves either way. When the move is intended,
re-record with
npm run eval:record(or:dedupe,:answer) and explain the baseline diff in the pull request. -
Browser tests drive the production build with Playwright. The first run
needs
npx playwright install chromium. See Accessibility. - Contract tests call real providers. Run them before a release and after an adapter change.
The coverage floor is statements 75, branches 62, functions 74 and lines 76,
with its own floor for server/. .agent/TESTING.md has the rest.
-
Docs are flat pages in
docs/, listed indocs/README.md, with screenshots and GIFs of fictional data indocs/images/. Tests fail on a broken link or anchor, whenconfiguration.md,api-reference.mdorkeyboard-shortcuts.mdmisses a variable, a route or a shortcut, and whenopenapi.jsonis not whatnpm run api:openapiwrites.- Take a screenshot from a production build on a scratch
DATA_DIRthatnpm run db:enrich -- --apply --count 400filled with fictional people. Never use real contacts. Docs images are 8-bit palette PNGs.
- Take a screenshot from a production build on a scratch
-
A schema change is a new migration.
npm run db:new <name>writesserver/db/migrations/NNNN_<name>.tsand adds it to the list. Write the SQL in itsup(db), mirror each new table and column inserver/db/schema.ts, and add each new object name toADDED_SINCE_FIXTUREintests/integration/db.migrations.test.ts. The server applies each migration once, in order, when it starts. Never edit a migration that has shipped. Write a new one.- The FTS tables, the vector stores and their triggers are rebuilt from
code, not migrated. Raise their version in
server/db/indexes.ts, for exampleFTS_SCHEMA_VERSIONwhen the full-text columns or triggers change. - A new owned table needs
ownerId, a<table>_owner_requiredtrigger in its migration, and an index that leads withownerId. Its name goes inOWNED_TABLESinserver/db.tsandscripts/tenant-lint.mjs, and in the delete loop ofpurgeOwner. - A new
contactscolumn also rebuilds thecontacts_auto_updated_atandcontacts_score_dirtytriggers with the new column list (contactEditColumns).tests/integration/scoring.incremental.test.tsfails until it does.
- The FTS tables, the vector stores and their triggers are rebuilt from
code, not migrated. Raise their version in
-
A new feature is a folder in
server/modules/whoseindex.tsexportsdefineModule({ id, routers, mcpTools, jobs, subscribers, onStart }), and one line inserver/modules/index.ts. The list order is the order the routers mount in. Each route still needs its row inserver/tenancy/routeManifest.tsand its contract (see Add a route). -
Background work is a job, never a new
setInterval. Declare it withdefineJob({ kind, run, every, atStart })and list it in its module. A job runs only whenDISABLE_BACKGROUND_JOBSis nottrue, so a test callsrunJobNoworpollJobs(server/jobs/runner.ts). -
A reaction to a write is a subscriber:
{ id, types, handle }listed in its module. The write records the event inside its transaction withrecordEvent, and callsdispatchEvents()after it. A new event type gets its payload schema inshared/contracts/events.ts. A handler schedules work and returns. Never rename a subscriber's id after it ships. -
Brand files in
public/anddocs/brand/are generated fromsrc/assets/corvidPaths.tsand the rig. Change the source, runnpm run brand:icons, and commit what it writes. Never edit a generated file.
- Write the subject as a plain sentence that says what the change does, such as "Search finds nicknames, phone digits and misspelled names".
- The repository squash-merges, so the pull request title becomes the commit.
- The description says what changed and why, then lists the test commands with their counts, and what did not run.
.github/workflows/ci.yml runs on pull requests into main, on pushes to it,
on v* tags, and by hand.
-
checks: lint, the format check and a production build. -
unit-tests: the tests with coverage, split across four runners with--shard. Each shard uploads a blob report. -
coverage: merges the four blob reports, shows every failure in one place and checks the coverage floor. -
browser-a11y: the Playwright suite, split across four runners. Each shard uploads its report on every run. -
build-imageandmerge-image: on a push tomainor av*tag, one image for linux/amd64 and linux/arm64 atghcr.io/arvarik/contrack.mainis taggedlatestand the short SHA, and a release its version, itsmajor.minorand itsmajor. Each image carries an SBOM, and a signed provenance record thatgh attestation verifychecks. -
scan-image: Grype scans the pushed image, and its findings go to the Security tab. The scan never fails the run. -
release: on av*tag, a GitHub release, unless one exists already.
Every job gets a read-only token unless it names more. Every action is pinned
to a commit, with its version in a comment. Dependabot
(.github/dependabot.yml) proposes npm, action and dev container updates each
Monday, a week after their release. .github/workflows/scorecard.yml runs
OpenSSF Scorecard on main each week and feeds the README badge.
A manual run can use the hppc self-hosted runner when the hosted pool is
slow. Pull requests from forks never reach it.
- Set the version in
package.jsonand move the[Unreleased]entries inCHANGELOG.mdunder it. - Merge to
main, then tag:git tag -a vX.Y.Z -m "vX.Y.Z"andgit push origin vX.Y.Z. - Publish the notes before the release job runs, or it writes a commit list:
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file notes.md --verify-tag.
Open an issue. The bug form asks for the steps, what you expected, what happened, your version and install, and the log lines around the error. Every error response has a request id that the server log repeats. Questions go to Discussions, and you can email the maintainer at arvind.arikatla@gmail.com.
For a security problem, post no details in public. SECURITY.md says how to report it privately.
Everyone who takes part follows the Code of Conduct.
Contrack is licensed under the GNU AGPL v3, and so is every contribution.
These pages are written from docs/ in arvarik/contrack. Change the source, then run npm run docs:wiki again.
Start here
Use Contrack
- Contacts
- Pulse and tracking
- Search and Ask Contrack
- Map
- Duplicates
- Import, sync, and export
- AI
- Keyboard shortcuts
- Accessibility
Run Contrack
Comparisons
- Contrack vs Monica
- Contrack vs Clay
- Contrack vs Folk
- Contrack vs Twenty
- Contrack vs Huly
- Contrack vs EspoCRM
- Contrack vs Nextcloud Contacts
Build on Contrack