Repository navigation
Architecture
This page explains how Contrack is built, for contributors. It covers the parts, the path of one request, the data model, the background work, and the rules that keep each account's data apart.
Contrack is one Node.js process. It serves the React app and the JSON API on one port, 3210 by default, and it keeps all data in one SQLite file.
flowchart LR
subgraph Browser
Views["Views and components"]
Hooks["React Query hooks and apiFetch"]
end
Clients["Scripts and MCP clients"]
subgraph Server["Node process: server.ts"]
MW["Express middleware: server/app.ts"]
Routes["Routes: server/routes"]
MCP["MCP server: server/mcp"]
Jobs["Events, jobs and connectors"]
Services["Services: server/services"]
Data["Repositories and SQL with a Scope"]
Gateway["AI gateway and queue: server/ai"]
Worker["CPU worker: local models"]
end
DB[("SQLite: tables, FTS5, sqlite-vec")]
Providers["AI providers"]
Web["Calendars, mail, Google, Nominatim, web pages"]
Views --> Hooks
Hooks -->|"JSON, NDJSON, SSE"| MW
Clients -->|"Bearer token"| MW
MW --> Routes & MCP
Routes & MCP & Jobs --> Services
Services --> Data --> DB
Services --> Gateway --> Providers
Services --> Worker
Services & Jobs --> Web
| Part | Built with |
|---|---|
| Runtime | Node 26.10 or later, which runs the .ts files itself. npm run dev runs node server.ts with Vite as middleware. Production serves dist/. |
| Frontend | React 19, Vite 8, React Router 7, TanStack Query 5, Tailwind CSS 4, Motion, Tiptap, cmdk and MapLibre GL. |
| Backend | Express 5, Zod 4, multer, sharp, nodemailer, SimpleWebAuthn and the MCP SDK. |
| Database | SQLite through better-sqlite3 and Drizzle ORM, in WAL mode, with FTS5 and sqlite-vec. |
| AI | The Gemini, OpenAI and Anthropic SDKs, any OpenAI-compatible server, and Transformers.js for the local models. |
| Folder | What it holds |
|---|---|
src/ |
The React app: api/ (query hooks and apiFetch), views/ (pages), components/, contexts/, hooks/ and lib/. |
shared/ |
Code that the server and the browser both run: the API contracts and event payloads (contracts/), facets, search history, dates, cadence, score bands, vCard and the MCP tool list. |
server/modules/ |
One module for each area: its routers, MCP tools, jobs and event subscribers, and the ordered list that the core reads. |
server/routes/ |
The Express routers, one file for each area. |
server/middleware/ |
Authentication, the host guard, rate limits, the AI switch, cache headers, compression, the uploads guard and the error handler. |
server/services/ |
The business logic, with search/, dedupe/, research/ and aiSearch/ (contact research), and geocoding/. |
server/repositories/ |
Contact reads and writes, and the hydration of child records. |
server/ai/ |
Capabilities, the gateway, the queue, the provider adapters, prompt safety, and the AI features in services/. |
server/connectors/ |
Calendar, mailbox and Google sync: adapters, the scheduler and the ingest step. |
server/events/, server/jobs/
|
The event log, its dispatcher and the contact subscribers; the job runner and the job kinds. |
server/db/ |
The migrations and their runner, the derived indexes and their versions, and the Drizzle schema. |
server/mcp/, server/tenancy/, server/workers/, server/mail/
|
The MCP server; Scope, the request context and the route manifest; the CPU worker for local models; the mail templates. |
server/utils/ |
Errors, validators, paths, the secret box, URL safety, the AI cache and the logger. |
server.ts, server/app.ts, server/db.ts, server/serveClient.ts
|
The process (boot, listen, shut down); the Express app; the connection and the steps that run on every boot; Vite in development or dist/ in production. |
scripts/ |
Command-line tools: seed data, db:enrich (a test network), db:new, reset-password, fetch-models, model-smoke, openapi, the tenant lint, eval recorders and benchmarks. |
tests/ |
unit/, integration/, eval/, contract/, e2e/ and fixtures/. |
public/ |
Icons, fonts and the web manifest. |
This is the path of one edit, from a click to a saved row.
-
A view calls a React Query hook in
src/api/, such asuseUpdateContact(). The hook callsapiFetchinsrc/api/client.ts. A unit test fails when a module insrc/api/callsfetchdirectly. -
The browser sends the session cookie. A script sends a personal token, and an MCP client sends a personal token or an OAuth access token.
-
server/app.tsruns the middleware in this order:- A request id (
X-Request-Id) and the security headers. - Compression (
server/middleware/compression.ts): brotli or gzip, as the request'sAccept-Encodingallows. It comes before everything that can answer, so it covers the static files too. Streams, byte ranges, photos and fonts, and bodies under 1 KB go out as they are. - CORS, only when
CORS_ORIGINis set. - The JSON parser: 1 MB, or 50 MB for
POST /api/contacts/bulk. - The AI rate limit for each client address, then the request log.
-
GET /healthz, which sits outside every guard below. -
hostGuard: while sign-in is off, and until the first account exists, a name that is not local gets403 HOST_NOT_ALLOWED. -
attachPrincipal: a token, a cookie, or the local owner. ThenrefuseCrossSiteWrites: a write with the cookie from another site's page gets403 CROSS_SITE_REQUEST. -
attachRequestContext, which puts the caller in AsyncLocalStorage. - The AI rate limit for each account, and
requireAiAllowed. -
Cache-Control: no-storefor four prefixes, thenguardReadOnlyToken. - The
/api/authrouter, thenrequireAuthandrequirePasswordCurrent. - The uploads guard and the static files, then every module's routers.
server.tsadds the rest:404 ROUTE_NOT_FOUNDfor an unknown/apipath, then Vite or thedist/files, then the error handler. - A request id (
-
The route checks its input with a Zod schema from
shared/contracts/(validateBodyorparseQueryinserver/utils/validators.ts), readsscopeOf(req), and calls a service. -
The service does the work. Every read and write takes the Scope and names the owner in the same SQL statement as the id. A write also records an event in the same transaction (see Events).
-
SQLite triggers update the derived data in the same transaction: the full-text rows,
updatedAt,trackedAt,archivedAt,nextFollowUpAt, the score's dirty flag and the search revision. -
After the commit, the write dispatches its events. The subscribers queue their work, such as the search index or a duplicate check.
-
The route answers with JSON. A thrown error goes to
errorHandler, which writes the error envelope with the request id. -
apiFetchturns an error answer into anApiError. A401, a disabled account or a password-change answer makes the whole app change screen. A mutation refreshes the queries whose data it changed.
-
Shell.
src/main.tsxmounts the app inside anErrorBoundary, theQueryClientProviderandAuthGate(src/components/auth/), which shows setup, sign-in, a password reset or the app. -
Routes.
src/App.tsxdeclares/and/contact/:id(the Network),/pulse/*,/search(Ask Contrack),/map,/map/contact/:idand/settings/*. One registry,src/views/settings/registry.ts, declares every settings page, its group and its searchable rows. -
Code loading. The Network list and the contact page are in the first
bundle. The map, Pulse, Ask Contrack, the note composer and each settings
page are chunks of their own. The app warms them in idle moments, after
the API requests on their way (
src/views/pages.ts,src/views/settings/warm.ts,src/lib/idle.ts). The gate asks for the account's preferences and the contact list beside its first/status, and opens once the preferences are in, so nothing draws with the defaults first. The build puts a hash on the render-blockingtheme-boot.js, so a browser keeps it. Pointing at a link starts its page's code and first data, except in a browser that asks to save data.src/lib/preloadable.tsxrenders a loaded view in the same frame. -
Page switches. One Suspense boundary in
src/App.tsxholds every route. A navigation is a transition, so the last page stays on screen until the next one is ready, and the two swap in one frame. The sidebar and the tab bar mark the pressed link at once (src/lib/pendingNav.ts). The Network list starts at its saved scroll position, and the pictures it showed stay in memory (src/lib/keptImages.ts). Long lists draw only the rows near the screen (src/components/ui/VirtualRows.tsx). -
Data. All server data goes through TanStack Query. The defaults in
src/main.tsxkeep data fresh for 30 seconds and cached for 10 minutes, with no refetch on window focus.src/lib/queryConfig.tsholds longer fresh times for some queries. -
Design system. The tokens live in
src/index.cssandsrc/lib/styles.ts. The visual and code rules, and the tests that hold them, are in the style guide. -
The corvid is the animated bird in the logo. Views ask it to move through
src/lib/corvid.ts. It stays still when the Corvid motion or Motion setting, or the operating system, asks for less motion.
| Layer | Folder | Rule |
|---|---|---|
| Module | server/modules/ |
One folder for each area. It lists its routers, MCP tools, jobs and event subscribers. |
| Route | server/routes/ |
Validate the request, read the Scope, call a service, answer. No business logic. |
| Service |
server/services/, server/connectors/, server/ai/services/
|
The work itself. Take a Scope as the first argument. |
| Repository and SQL |
server/repositories/, and prepared statements in services |
Name the owner in every statement on an owned table. |
| Database |
server/db.ts, server/db/
|
The connection, the migrations, the derived indexes and the schema. A schema change is a new migration. |
-
Errors. A service throws an
AppError(server/utils/AppError.ts) or a subclass:NotFoundError,ValidationError,ConflictError,RateLimitedError,ServiceUnavailableErrororUpstreamTimeoutError. Each carries a status, a stablecode, a message and optionaldetails. -
The error handler.
server/middleware/errorHandler.tswrites one JSON envelope for every error, and handlers run insideasyncHandlerso a rejected promise reaches it. It maps Zod errors, bad JSON, size limits, upload limits and SQLite errors (DB_CONSTRAINT,DB_BUSY,DB_READONLY). An unknown error answers500 INTERNALwith a generic message, and only a server outside production adds the stack. It logs each error with the request id. -
Validation. The request schemas live in
shared/contracts/.server/utils/validators.tsruns them:validateBody(schema)replacesreq.bodywith the parsed value or throws aValidationError, andparseQuery(schema, req.query)reads a query string the same way.parseInteractionSearchQueryreads the note search's query. The schemas cap names, child arrays (100 items) and id lists (5,000 ids).
For the envelope and the codes, see Conventions.
A route's contract is one Zod schema for each thing it reads and for what it answers. The server, the app, the MCP tools and the OpenAPI file read the same contract, so they cannot disagree.
-
Where.
shared/contracts/<area>.tsdeclares each route withroute({ method, path, summary, status, query, body, response })(shared/contracts/route.ts). The path is written as the route manifest writes it, such as/api/contacts/:id.shared/contracts/index.tscollects the contracts inCONTRACTS, andcontractFor("GET /api/contacts/:id")finds one. -
The server validates a request with its contract's
bodyorquery. It never parses its own answer: the tests check the answers. -
The app takes its types from the contracts.
src/types.tsre-exportsContact,Interaction,ActionItem,ContactListand their parts under the names the views use.apiJson(contract, path, init)insrc/api/client.tssends the contract's method and types the answer by itsresponse.apiJson<T>(path, init)serves a route with no contract. -
The MCP tools build their inputs from the same field schemas, plus
fields of their own such as
allowDuplicateandmentionContactIds. A tool checks a name, a date or an id list exactly as the REST route that does the same write. -
The OpenAPI file.
npm run api:openapi(scripts/openapi.ts) writesdocs/openapi.json, OpenAPI 3.1, version1.0.0, from the contracts withz.toJSONSchema. A test fails when the committed file is out of date. -
Answers are strict. A response schema lists every field the server
sends, internal columns such as
ownerIdincluded, and has no transforms and no defaults. Every integration test that builds its app withmakeTestApp()(tests/integration/helpers.ts) checks each 2xx JSON answer of a contracted route against it, and its status too. An undeclared or a missing field fails the test that caused it. -
What has a contract. The contacts, notes, follow-ups, lists, tags,
personal tokens and OAuth routes; the read-only query routes
(
/api/query/contacts,/api/industries,/api/timelineand/api/interactions/search); and a few more, such as the background jobs route, the address lookup routes (/api/geo/statusand/api/geo/lookups), and the routes that undo a merge or restore a dismissed duplicate.UNCONTRACTEDinindex.tslists every other route in the manifest, andUNCONTRACTED_CEILINGstops the list from growing: a new route gets a contract. The request schemas of the routes with no contract live in the same folder, as named exports. A few of those routes check a body that their route file writes itself. -
Paths stay
/api. A breaking change to a route adds a new path beside the old one. There is no/api/v1.
The Drizzle schema is server/db/schema.ts. It names every table and column
that the migrations create, and tests/integration/db.migrations.test.ts
holds the two equal. Migrations, events and jobs
says how the schema changes.
| Group | Tables |
|---|---|
| Accounts |
users (the local owner has credentialState = 'none'), sessions (keyed by the SHA-256 of the cookie secret), api_tokens (the SHA-256 of each personal token, and one row for each OAuth grant, with kind = 'oauth'), passkeys, auth_challenges, auth_links (reset and sign-in links), invitations, user_settings (one row for each preference), audit_log. |
| OAuth |
oauth_clients (the apps that may sign in), oauth_requests (one sign-in in progress, its code stored as a SHA-256), oauth_tokens (the access and refresh tokens of a grant, as SHA-256). |
| Instance |
app_settings: instance settings, and saved credentials sealed by the secret box. |
| Contacts |
contacts, with the flags isGhost, isArchived (with archivedAt), isTracked, deletedAt (the trash) and canonicalId (the losing side of a merge). |
| Contact details |
contact_emails, contact_phones, contact_addresses, contact_social_links, contact_education, contact_experience, contact_sources (import origin and raw payload), contact_tags, contact_interests, contact_attributes. |
| Timeline |
interactions, interaction_mentions (@mention links), action_items (follow-ups). |
| Lists and scores |
lists, list_members, score_snapshots (one score a week for each tracked contact). |
| Duplicates |
dedupe_suggestions, dedupe_exclusions (pairs never to suggest again), dedupe_merge_log (with the data an undo needs), dedupe_embedding_meta. |
| Search |
search_passages (slices of long fields, with offsets and a source hash), search_passage_state (the marker of a finished index), search_index_queue, search_revision and notes_revision (a counter for each owner), search_history. |
| Imports and sync |
imports, import_rows, connectors (secret sealed), connector_runs, connector_links (external ids and correspondents), upcoming_events, oauth_states (the state of a Google sign-in). |
| Core |
schema_migrations (the applied migrations and the version of each derived index), events, event_cursors, jobs. |
| Other |
map_views, ai_invocations (the AI usage log), geocode_cache. |
| Virtual table | Kind | What it holds |
|---|---|---|
contacts_fts |
FTS5 | One row for every visible contact: name, company, role, headline, location, about, industry, tags, interests, emails, phone digits, addresses and the owner token. Prefixes of 2 to 4 characters. |
interactions_fts |
FTS5 | The title and the plain text of each note, with an owner token. The tokenizer stems and folds accents. |
search_passages_fts |
FTS5 | The text of each search passage, with an owner token. |
search_embeddings |
vec0 | One int8 vector for each contact, partitioned by ownerId. 384 dimensions with the built-in model. |
search_passage_vectors |
vec0 | One int8 vector for each passage, partitioned by ownerId. |
contact_embeddings |
vec0 | One vector for each contact, for duplicate detection. Its width follows the embeddings model. |
Each derived index has an index row in schema_migrations with its version.
contacts_fts is at 6, and its row covers interactions_fts too. A new
version rebuilds both FTS tables once at boot. The other rows are at 1. A new
embeddings model rebuilds the vector tables and embeds every contact again.
The triggers that matter:
-
Full text. Triggers on
contactsdelete and insert the FTS row by rowid. A change to a tag, interest, email, phone, job or school refreshes the contact's row. A trashed, merged, archived or ghost contact has no row. -
Notes. Three triggers keep
interactions_ftsin step. They call the SQL functioncontrack_note_text, so no HTML tag becomes a search word. - Vectors. A change to a searchable field deletes the contact's vectors and passages and queues it for indexing. Status triggers copy the ghost, archive and trash flags into the vector tables.
-
Search revision. A contact change bumps its owner's
search_revision, and a note change bumpsnotes_revision. The search caches include both, so an edit makes older answers stale. -
Stamps and derived columns. Triggers stamp
updatedAt, set or cleartrackedAtwithisTracked, and set or cleararchivedAtwithisArchived. A pin (lat,lng,geoSource) and the score are not edits, so they stamp nothing.nextFollowUpAtis always the earliest due date of the open follow-ups. Writes setscoreDirtyfor the hourly sweep.
A schema change is a numbered file in server/db/migrations/, and
server/db/migrations/index.ts lists the files in order. At boot,
runMigrations in server/db/runner.ts applies each listed migration that
has no row in schema_migrations.
- A migration and its row commit in one transaction. A migration that throws keeps nothing, and the boot stops with an error that names it.
- A database that holds a migration this build does not have refuses to start. A newer build wrote it.
- A database that has tables but no
schema_migrationsrefuses to start, before anything is written. Contrack 1, or another program, made it. -
0001_baselineis the schema of a new database: its tables, indexes and triggers, and the local owner.tests/fixtures/schema/v2.0-d67c8a9.sqlis that schema. The migration test compares the stored SQL of every table, index and trigger with it, plus what later migrations add. -
npm run db:new <name>writes the next file from a template and adds it to the list. A migration writes its own SQL, imports no service, and never reads a list that later code extends. - The derived structures are rebuilt from code, not migrated: the FTS tables,
the vector stores, the passage index and the triggers that feed them.
server/db/indexes.tsruns their installers on every boot, after the migrations. - Four steps run on every boot, after the installers, because live code
needs them: the
nextFollowUpAtbackfill, the check that every owned table hasownerId,ANALYZEwithPRAGMA optimize, and the phonetic hash of contacts that have none, such as new ghost contacts. - Drizzle stays for typed queries. It does not manage the schema.
A write records what it changed in its own transaction.
recordEvent(scope, type, subjectId, payload) (server/events/record.ts)
inserts one row in events, and throws outside a transaction. A write that
rolls back leaves no row, and a crash after the commit loses nothing.
-
shared/contracts/events.tsholds the types and their payload schemas:contact.created,contact.updated(withchanged, the names of the fields the write set),contact.deleted,contact.restored,contact.merged,interaction.created,interaction.updated,interaction.deleted,action_item.created,action_item.updated,action_item.completed,action_item.deletedandlist.members_changed. A payload holds ids and field names, never a name or an email. - After its transaction, and before it reads its own answer, the write calls
dispatchEvents()(server/events/dispatcher.ts). The dispatcher gives each subscriber the events of its types that it has not seen, in order, from the subscriber's cursor inevent_cursors. At boot it catches up from the cursors. - A subscriber is synchronous and short. It schedules work, such as the search index queue or a job, and returns. A subscriber that throws runs again at the next dispatch, three times at most, and then skips that event with an error log. A throw never undoes the write.
- A new subscriber starts from now. Never rename the id of a subscriber that has shipped: the new id would skip every event before it.
- The reactions to a contact write are subscribers
(
server/events/contactSubscribers.ts): the search index, the dedupe vector, the duplicate check, the geocoder, the score, the owner's AI caches and auto-enrichment. Each one decides from the event, so every write path that records the event gets the same reactions. An import (origin: "import") and a bulk edit (bulk: true) skip the per-row work. - These write paths record events:
contactService,interactionService,actionItemService,listService, the merge indedupe/merging.ts, and taking back a research run (aiSearch/mergeEngine.ts). These writers record none and make their own follow-up calls: the ghost contacts a connector adds, tag edits (tagService), the research merge itself, and the list memberships that a merge or an undo moves. - Daily maintenance deletes events older than 30 days that every cursor has passed.
Background work is a row in jobs, and server/jobs/runner.ts runs it.
- A kind is declared once with
defineJob({ kind, run, every, atStart, maxAttempts }), and a module lists it.everymakes the job recurring.atStartruns it when the server starts, at once or after a delay. - The runner polls every second and runs at most
JOB_CONCURRENCYjobs at once, 2 by default. It takes turns between accounts, and the instance's own jobs take one turn together. An account's job runs in that account's scope. - A job that throws runs again after a wait that starts at 30 seconds and
doubles, up to an hour, until its
maxAttempts(3 by default). Then it endsfailed, with its error. - At boot, a row left
runninggoes back to the queue, because the process that ran it is gone. A row that was on its last try endsfailed. - A recurring kind keeps one queued row through
dedupeKey, and that row survives a restart. So a server that restarts every day still runs its daily jobs. A finished run stays for the health page until maintenance removes it. -
runJobNow(kind, payload)runs a job in the calling process, also whenDISABLE_BACKGROUND_JOBS=true.enqueueJobputs one in the queue. -
GET /api/admin/jobsand the Background jobs card on Instance health show each recurring job and the jobs that failed in the last 24 hours.
server.ts starts the runner after the server listens.
DISABLE_BACKGROUND_JOBS=true starts no job and no start-up work, which the
integration tests use. The runner still puts back in the queue a job that a
restart stopped. These are the job kinds:
| Work | Job | When |
|---|---|---|
| Geocode contacts that have an address and no pin, one Nominatim request every 1.1 seconds | geocode.startup |
Once, 2 seconds after start |
| Delete the cached address lookups that no contact uses, once they are a day old | geocode.cachePrune |
A minute after start, then daily |
| Copy stored Google photo URLs into uploads | connectors.photoSweep |
Once, 5 seconds after start |
| Sync the due connectors, 2 at a time and one for each account | connectors.tick |
At start, then every minute |
| Take a database snapshot, check it, and rotate old ones |
backup.startup, backup.scheduled
|
15 seconds after start, then every 24 hours by default |
| Purge trashed contacts after the retention period, 30 days by default | contacts.trashPurge |
At start, then daily |
| Delete a merged-away contact once its merge can no longer be undone (90 days), and the merge log entries past that window | contacts.mergePurge |
At start, then daily |
| Delete the files under an account's upload folder that no row uses, 2 days after they were written (31 for a link preview) | uploads.orphanSweep |
A minute after start, then daily |
| Delete expired and old rows (audit entries, sessions, sign-in links, revoked tokens, dead invitations, AI usage, finished imports, old score snapshots, OAuth states, connector runs, old events, finished jobs), and checkpoint the WAL | maintenance.daily |
At start, then daily |
| Delete expired OAuth sign-in requests and tokens, and app registrations that nobody uses | oauth.sweep |
Hourly |
| Refresh the model lists of the AI providers | ai.modelCatalogs |
At start, then daily |
Run PRAGMA optimize
|
database.plannerStats |
Daily. Shutdown and each search index drain run it too. |
| Score the contacts marked dirty, and take this week's snapshot when it is missing | scores.stale |
At start, then hourly |
| Score every tracked contact and take the weekly snapshot | scores.all |
Daily |
| Check one contact for duplicates, when Check new contacts automatically is on | dedupe.check |
5 seconds after a contact is added on its own, or after its name, company, role or location changes |
The search module's start-up work is not a job. It loads the local models, rebuilds the vector tables when the model changed, and fills missing vectors for search and duplicates. The duplicate vector backfill takes turns between accounts in rounds of 200 contacts.
On SIGTERM or SIGINT the server stops taking connections, lets open
requests finish, stops the job runner and the connector syncs, and closes the
database. It forces a close after 8 seconds.
Each area of the server is a module: a folder in server/modules/ whose
index.ts exports
defineModule({ id, routers, mcpTools, jobs, subscribers, onStart }).
server/modules/index.ts holds the ordered list: admin, avatar,
link-preview, search, lists, mcp, taxonomy, contacts,
connectors, imports, interactions, dedupe, action-items,
dashboard, ai-search, data-lifecycle, ai, logos, map and oauth.
-
createApp()mounts every module's routers in list order, after the auth middleware. Express matches in mount order, so the order is part of the behavior: themcpmodule mounts before thecontactsmodule, orGET /contacts/:idwould captureGET /contacts/action-items. -
registerAllToolsregisters every module's MCP tools. -
server.tsregisters every module's jobs and subscribers, starts the job runner, and runs each module'sonStartwork. The search module loads the local models, and the data-lifecycle module checks the age of the newest snapshot. - The route manifest, the migration list, the auth router and the middleware stay central. They hold for every module.
Many accounts can share one instance. These rules keep each account's data apart.
-
Every request has a principal.
attachPrincipal(server/middleware/auth.ts) resolves a personal token, an OAuth access token on/api/mcp, a session, or the local owner when sign-in is off. The local owner is a real account, so every row has an owner. -
The Scope is the isolation.
scopeOf(req)returns aScopewith a typedownerId(server/tenancy/scope.ts). Every function that reads or writes an owned table takes a Scope first. -
The owner is in the same statement. A query puts the id and the owner
together:
WHERE id = ? AND ownerId = ?. Code never reads a row by id and then compares the owner in JavaScript. -
Another owner's row is a 404. The body equals the body for an id that
does not exist, so a caller cannot learn which ids are real.
tests/integration/tenancy.isolation.test.tschecks every scoped route with three accounts. -
The request context is for attribution only.
server/tenancy/requestContext.tscarries the caller in AsyncLocalStorage, so an insert can stampownerIdand an AI call can bill the right account. It never decides what a query returns. A stream reads its Scope before it subscribes to events, and a background job that calls AI runs one account at a time insiderunWithContext. -
Triggers guard the owner column.
<table>_owner_requiredrefuses a row with no owner. On tables whose rows hang off a contact,<table>_owner_fillcopies the contact's owner, and<table>_owner_checkrefuses a row whose owner differs from the contact's. -
Indexes, files and memory carry the owner. Every FTS row holds an owner
token, and every
MATCHstarts with it. The vector tables are partitioned byownerId, and every KNN query names the owner. Uploads live underuploads/u/<ownerId>/, checked byguardUploads. In-memory queues keep each record beside its owner, and a cache built from one account's rows is keyed by that owner (ownerKeyinserver/utils/aiCache.ts). -
Every route is classified.
server/tenancy/routeManifest.tsgives each route a class:public,session-self,scoped,admin,instance-readorstatic.tests/integration/tenancy.routeManifest.test.tsfails on an unclassified route, a stale row, a scoped route with no isolation test, and an admin row whose handler does not carryrequireAdmin. -
The tenant lint reads the SQL.
scripts/tenant-lint.mjsruns innpm run lintand flags a statement on an owned table with no owner predicate. A deliberate instance-wide sweep carries a// tenant-lint: allow <reason>comment on the line above. Composite indexes start withownerId, andtests/integration/tenancy.queryPlans.test.tsfails when a core statement scans a table.
Three guards keep an instance manageable. The local owner of an instance with sign-in off cannot be disabled or deleted. The last active admin cannot lose the role. No admin can disable or delete their own account. For the routes, see Administration.
The code is in server/services/searchService.ts and server/services/search/.
runSearch in searchService.ts answers POST /api/search/semantic, for the
JSON and the streaming callers alike.
-
L1 cache. It is kept for each owner. The key holds the search revision,
the notes revision, a five-minute bucket, the model that answers (or
local), the cross-encoder state, the facets and the normalized question. -
Facets.
parseFacetQuery(shared/facetQuery.ts) reads facets typed in the question and adds the request's facets.compileFacets(facetSql.ts) turns them into one SQL predicate that every stage applies before its limit. A question made only of facets is answered by the database, in name order. -
Strict keyword search.
lexicalSearch(lexical.ts) requires every word.classifyQuery(intent.ts) then names the kind:email,phone,quoted,name,conceptualormixed. - Local kinds. For a name, an email, a phone number or a quoted phrase, the strict result is the answer. It is verified, and no model runs.
-
Implicit facets.
findImplicitFacets(implicitFacets.ts) finds a company, a place or an industry that the owner's contacts hold. When the question asks for nothing else, the facet answer is final. -
The local list.
localRetrieval(hybridRetrieval.ts) fuses the BM25 keyword list and the vector list by weighted reciprocal rank, withRRF_K = 15. Keyword and vector weigh 0.7 and 0.3 for the local kinds, 0.3 and 0.7 forconceptual, and 0.5 each formixed. Aconceptualquestion adds a passage list. The cross-encoder reorders the top 30 within 25 ms. The list streams as theinstantchunk. -
AI off or no provider. The local list is the answer, with
fallback: true. -
L2 cache. With the built-in embedding model,
semanticCache.tsreturns a verified answer from the last five minutes when the question vector has a cosine of 0.97 or more, and the revision, facets, model, named entities and constraint words match. -
Model stages, in the AI queue's search lane, within 12 seconds. The
planner (
parseSearchQuery) writes a query plan, andserver/ai/queryConstraints.tschecks its hard filters against the question. The local list runs inside the hard filter. When the database proves the whole plan, the filtered contacts are the answer. Otherwise the reranker reads the top 30. The server keeps a match only when the quoted value is in the named field or passage, and the hard filters hold.buildReason(reasons.ts) writes each reason from the proven fields. -
Failure. An error, a timeout, or an edit in the account during the
model stages ends with a fresh local list and
fallback: true. The caches do not keep it.
Every match on every path carries matchedOn (search/matchedOn.ts): the
fields that answer the question, with the question's words marked in the
contact's own text. Proven fields come first, then fields that hold the
words, then a passage close in meaning. It runs no model.
The Try asking questions come from search/starterQuestions.ts. The pool
holds up to 500 questions about values that two of the account's contacts
share, and the seven general questions of shared/generalQuestions.ts. A
general question is in the pool only when its facets find a contact.
implicitFacets.ts reads the same question as those facets, so the search
answers it with no model. The pool is kept per account and search revision,
and built again after boot and after an import. Ask Contrack draws six
questions (src/views/search/suggestions.ts) and the palette's AI mode draws
four, both through useStarterDraw. A draw takes one question from each kind
before it takes a second from any.
-
Contacts.
contacts_ftsranks with BM25 weights (WEIGHTSinlexical.ts): name 10, company 5, role 3, tags 3, headline 2, location 2, about 1, industry 1, extras 1, addresses 0.5, search expansion 0.5. An address weighs least, so a street finds a contact while a name, a company or a role with the same word ranks first. A phone number is indexed with all its digits, its last 10 and its last 7, so a number with or without a country code finds its contact. -
Passages.
passages.tscuts the about text, preferences, jobs and schools into slices of 480 characters that overlap by 80. Each slice keeps its source, a hash and its offsets, so a reranker quote can be checked. -
Notes.
interactions_ftsstems English words and folds accents. The date phrase parser (datePhrases.ts) is deterministic and reads the caller's time zone. Notes on hidden contacts are filtered at query time. -
The index queue.
indexQueue.tskeeps the embedding work insearch_index_queue, so a restart resumes it. Keyword search works while the vectors build. -
Local models. Two models run on one worker thread
(
server/workers/cpuHost.ts):Xenova/all-MiniLM-L6-v2, the built-in embedding model (384 dimensions, stored as int8), andXenova/ms-marco-TinyBERT-L-2-v2, the cross-encoder (SEARCH_RERANK_MODEL=offturns it off).npm run models:fetchdownloads both, the Docker image ships them, andMODEL_DOWNLOADS=falsestops any download at run time. See Model files and offline installs.
The eval tests run in npm test with recorded vectors and model answers, so
they need no model, no key and no network. Each one fails when a number moves
from its committed baseline, up or down.
| Test | What it measures |
|---|---|
tests/eval/search.eval.test.ts |
Recall at 10 and mean reciprocal rank for 79 questions over 300 contacts, for five rankings: the sidebar search, keyword, fused, the local answer, and the local answer with the cross-encoder. Record with npm run eval:record. |
tests/eval/answer.eval.test.ts |
The whole Ask pipeline with recorded model answers: filters, results, empty answers, prompt-injection cases and grounded briefs. Record with npm run eval:record:answer, or run live with npm run eval:answer:live. |
tests/eval/passages.eval.test.ts |
Questions whose answer sits late in a long field, through the passage index. |
tests/eval/dedupe.eval.test.ts |
Precision and recall of the duplicate passes with AI off, on a labeled corpus with hard negatives. Record with npm run eval:record:dedupe. |
Every AI feature is optional. The AI layer is server/ai/. The features live
in server/ai/services/ (contact parsing, briefings and summaries, mentions,
search), server/services/research/ (contact research) and
server/services/aiSearch/ (the research batch queue and the merge).
-
Capabilities. Code asks for a kind of work, not a model
(
server/ai/capabilities.ts). The settings name each one by its model.src/lib/aiFeatures.tsmaps the models to the features for the settings pages.-
quick, the Fast model: parsing, mentions, briefings, the daily insight, mail summaries, search planning and checking, and the fields that research fills. -
deep, the Strong model:.emlsummaries, duplicate checks, and the pages that SearXNG finds. -
research, the web search model: a provider's own web search. -
embeddings, the embedding model: the search and duplicate vectors (server/ai/embeddings.ts).
-
-
Resolution. At call time a capability takes a pin from Settings, then an
environment pin (
AI_QUICK_MODEL,AI_DEEP_MODEL,AI_RESEARCH_MODEL,AI_EMBEDDINGS_MODEL), then Automatic:AI_PROVIDERfirst, then a fixed order. The providers come from environment keys, keys saved in Settings and OpenAI-compatible servers (server/ai/providerRegistry.ts). While AI is off for the instance, no provider resolves. -
Gateway.
generateForandstreamFor(server/ai/gateway.ts) are the only way to run a generation. They check the instance switch, resolve the capability, and run the call in the queue under one deadline. The deadline is 60 seconds by default and 150 at most, and it counts the wait for a slot. The default output cap is 4,096 tokens. -
Queue.
GenerationQueue(server/ai/workQueue.ts) runs 2 calls at once and holds at most 16 waiting. Interactive work goes first, and the accounts take turns. Ask Contrack's planner, reranker and brief use the search lane, which has 2 slots of its own. A full queue answers429 AI_BUSY. -
Adapters.
server/ai/adapters/holdsgemini.ts,openai.ts,anthropic.tsandopenaiCompatible.ts.- The Gemini adapter picks its model with
routing/SmartRouter.ts. It pauses a model that answers 429, a 5xx or a timeout for the delay Google names, 30 seconds by default. - The OpenAI-compatible adapter falls back from a JSON schema to a JSON object to a prompt when a server refuses the format.
-
server/ai/resilience.tsgives every adapterwithTimeout,withRetry(at most one retry, with jittered backoff) andparseAIJson. The SDKs' own retries are off. A canceled request stops its queued work.
- The Gemini adapter picks its model with
-
Embedder and reranker. Search and dedupe turn text into vectors through
one interface,
Embedder(server/ai/embedder.ts). Search reorders its local list through another,Reranker(server/ai/reranker.ts).- A local embedding model and the cross-encoder run on the CPU worker. A
provider embedder calls
AIProvider.embed. - Each one says whether it is local, and the privacy rules read that. A model that is not local reads nothing of an account with AI off, and a run asks again before every call.
- Each embedding call says what its texts are for: a question, a document, or a text to compare for duplicates.
- Both vector stores record the embedder's id and width, so an embedder
with a new id rebuilds them. A new model is a new adapter, and search does
not change.
scripts/benchmark-search.ts --embedder <model>compares a local model with the bundled one.
- A local embedding model and the cross-encoder run on the CPU worker. A
provider embedder calls
-
Research. Every contact research request runs through one function,
research()(server/services/research/). The batch queue, the one-contact route and auto-enrichment call it, and no other code runs a technique.- A request names a technique and a web search, or gets the account's web
search engine (
webSearchEngine, or the instance's engine when it isdefault). - A technique finds facts and returns evidence, never fields:
provider-search(the web search model's own search),search-and-read(a web search, whose pages the Strong model reads) orcombined(both at once). One extraction reads the evidence into fields, so every technique's result has the same fields. - A web search (
WebSearch) is a port too, and SearXNG is its only adapter. Both ports have a registry and aset*seam for tests. - Each technique's
needs()says what a start needs set up. A start with an unknown name answers 400, and one with a missing need answers 503, before anything is spent. - Before every model call and every web search, a run reads the instance
switch, the account switch and Allow web search
(
server/ai/webSearchPolicy.ts). A model call reads them again when it gets its slot in the queue (beforeSend). A refusal ends the run for that contact, and the batch queue stops the rest of that account's batch.
- A request names a technique and a web search, or gets the account's web
search engine (
-
Prompt safety.
wrapUntrusted(server/ai/promptSafety.ts) fences contact fields, files and web text inside<untrusted_data>tags, and each such prompt carriesUNTRUSTED_DATA_RULE.sanitizeAiOutputValuechecks model text before it is shown or saved. Research merges only validated, additive changes (server/services/aiSearch/mergeEngine.ts). -
Caches.
server/utils/aiCache.tskeeps LRU tiers in memory:briefing,mentions,dailyInsightandqueryParsefor 24 hours, andrerank(the L1 search cache) andsynthesisfor 12 hours. Tiers built from one account's rows are keyed by owner.queryParseandmentionsdepend only on the text the caller sent, so they are shared. -
Cost limits. The AI routes allow 60 requests a minute for each client
address and 30 for each account. One research batch runs at a time on the
instance. Every call is logged in
ai_invocations, which feeds the AI usage pages with a cost estimate fromserver/ai/pricing.ts. -
Switches.
AI_DISABLEDor the admin switch turns AI off for the instance, and theaiAssistpreference turns it off for one account.requireAiAllowedthen refuses the AI routes, except Ask Contrack, which answers from local data. The work that runs outside a route readsaiAllowedForUseritself: mention detection,.emlsummaries, connector summaries, auto-enrichment, contact research before each of its calls, the MCP search, and hosted embeddings (mayEmbedContactsFor).
For the settings a person sees, see The AI page.
-
Headers. Every response carries
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, a referrer policy, a permissions policy and a Content-Security-Policy (server/utils/securityHeaders.ts). An HTTPS request also getsStrict-Transport-Security. -
Host guard. While sign-in is off, and until the first account exists,
hostGuard(server/middleware/hostGuard.ts) answers only local names, thePUBLIC_URLhost and the names inALLOWED_HOSTS. So a page on another site cannot use DNS rebinding to act as the owner.mcpOriginGuardrefuses an MCP request whoseOriginis not this app's,PUBLIC_URLorCORS_ORIGIN. -
Cookies and tokens. The session cookie is
HttpOnlyandSameSite=Strict, andrefuseCrossSiteWriteschecksOriginandSec-Fetch-Siteon every write that the cookie signs. A read-only token may send onlyGET,HEAD,OPTIONSand MCP requests. Admin routes need a signed-in session, so a token cannot administer the instance. -
Outgoing requests.
safeFetch(server/utils/urlSafety.ts) fetches only publichttpandhttpsaddresses. It checks the address again at connect time and on each of at most 3 redirects, and it caps the response size. Link previews, remote images, calendar feeds and SearXNG research pages use it. -
Uploads.
guardUploads(server/middleware/uploads.ts) serves only the caller's own folder, shared logos and account photos, and answers404for everything else. Files that are not images download instead of rendering. -
Secrets and credentials.
server/utils/secretBox.tsseals SMTP passwords, AI keys, connector credentials and the Google client secret with AES-256-GCM. The key isCONTRACK_SECRET_KEY, orsecret.keyin the data folder, which the server creates. Passwords use scrypt. Sessions, tokens and emailed links are stored only as hashes. -
The log.
redactUrlForLogdrops the query string from every logged URL, because it can hold a token or a search. A log line names ids and counts, never a contact's name, a note or a question. -
File modes. At boot the server sets its umask to
077and removes group and other access from the database, its WAL files,secret.key,uploads/,backups/,.cache/andmodels/(server/utils/privateFiles.ts).
| Command | What it checks |
|---|---|
npm run lint |
oxlint, the TypeScript compiler (tsc --noEmit) and the tenant lint. |
npm run format:check |
Prettier, over the whole repository, these pages included. |
npm test |
The unit, integration and eval projects of Vitest. The tests use a temporary data folder, never your curator.db. |
npm run test:e2e |
Playwright journeys in tests/e2e/, including the accessibility checks. |
npm run test:contract |
Optional live checks against the configured AI providers. They can cost money. |
npm run knip |
Files, exports and types in src/ that nothing uses. |
npm run api:openapi |
Writes docs/openapi.json from the route contracts. A test fails when the committed file differs. |
CI runs the lint, the format check, the tests with coverage, a production
build and the Playwright journeys. A push to main or a version tag also
builds the image for linux/amd64 and linux/arm64, signs its provenance and
scans it. For the workflow, see the contributing guide.
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