This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Databuddy is a comprehensive analytics platform — a Turborepo monorepo using Bun as the package manager and runtime. It consists of multiple apps (dashboard, API, data collectors) and shared packages, backed by PostgreSQL, ClickHouse, and Redis.
# Start dashboard + API only (most common)
bun run dev:dashboard
# Start all apps
bun run dev
# Lint
bun run lint
# Format
bun run format
# Type check
bun run check-types
# Run tests
bun run test
bun run test:watch
# Database
bun run db:push # Apply schema changes (no migration files)
bun run db:migrate # Run migration files
bun run db:studio # Open Drizzle Studio GUI
bun run workspace [--reset] [--anomaly] [--events N] [--website <ID>] # Local login, website and seeded analytics
# SDK (must build before dev if SDK changed)
bun run sdk:build
# Build everything
bun run buildNote: All commands use dotenv -- prefix internally to load .env — just run them from root.
# From root
cd apps/api && bun test path/to/test.ts
# Or with filter
cd apps/api && bun test --test-name-pattern "test name"bun install
cp .env.example .env
docker-compose up -d # PostgreSQL, ClickHouse, Redis
bun run db:push
bun run clickhouse:init # from packages/db
bun run sdk:build
bun run dev:dashboardapps/
dashboard/ # Next.js 16 frontend (React 19, TailwindCSS 4)
api/ # Elysia.js backend (Bun, port 3001)
basket/ # Analytics event ingestion service
uptime/ # Uptime monitoring
cron/ # Scheduled jobs
links/ # Short link service
docs/ # Documentation site
status/ # Status page app
slack/ # Slack integration
packages/
db/ # Drizzle ORM schemas + clients (PostgreSQL + ClickHouse)
rpc/ # ORPC router — type-safe API layer between dashboard and api
auth/ # Better-Auth integration + permission system
sdk/ # Public analytics SDK (React, Vue, Node.js)
sdk-swift/ # Swift analytics SDK
nuxt/ # Nuxt module for the SDK
ui/ # Design system components (@databuddy/ui)
ai/ # AI agent, query builders, MCP tools
redis/ # Redis client, cacheable() caching, pub/sub, BullMQ queues, rate limiting
shared/ # Shared types, utilities, constants
validation/ # Zod schemas
services/ # Business logic services
email/ # Email via Resend
notifications/ # Notification system
tracker/ # Lightweight client-side tracking scripts
mapper/ # Data transformation utilities
env/ # Environment configuration (type-safe env vars)
devtools/ # Browser devtools extension
encryption/ # Encryption utilities
api-keys/ # API key management and scopes
test/ # Shared integration test infra (factories, contexts, assertions)
migrate/ # Migrates competitor tracker attributes (Umami, Pirsch, Rybbit) to Databuddy
Browser (SDK/tracker) → basket (ingestion) → ClickHouse (analytics warehouse)
→ PostgreSQL (relational data)
Dashboard (Next.js) ←→ ORPC (rpc package) ←→ API (Elysia) → PostgreSQL + ClickHouse + Redis
RPC Layer (packages/rpc): The central type-safe API contract between dashboard and api. Dashboard uses ORPC client with TanStack Query; API implements the procedures. Adding a new endpoint means defining it in rpc, implementing it in api, and calling it from dashboard.
Database Layer (packages/db): Single source of truth for all schemas. Uses Drizzle ORM for PostgreSQL (relational data: users, websites, settings) and a ClickHouse client for analytics data (events, sessions, pageviews). Schema changes use db:push for development; db:migrate for production migrations.
Caching (packages/redis): cacheable() wraps repeated lookups with positive + negative caching, single-flight dedup, stale-while-revalidate, and Redis fallback. Pass reviveDates: false when the cached value carries ISO-string timestamps validated by z.string() output schemas.
Auth (packages/auth): Better-Auth handles sessions. The package also contains the permission system used across all apps.
State management in Dashboard: Jotai for local UI state, TanStack Query for server state.
- Runtime: Bun 1.3.14+
- Frontend: Next.js 16, React 19, TailwindCSS 4, Radix UI, Recharts
- Backend: Elysia.js (Bun-native HTTP framework)
- API layer: ORPC (type-safe RPC with OpenAPI generation)
- Auth: Better-Auth
- ORM: Drizzle ORM
- Databases: PostgreSQL 17, ClickHouse 25.5, Redis 7
- Validation: Zod 4
- Linting/Formatting: Biome via Ultracite
- Build: Turborepo + Bun
- Linter/Formatter: Ultracite (Biome-based). Run
bun run lint/bun run format. - TypeScript: Strict mode. Always use proper types — avoid
any. - Marketing copy: Never write staccato sentences on landing pages, docs, or any marketing copy. No runs of clipped fragments like "Fast. Private. Simple." or "No cookies. No banners. No guesswork." Write full sentences that connect the idea to what the reader gets.
- Commit format:
<type>(<scope>): <description>(e.g.,feat(dashboard): add export button,fix(api): handle null session) - Commit slicing rule: Prefer one commit per coherent product or technical slice, not one giant snapshot and not ultra-fragmented file-by-file commits.
- Split commits by intent: feature, bug fix, refactor, style/copy pass, or migration slice.
- Use the dominant surface as scope:
dashboard,api,rpc,basket,docs,db,sdk,tracker,deps,ci. - Group closely related UI files into one commit when they ship one visible change.
- Keep unrelated surfaces in separate commits even if they were edited in the same session.
- For broad migrations, follow the repo’s existing pattern: one commit per meaningful area, e.g.
feat(dashboard): migrate home, events, insights, and links pages to DS primitives. - Before committing, check
git diff --statandgit status --short; if the diff mixes unrelated intents, split it. - Only make a single snapshot commit for the whole worktree when the user explicitly asks to include everything as-is.
- PRs: Open against
stagingbranch (notmain).
Runners: apps/api and apps/basket use vitest; every other package uses bun test. Import helpers from the runner the package actually runs (bun:test vs vitest); bun run lint:policies rejects the wrong one. Biome and most tsconfig.json files exclude test files, so format tests by hand (tabs, no blank line before a closing });) and keep imports honest.
Every test file must be reachable. A package that contains *.test.ts(x) needs a test script, and any file path named in a test* script must exist; lint:policies enforces both, and only counts a file as run when CI reaches it through the package's test or test:integration script. Env-gated integration files go in the package's test:integration script, which sets its own gate flags; CI runs every package's test:integration with one turbo run test:integration step, so never wire a suite as its own ci.yml step. A gated file nobody runs is dead, not "for later".
Integration tests use the shared services only. Postgres databuddy_test on 5432, Redis on 6379, ClickHouse on 8123, via import "@databuddy/test/env" as the first import. Never pin a test to a scratch container port; lint:policies rejects five-digit loopback ports in test files. A test that needs isolation uses its own organization, website, or table names and cleans them up.
Do not write these tests. They were removed in bulk once and will be removed again:
- Constant snapshots: asserting a constant or config equals a literal copy of itself. Drift guards that compare two sources of truth are the exception.
- Mock echo: mocking a dependency and asserting it was called with the arguments the test passed in.
- Library behaviour: zod defaults, drizzle column config, ai-sdk retries, bun or node semantics.
- Duplicates: add a case to the existing
<module>.test.tsinstead of creating<module>-extra.test.ts,<module>-boundary.test.ts, or a "fuzz" file that reruns fixed inputs. .skipor.todowithout an issue link, or adescribe.skipthat never turns on.
# Run every integration suite (requires Docker: postgres, redis, clickhouse)
bun run test:integration
# One package
cd apps/api && bun run test:integration
# One-time setup for test DB
cd packages/db && DATABASE_URL="postgres://databuddy:databuddy_dev_password@localhost:5432/databuddy_test" bunx drizzle-kit pushKey helpers from @databuddy/test:
signUp()— creates a real user via better-auth with session cookieaddToOrganization(userId, orgId, role)— inserts member rowuserContext(user, orgId)/apiKeyContext(orgId, scopes)— builds RPC ContextinsertOrganization()/insertWebsite()/insertApiKey()— DB factoriesexpectCode(promise, "FORBIDDEN")— asserts ORPCError codereset()/cleanup()— truncate tables / close connectionsimport "@databuddy/test/env"— sets test env vars (must be first import)
Rules for integration tests:
- Always
import "@databuddy/test/env"as the first line - Use
const iit = hasTestDb ? it : it.skipfor graceful skip when Docker is down - Use
userContext/apiKeyContext/expectCodefrom the test package — don't redefine locally - Type API key objects against
Context["apiKey"]to catch schema drift beforeEach(() => reset())andafterAll(() => cleanup())in every file- Business context lives in
organization_business_contexts, neverorganization.metadata; fixtures that write metadata test nothing
- Time series bucket in the site's timezone. Query builders bucket with
toDate(toTimeZone(time, {timezone:String}))(ortoStartOfHour), passingctx.timezone || "UTC"; a baretoDate(time)buckets by UTC day and shifts evening traffic to the next day. Date-only ranges are bound in the same timezone:SimpleQueryBuilderturnstoDateTime({startDate:String})andtoDateTime(concat({endDate:String}, ' 23:59:59'))into timezone-aware bounds for non-UTC requests, so write those forms and never convert dates by hand; totals and daily buckets then cover the same local days. - AI agent analytics has one source per list. The agent registry (ids, names, products, purposes) is
packages/shared/src/utils/bot-detection/ai-agents.ts, generated from the vendoredwell-known-bots.json; the lists the dashboard needs (FEATURED_AI_PRODUCTS,CONTENT_FORMATS,AgentPurpose,ROBOTS_ACCESS) live in the lightweightbot-detection/types.ts, because importingai-agents.tsinto the dashboard pulls the 437 KB bot JSON into the bundle. Name crawlers and products from the registry byagent_id, never from the storedbot_name(rows written before the October 2026 detector fix can hold browser engines like "WebKit" or "unknown"). The logo list (AI_ICON_COLORS, resolved withaiProductIcon) also lives inbot-detection/types.tsand must matchapps/dashboard/public/ai/*.svgand the email logos inapps/dashboard/public/ai/email/*.png;icon.test.tsenforces both and that every featured product has a logo. - Type test objects against their source type. Fake API keys must be typed as
Context["apiKey"], fake users asUser, etc. Tests are not type-checked, so this does not fail the build; it makes a partial fixture visible in review and in the editor instead of silently passing. - Never hand-write dependency versions. Use
bun add <pkg>to add dependencies. Hand-written version ranges drift from lockfile reality and cause phantom resolution bugs. - Shared test helpers over local copies.
expectCode,userContext,apiKeyContext, env setup — these live in@databuddy/test. If you're about to define a helper that already exists there, import it instead. - Scope maps must match. If
RESOURCE_SCOPE_OVERRIDESchanges inpackages/api-keys/src/scopes.ts, the integration tests inlink-handlers.test.tsandwith-workspace.test.tsmust be updated to match. The link resource is mapped there (read:linksfor read,write:linksfor create/update/delete), as is the flag resource (manage:flagsfor create/update/delete). Both are enforced solely bywithWorkspace; there are no separate pre-check layers. New resources also need role grants inpackages/auth/src/permissions.ts(statement plus each role). - Identity columns are governed by
packages/db/src/clickhouse/identity.ts.PROFILE_ID_TABLESlists every ClickHouse table carryingprofile_id;identity.test.tsenforces that each entry has a CREATE column, an idempotent migration, andprofile_id+anonymous_idin the agent SQL allowlist. Addingprofile_idto a table means adding it there and following the failing test. Query-side identity stitching must useEVENTS_VISITOR_KEY/CUSTOM_EVENTS_VISITOR_KEY/visitorMatch()from that module — never inline the expression. Analytics columns labelled visitors useuniq(anonymous_id)so every page reports the same number as the overview;uniq(session_id)counts sessions and must be labelled that way. Profile write semantics (trait splitting, upserts) live in@databuddy/services/identity;profile_idvalues are customer-supplied and are never salted, unlikeanonymous_id.
The project has a formal AI usage policy (AI_POLICY.md). For contributions: all AI usage must be disclosed, PRs must reference an accepted issue, and all AI-generated code must be fully human-verified. Maintainers are exempt and may use AI at their discretion.