# Self-hosted (your database)

In self mode the app hosts the ingest route and the store; nothing leaves your infrastructure. The SDK, the CLI, the statistics and the MCP server are the same as in the 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 counts and a verdict headline |
| GET | `/results/:id` | the full verdict |
| GET | `/health` | liveness |

The handler accepts up to 500 events per batch and 4,096 characters of serialized goal props. Set `adminToken` in production; without it, results and status are 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
  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` or `goal` |
| `experiment`, `variant` | text | for exposures |
| `goal` | text | for goals |
| `visitor_id`, `session_id` | text | the `sh_vid` cookie / the session |
| `ts` | bigint | client time, unix ms |
| `url` | text | page |
| `props` | json | goal payload, e.g. `{ "revenue": 49 }` |
| `received_at` | bigint | server time, unix ms |
| `host`, `country`, `device`, `browser` | text | optional request metadata for breakdowns |

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.

## Reports into your warehouse (cloud mode)

If you use the cloud but want data in your own database: CSV export per experiment (summary, daily timeline, raw events up to 50k rows), a webhook channel for alerts and the digest, or a cron that calls `GET /v1/status` and `GET /v1/results/<id>` with the API key and stores the verdicts. Raw events live 30–90 days in the cloud, aggregates a year: 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; use `npx splithub results` or `@splithub/stats` for the verdict.
