--- title: Storage description: "enpilink's pluggable storage adapters — memory, SQLite, and Postgres — plus registerStorageAdapter for custom backends and optional OpenTelemetry metrics export." --- Analytics events, captured logs, and config (with its audit trail) are all persisted through a single pluggable interface: the **`StorageAdapter`**. Pick a built-in engine with one environment variable, or register your own. ## Choosing an adapter ```bash ENPILINK_STORAGE=sqlite # default (dev + production) ENPILINK_STORAGE=memory # opt in to an ephemeral, file-less store ENPILINK_STORAGE=postgres # external database ``` When `ENPILINK_STORAGE` is unset, enpilink defaults to **`sqlite` in both development and production** — a durable local `enpilink.db` file. This means `enpilink dev` now persists your runtime config (e.g. enabling analytics) and captured analytics/logs **across restarts**. Set `ENPILINK_STORAGE=memory` to opt back into an ephemeral store. (`enpilink dev --mock` always uses an ephemeral in-memory store regardless of this setting — the demo seed never touches disk.) The storage engine (`ENPILINK_STORAGE`) and database path (`ENPILINK_DB_PATH`) are **environment-only** — they're read at boot to *open* the database, so they can't be edited from the Configuration tab (a value persisted to the DB could never be honoured on the next boot — a chicken-and-egg). Set them via the environment as shown here. The same applies to the listening `PORT` (see [Deploy](/quickstart/deploy)). | Adapter | Best for | Persistence | Extra setup | |---|---|---|---| | `sqlite` | default (dev + production) | embedded file on disk (`enpilink.db`) | none (ships with enpilink) | | `memory` | `--mock`, opt-in ephemeral | none (in-process, lost on exit) | none | | `postgres` | shared / external DB | your Postgres server | set a connection string | All adapters implement the same interface (events, logs, config, and the config audit trail) and return query results most-recent-first, so the Dashboard and Configuration tab behave identically regardless of engine. ### memory Zero-dependency ring buffers (default cap 5000 events/logs). Nothing is written to disk and everything is lost on exit. It is no longer the dev default — opt in with `ENPILINK_STORAGE=memory` when you want an ephemeral store. It always backs the [`--mock` demo seed](/guides/observability#demo-data-with-enpilink-dev---mock), so demo data never touches disk and vanishes on exit. ### sqlite The **default in both dev and production**: an embedded SQLite database, no server to run. The file path comes from `ENPILINK_DB_PATH` (default `./enpilink.db`, relative to the working directory, so each app gets its own file). The file is gitignored by enpilink's scaffold: ```bash ENPILINK_STORAGE=sqlite ENPILINK_DB_PATH=/var/lib/enpilink/enpilink.db ``` SQLite ships with enpilink via prebuilt native binaries (macOS-arm64 and linux-x64), so there's no native build step. ### postgres For a shared or external database, use the Postgres adapter: ```bash ENPILINK_STORAGE=postgres ENPILINK_DB_URL=postgres://user:pass@host:5432/enpilink ``` The connection string is resolved from `ENPILINK_DB_URL`, then `DATABASE_URL`, and finally the standard `PG*` environment variables (`PGHOST`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, …) if neither URL is set. The adapter creates its tables (`events`, `logs`, `config`, `config_audit`) on first use. The Postgres driver (`pg`) is **pure JavaScript** and ships with enpilink — no extra install steps and no native build. The Postgres adapter's config read-then-write is not wrapped in a transaction, so concurrent writers to the *same* config key are not strictly atomic. This is fine for single-operator admin config edits. A high-concurrency deployment that writes config from multiple processes would want a row lock or a single `INSERT ... RETURNING`. ### Firebird (documented adapter slot) Firebird is a **documented adapter slot**, not a built-in — there is no `firebird` engine shipped today. To use it, implement the `StorageAdapter` interface and register it (see below). ## Custom adapters Register a named factory before the server resolves its adapter, then select it by name: ```ts import { registerStorageAdapter } from "enpilink/server"; registerStorageAdapter("my-store", (opts) => new MyStorageAdapter(opts)); ``` ```bash ENPILINK_STORAGE=my-store ``` Your adapter must implement the full `StorageAdapter` interface: `init`, `recordEvent`, `queryEvents`, `appendLog`, `queryLogs`, `getConfig`, `setConfig` (which must append a `config_audit` row), `allConfig`, `getConfigAudit`, and `close`. Treat config values as opaque, write an audit row on every `setConfig`, and return queries most-recent-first. ### End-user auth tracking (optional) When end-user [authentication](/guides/auth) is enabled with a co-hosted authorization server, enpilink records a **user** and a **session** on every successful sign-in. Adapters may implement these **optional** methods (the built-in `memory` / `sqlite` / `postgres` adapters all do; custom adapters that omit them simply skip tracking): | Method | Purpose | | --- | --- | | `upsertUser(user)` | Insert-or-update a tracked user keyed by `sub` (`createdAt` sticks; `lastSeenAt` bumps). | | `recordSession(session)` | Record / refresh a session keyed by its id. | | `getSession(id)` | Read one session. | | `listSessions(query?)` | List sessions, most recent first (filter by `sub`, cap with `limit`). | | `listUsers(query?)` | List tracked users, most recently seen first. | | `deleteSession(id)` | **Revoke** — delete one session by id (no-op when unknown). | | `deleteUser(sub)` | Delete a user **and cascade** all their sessions. | These writes are **best-effort**: enpilink fire-and-forgets them so they never block or slow a tool call. **Tokens at rest:** a session stores only an opaque SHA-256 `tokenRef` — never the raw upstream access/refresh token — so there is no credential at rest to encrypt or leak. ## OpenTelemetry export Beyond storage, you can **mirror the analytics stream to an OpenTelemetry collector** as metrics. This is **off by default** — when disabled, no `@opentelemetry/*` package is imported, no exporter is constructed, and there is zero network activity. Enabling requires **both** of: ```bash ENPILINK_OTEL=1 OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318 ``` (`OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` is preferred if set.) No endpoint is ever hardcoded — if the endpoint var is unset, OTel stays disabled even with `ENPILINK_OTEL=1`. When enabled, each `tool_call` is exported via OTLP/HTTP as three metrics: | Metric | Type | Attributes | |---|---|---| | `enpilink.tool_call.count` | counter | `tool`, `method`, `ok` | | `enpilink.tool_call.errors` | counter | `tool`, `method` | | `enpilink.tool_call.duration` | histogram (ms) | `tool`, `method`, `ok` | Attributes are deliberately low-cardinality. The exporter runs as an additional, guarded sink alongside storage — recording is fire-and-forget and never blocks or breaks a tool call, and `--mock` data is never exported. The OpenTelemetry packages are pure JavaScript and ship with enpilink. ## Related - [Observability](/guides/observability) — what's recorded and shown - [Configuration](/guides/configuration) — `storage`/`dbPath` are bootstrap keys; retention caps - [Admin](/guides/admin) — prod admin uses the SQLite default