# Self-hosted (your database)

In self mode the app hosts the ingest route and the store; exposures, goals, pageviews and events stay in your database and nothing leaves your infrastructure. The SDK, the CLI and the MCP server are the same as in the cloud; reads are raw counts, while verdicts and analytics reports come with SplitHub Cloud. It is free and has no limits.

```ts
// splithub.config.ts
storage: { mode: 'self', endpoint: '/api/splithub' }
```

## The handler

`@splithub/node` exports one fetch-style handler that works in Next.js route handlers, Remix, Hono, Bun, Deno and plain Node (`toNodeListener` for Express-style servers).

```ts
import { createSplitHubHandler, toNodeListener } from '@splithub/node';
import { sqliteStore } from '@splithub/adapter-sqlite';
import config from './splithub.config';

const handler = createSplitHubHandler({
  config,
  store: sqliteStore({ file: './splithub.db' }),
  adminToken: process.env.SPLITHUB_ADMIN_TOKEN, // protects status and results; health remains public
});

// Next.js app router: export const POST = handler; export const GET = handler;
// Express / Vite / http: app.use('/api/splithub', toNodeListener(handler));
```

Routes relative to the mount path:

| Method | Path | Purpose |
|---|---|---|
| POST | `/` | ingest a batch from the SDK |
| GET | `/status` | every experiment with exposures, visitors and days |
| GET | `/results/:id` | visitors, conversions, rate and revenue per variant |
| GET | `/properties` | every analytics property with raw counts (`?days=30`, or `?from=&to=`) |
| GET | `/properties/:id` | one property's raw counts |
| GET | `/properties/:id/events` | its latest raw pageviews and events (`?before=&limit=`) |
| GET | `/health` | liveness |

The handler accepts up to 500 events per batch and 4,096 characters of serialized props. Set `adminToken` in production; without it, every read above is public.

The CLI reads `status` and `results` from the local dev server (`localhost:3000` for Next.js, `5173` for Vite) or from `--url https://your.app/api/splithub`, with `--token` / `SPLITHUB_ADMIN_TOKEN`.

## Stores

| Store | Package | Notes |
|---|---|---|
| SQLite | `@splithub/adapter-sqlite` | `node:sqlite`, Node 22.13+, zero setup; fine for one server |
| Cloudflare D1 | `@splithub/adapter-d1` | for Workers; `d1Schema()` gives the DDL |
| Postgres | generated | `npx splithub init --mode self --db postgres` writes `splithub/store.ts` using `pg` |
| MySQL | generated | `--db mysql`, uses `mysql2` |
| anything else | `--db custom` | a documented stub with the three methods to fill in (Prisma, Drizzle, Supabase, Mongo…) |

## The Store interface

```ts
interface Store {
  insertEvents(events: StoredEvent[]): Promise<number>;                      // skip existing ids, return inserted count
  variantCounts(experiment: string, goal: string): Promise<VariantCounts[]>;  // per variant: exposures, conversions, revenue
  summaries(): Promise<ExperimentSummary[]>;                                 // per experiment: exposures, visitors, first/last ts
  propertyCounts?(property: string, since: number, until?: number): Promise<PropertyCounts>; // analytics: raw counts
  propertyEvents?(property: string, options: { before: number; limit: number }): Promise<StoredEvent[]>; // newest rows first
  close?(): Promise<void> | void;
}
```

Attribution rule, keep it exactly so self-hosted results equal the cloud's: **a visitor converts for a variant when they sent the goal at or after their first exposure to that variant; exposures and conversions count unique visitors.** `createMemoryStore()` is the reference implementation.

## The events table

One table, one row per event. Column names are yours; the generated stores use these:

| column | type | meaning |
|---|---|---|
| `id` | text, primary key | client-generated, unique; dedupe on it |
| `type` | text | `exposure`, `goal`, `page`, `event`, `engage` or `identify` |
| `experiment`, `variant` | text | for exposures; on analytics rows `experiment` holds the property id |
| `goal` | text | for goals; on `event` rows the event name |
| `visitor_id`, `session_id` | text | the `sh_vid` cookie / the session |
| `ts` | bigint | client time, unix ms |
| `url` | text | page |
| `props` | json | goal or event payload, e.g. `{ "revenue": 49 }`; a page's title and referrer; identify traits |
| `received_at` | bigint | server time, unix ms |
| `host`, `country`, `device`, `browser` | text | optional request metadata for breakdowns |
| `platform`, `app_version` | text | `web` for websites, else the app's platform (`ios`, `android`…) and version |

Indexes: `(type, experiment, visitor_id, ts)` and `(type, goal, visitor_id, ts)`.

## Attribution in SQL

```sql
WITH first_exposure AS (
  SELECT visitor_id, variant, MIN(ts) AS first_ts
  FROM splithub_events WHERE type = 'exposure' AND experiment = $1
  GROUP BY visitor_id, variant
)
SELECT fe.variant,
       COUNT(DISTINCT fe.visitor_id) AS exposures,
       COUNT(DISTINCT g.visitor_id)  AS conversions,
       COALESCE(SUM((g.props->>'revenue')::numeric), 0) AS revenue
FROM first_exposure fe
LEFT JOIN splithub_events g
  ON g.type = 'goal' AND g.goal = $2 AND g.visitor_id = fe.visitor_id AND g.ts >= fe.first_ts
GROUP BY fe.variant;
```

Prisma or Drizzle: map the table in your schema and run this one raw query for `variantCounts`. Supabase: the same SQL as a Postgres function called through `rpc`. Mongo: `$group` by variant and visitor with `$min ts`, then `$lookup` goals.

## Analytics

Self-hosted projects keep their [analytics properties](/docs/analytics) in the config file. `npx splithub analytics create --name "Marketing site"` writes one with a generated id:

```ts
// splithub.config.ts
properties: {
  'sh-x1h78w': { name: 'Marketing site' },
},
```

Name it on the provider (`property="sh-x1h78w"`) and the handler stores its `page`, `event`, `engage` and `identify` rows in the same table. Rows of a property that is not in the config or not running are dropped. Each `event` and `page` row also converts the experiments whose goal is that event or page, so one `track()` call serves both.

Reads are raw counts for a range of UTC days: pageviews, visitors, visits, identified users and events by name.

```http
GET /api/splithub/properties?days=7
GET /api/splithub/properties/sh-x1h78w?from=2026-10-01&to=2026-10-31
```

`npx splithub analytics sh-x1h78w --days 7` prints the same. The SQLite, D1 and generated stores implement `propertyCounts`; a custom store without it answers `501` on a property's counts, and `propertyCountsSql()` from `@splithub/node` gives the queries for the SplitHub table layout. Reports with pages, sources, channels, visits, conversions, filters, funnels, paths and retention come with SplitHub Cloud.

## Reports into your warehouse (cloud mode)

If you use the cloud but want data in your own database: CSV export per experiment and per property (summary, daily timeline, raw events), a webhook channel for alerts and the digest, or a cron that calls `GET /v1/status`, `GET /v1/results/<id>` and `GET /v1/properties/<id>/summary` with the API key and stores the answers. Raw events are a short tail in the cloud (7 days on Free, 30 with packs or Pro) while daily numbers stay much longer: pull summaries, not raw rows, for long-term reporting.

## Do not

- Put database credentials in `splithub.config.ts`; it ships to the browser. They belong in the route's environment.
- Compute winners from raw rates in SQL: raw rates ignore sample size and peeking. Verdicts come with SplitHub Cloud.
- Read analytics counts as an experiment result: use `npx splithub results` for experiments.
