# Storage (/docs/storage)



`@pleach/core` ships storage adapters that all implement the same
`StorageAdapter` interface. The application code that calls
`runtime.createSession()` or `runtime.executeMessage()` is the same
regardless of which adapter is wired.

```typescript
import {
  MemoryAdapter,
  IndexedDBAdapter,
  SupabaseAdapter,
  createSupabaseAdapter,
  type SupabaseAdapterConfig,
  // Provider-agnostic Postgres — inject any pg-shaped client
  PgStorageAdapter,
  createPgStorageAdapter,
  type PgStorageAdapterConfig,
  type PgClientLike,
} from "@pleach/core/sessions";
```

<Callout type="warn" title="Cross-restart durability requires wiring an adapter">
  The runtime &#x2A;*defaults to `MemoryAdapter`** — everything lives in process
  memory and disappears on restart. Session restore/resume across a restart
  only works once you wire a **durable** `StorageAdapter` into
  `SessionRuntimeConfig.storage`. The recommended provider-agnostic default is
  [`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres) (inject
  your own Postgres client); `SupabaseAdapter` is the hosted-Supabase option and
  `IndexedDBAdapter` the browser option. Durable persistence of the **event log**
  is a separate wiring step — pair with
  [`createPgEventLogWriter`](#durable-event-log-writer) as `eventLogWriter`.
</Callout>

`createSupabaseAdapter(config)` is the preferred way to wire the
Supabase adapter — it routes through the chat-session-link registry
so RLS and JWT plumbing land correctly. Reach for
`new SupabaseAdapter(config)` only when you're explicitly opting out
of the registry.

<SourceMeta
  subpath="@pleach/core/sessions"
  source="[
  { label: &#x22;src/sessions/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/sessions&#x22; },
  { label: &#x22;src/store/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/store&#x22; },
]"
/>

## The state-and-persistence cluster [#the-state-and-persistence-cluster]

Storage adapter is one of three concepts paired with
[checkpointing](/docs/checkpointing) (the rewind axis) and
[sync version vector](/docs/sync) (the concurrent-writer axis).
The cluster sits below the execution graph and above the schema
bundle; together the three carry session state across restarts,
rewinds, and concurrent writers. The full triplet framing lives
at
[Concept clusters → State-and-persistence](/docs/concept-clusters#state-and-persistence-cluster);
the rest of this page is the deep dive on the adapter.

## Picking an adapter [#picking-an-adapter]

| Adapter            | Environment   | When to reach for it                                                                                                                                                                                                 |
| ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MemoryAdapter`    | Any           | Tests, local dev, ephemeral demos — **non-durable** (default; lost on restart)                                                                                                                                       |
| `IndexedDBAdapter` | Browser       | Offline-first PWAs, browser extensions, multi-device drafts                                                                                                                                                          |
| `PgStorageAdapter` | Server (Node) | **Recommended durable default.** Any Postgres — node-postgres, Neon, RDS, pglite, Supabase's underlying pg — via an injected `PgClientLike`. See [`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres). |
| `SupabaseAdapter`  | Server (Node) | Hosted Supabase specifically — PostgREST client, Realtime, RLS + JWT registry plumbing                                                                                                                               |
| pglite (local)     | Server (Node) | Durable *local* dev — chats survive a restart with no cloud account. Built into `pleach dev --sql`; see [Durable local dev](#durable-local-dev).                                                                     |

All three implement the same interface — swapping is a one-line
change at runtime construction. The line is the `storage` field on
`SessionRuntimeConfig`: replace `new MemoryAdapter()` with
`createSupabaseAdapter({ client: supabase })` and every call site
that already runs against `runtime.createSession()` or
`runtime.executeMessage()` keeps working. The `StorageAdapter`
interface is the contract that makes this swap safe — see
[Common interface](#common-interface) below for the exact shape.

## `MemoryAdapter` [#memoryadapter]

Zero config; everything lives in process memory and disappears on
restart.

```typescript
import { SessionRuntime } from "@pleach/core";
import { MemoryAdapter } from "@pleach/core/sessions";

const runtime = new SessionRuntime({
  storage: new MemoryAdapter(),
  userId: "user_123",
});
```

This is what `HARNESS_MOCK_MODE=true` wires automatically — if
you've set that env var, you don't need to pass `storage` at all.
Concretely: state lives in a `Map<sessionId, SessionState>` on the
adapter instance, `listSessions` is an iteration over that map's
values with the filter predicate applied in-process, and the next
restart starts from empty. Use it for unit tests that need a clean
session per `beforeEach` without a database round trip.

## `IndexedDBAdapter` [#indexeddbadapter]

Persists sessions to the browser's IndexedDB store. Survives page
reloads; available offline.

```typescript
import { SessionRuntime } from "@pleach/core";
import { IndexedDBAdapter } from "@pleach/core/sessions";

const runtime = new SessionRuntime({
  storage: new IndexedDBAdapter({ dbName: "pleach-sessions" }),
  userId: currentUser.id,
  clientId: getOrCreateClientId(),
});
```

`dbName` is the only constructor option; the schema version of the
IndexedDB store is managed internally. Default is `"harness-sessions"`.

The adapter opens IndexedDB with a 5-second timeout — if another tab
holds an open connection with a different schema, the open fires
`onblocked` and the next call rejects with
`IndexedDB open timed out — database may be blocked or unavailable`.
The store auto-indexes on `userId`, `organizationId`, `updatedAt`, and
`lastActiveAt` so `listSessions` filters resolve without a table scan.

Pair with `IndexedDBSaver` for offline checkpointing and the
client-side sync coordinator to push changes to a server adapter
when the network returns — see [Checkpointing](/docs/checkpointing)
and `@pleach/core/sync`.

## `SupabaseAdapter` [#supabaseadapter]

Persists sessions to a Postgres database via the Supabase client.
RLS-bound when constructed from a per-request client.

```typescript
import { createClient } from "@supabase/supabase-js";
import { SessionRuntime } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

const runtime = new SessionRuntime({
  storage: createSupabaseAdapter({ client: supabase }),
  userId: req.user.id,
  organizationId: req.user.orgId,
});
```

`SupabaseAdapterConfig` accepts three optional fields:

| Field             | Default              | Effect                                                                              |
| ----------------- | -------------------- | ----------------------------------------------------------------------------------- |
| `tableName`       | `"harness_sessions"` | Override if you renamed the table in your migrations                                |
| `enableRealtime`  | `false`              | Subscribe via Supabase Realtime for cross-process change fan-out                    |
| `chatSessionLink` | registry lookup      | Inject a `ChatSessionLink` to mirror `chatId → sessionId` into `chat_session_links` |

### Schema [#schema]

The adapter expects the harness schema bundle to be applied.
Scaffold it once per project:

```bash
npx pleach init --apply --target ./supabase/migrations
```

Then run your usual Supabase migration flow (`supabase db push`,
or apply each `*.sql` by hand with `psql`). The bundle ships
`CREATE ... IF NOT EXISTS` DDL so re-running is safe.

### Browser vs server clients [#browser-vs-server-clients]

| Constructed from            | Authority                      | Use case                          |
| --------------------------- | ------------------------------ | --------------------------------- |
| `SUPABASE_ANON_KEY`         | RLS-bound to the signed-in JWT | Direct-from-browser writes        |
| `SUPABASE_SERVICE_ROLE_KEY` | Bypasses RLS                   | API routes, server-rendered pages |

`userId` and `organizationId` on the runtime are the scoping fields
the RLS policies in the schema bundle key against. Service-role
clients still need to set them so the audit ledger attributes
rows correctly.

## `PgStorageAdapter` — provider-agnostic Postgres [#pgstorageadapter--provider-agnostic-postgres]

The recommended durable default when you are **not** on hosted Supabase.
`PgStorageAdapter` implements the same `StorageAdapter` interface as
`SupabaseAdapter&#x60;, but talks raw SQL through an injected &#x2A;*`PgClientLike`** —
so it runs on *any* Postgres provider. `PgClientLike` is a minimal structural
type; anything with a parameterized `query(sql, params)` that resolves to
`{ rows }` satisfies it:

```typescript
interface PgClientLike {
  query<Row>(sql: string, params?: readonly unknown[]): Promise<{ rows: Row[] }>;
}
```

That one seam is the "tune to any provider" mechanism — you construct your
own driver and pass it in. `@pleach/core` adds **no** Postgres driver as a
dependency: you own the client.

```typescript title="(a) node-postgres"
import { Pool } from "pg";
import { SessionRuntime } from "@pleach/core";
import { createPgStorageAdapter } from "@pleach/core/sessions";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const runtime = new SessionRuntime({
  storage: createPgStorageAdapter(pool),
  userId: req.user.id,
});
```

```typescript title="(b) Neon serverless"
import { Pool } from "@neondatabase/serverless";
import { createPgStorageAdapter } from "@pleach/core/sessions";

const storage = createPgStorageAdapter(
  new Pool({ connectionString: process.env.DATABASE_URL }),
);
```

```typescript title="(c) Supabase's underlying pg (or any pg-shaped client)"
import { createPgStorageAdapter } from "@pleach/core/sessions";

// Bring your own client — RDS Data-API shim, a pooled pg client, pglite, …
const storage = createPgStorageAdapter(myPgClient, {
  tableName: "harness_sessions", // optional override
  schemaName: "public",          // optional schema qualifier
});
```

`PgStorageAdapterConfig` accepts:

| Field        | Default              | Effect                                                          |
| ------------ | -------------------- | --------------------------------------------------------------- |
| `client`     | *(required)*         | The injected `PgClientLike` — your Postgres driver              |
| `tableName`  | `"harness_sessions"` | Override if you renamed the table                               |
| `schemaName` | *(unqualified)*      | Qualify the table (e.g. `"pleach"`) for a namespaced deployment |

Semantics match `SupabaseAdapter`: full `SessionState` persisted as a JSONB
`state` column, soft delete via `deleted_at`, and optimistic concurrency via
`conditionalUpdate` (a version-guarded `UPDATE … RETURNING id`, so it is
portable across drivers regardless of their affected-row-count conventions).

### Schema [#schema-1]

`PgStorageAdapter` targets the **shipped** `harness_sessions` DDL —
`src/schema/postgres/001_harness_sessions.sql` in the `@pleach/core` repo, the
same vanilla-Postgres-portable bundle `pleach init` scaffolds. Apply it once
(the DDL is `CREATE … IF NOT EXISTS`, so re-running is safe), then point the
adapter at it. Do not hand-write a divergent schema.

The shipped DDL types `id` (and `organization_id`) as `UUID`, so `session.id`
and `organizationId` must be UUID-shaped strings — the runtime mints UUIDv7
session ids by default. If you need non-UUID ids, point the adapter at a table
whose id columns are `TEXT`.

### Durable event-log writer [#durable-event-log-writer]

Persisting sessions is one axis; persisting the **event log** (the source of
truth for restore, replay, and audit) is a separate one. Pair the adapter with
`createPgEventLogWriter` — the provider-agnostic sibling of the Supabase-bound
`EventLogWriter`, backed by the *same* `PgClientLike`:

```typescript
import { createPgStorageAdapter } from "@pleach/core/sessions";
import { createPgEventLogWriter } from "@pleach/core/eventLog";
import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  storage: createPgStorageAdapter(pool),
  eventLogWriter: createPgEventLogWriter(pool, {
    tenant: { id: "acme" }, // optional multi-tenant attribution
  }),
  userId: req.user.id,
});
```

It writes append-only rows to the shipped `harness_event_log` table
(`src/schema/postgres/003_harness_event_log.sql`), stamping a per-chat
`sequence_number` (cold-start `MAX + 1`), `payload_hash`, `tenant_id`,
`domain`/`kind`, and `severity`. Writes are fire-and-forget (`write()` never
throws into a hot path; failures route to `onError`); `flush()` awaits the
serialized write tail. It is a lean durable writer — it does **not** compute
the C9 tamper-evidence `prev_hash`/`row_hash` chain (those columns stay NULL);
wire the full `EventLogWriter` if you need the hash chain.

<Callout type="warn" title="Single writer per chat">
  `sequence_number` is allocated in-process (cold-start `MAX + 1`), which is
  race-free within one writer instance but **not across instances**. Two
  concurrent writers for the same chat — e.g. two serverless invocations — can
  cold-start from the same `MAX` and emit duplicate ordinals silently. In a
  serverless / multi-pod deployment, guarantee one writer per chat, or use the
  Supabase-bound `EventLogWriter` with its atomic per-chat sequence RPC. The
  `chat_id`/`session_id` you pass must be UUID-shaped (the shipped `003` columns
  are `UUID`).
</Callout>

## Common interface [#common-interface]

Every adapter implements the same shape. You rarely call it
directly — `SessionRuntime` does — but the type is exported for
testing and for writing custom adapters:

```typescript
import type { StorageAdapter } from "@pleach/core";

interface StorageAdapter {
  createSession(session: SessionState): Promise<void>;
  getSession(id: string): Promise<SessionState | null>;
  updateSession(id: string, session: SessionState): Promise<void>;
  deleteSession(id: string): Promise<void>;
  listSessions(filter?: SessionFilter): Promise<SessionSummary[]>;
  conditionalUpdate?(
    id: string,
    session: SessionState,
    expectedVersion: number,
  ): Promise<boolean>;
}
```

`listSessions` returns `SessionSummary[]` — the light shape for
sidebar rendering, not the full `SessionState`. The summary carries
`id`, `title`, `userId`, `organizationId`, `updatedAt`, and
`lastActiveAt`; rendering 200 entries in a sidebar reads no message
bodies and no channel state. `conditionalUpdate` is the
optimistic-concurrency hook; adapters that implement it let the
runtime detect lost-update races on `(id, version)` and surface them
rather than overwrite blindly. When `conditionalUpdate` returns
`false`, the runtime treats it as a `SYNC_VERSION_MISMATCH` (code
`3003`) — the application reaches for the rollback path or pulls the
remote version before retrying. Event log and checkpoint writes go
through their own interfaces (`EventLogWriter`, `Checkpointer`),
not through `StorageAdapter`.

For **any Postgres** store, reach for
[`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres) first —
inject your driver rather than reimplementing the interface. Build your own
adapter (Redis, DynamoDB, SQLite) only for non-Postgres stores by implementing
this interface against your store of choice. Pass it as `storage` on the
runtime config — application code stays unchanged.

## Cache adapter [#cache-adapter]

The optional `cache` field accepts a second `StorageAdapter`. The
runtime reads from cache first and falls back to the primary
store on miss. Typical pairing: `SupabaseAdapter` as primary,
an `IORedisAdapter` (BYO) as cache. The cache is invalidated by
the runtime on every write — no manual `cache.del()` calls.

A concrete walk-through for a knowledge-base assistant: a user
opens a chat sidebar; the React layer reads
`listSessions({ userId: "user-7", organizationId: "org-acme" })`,
the cache returns 50 summaries in \~2ms, and `getSession("session-018f-...")`
hits the cache for the active session without a Postgres round
trip. When the next `executeMessage` mutates the session, the
runtime writes to Supabase and invalidates the cache key — the
sidebar's next read pulls the new `updatedAt` automatically.

## Durable local dev [#durable-local-dev]

You don't need a cloud account to see durability work. `pleach dev --sql`
backs the playground route with a local [pglite](https://pglite.dev)
(Postgres-in-WASM) store — so chats and checkpoints **survive a server
restart**, with no Docker and no Supabase project:

```bash
npm install @electric-sql/pglite      # optional peer, one time
npx pleach dev --sql                  # store lives under ./.pleach-dev-sql
```

Because pglite is real Postgres, this is the only local option that
demonstrates the *enforced* multi-tenant boundary end-to-end: the same
`current_tenant()` GUC function + row-level-security policy that
`SupabaseAdapter` relies on in production runs in-process, so a cross-tenant
read is **refused by the database**, not just filtered in application code.

The same store backs the value-prop battery (`npm run ci:devharness-sql` in
the `@pleach/core` repo), which asserts nine durable properties the in-memory
default cannot show headlessly:

1. **Restart-restore reconstructed in-graph** — a fresh `SessionRuntime` pointed
   at the durable store calls `resumeSession`, then a follow-up turn continues the
   conversation through real graph execution (not a detached `reconstructSessionState` fold).
2. **Per-tenant SQL cost rollup** — `GROUP BY tenant_id` over the event log.
3. **RLS tenant isolation** — the database refuses a cross-tenant read.
4. **Durable checkpoints** — survive a restart at `_durability.level: "disk"`.
5. **`@pleach/observe` `postgres()` destination** — each provider call recorded as
   a SQL-queryable row in `pleach_observe_calls`.
6. **Time-travel fork** — `TimeTravelAPI.fork` cross-session lineage survives a restart.
7. **SQL-queryable audit ledger** — `harness_auditable_calls` FinOps (`GROUP BY
   tenant_id`) + SOC2 (every call classified) rollups.
8. **Tamper-evident hash chain** — a recursive-CTE linkage verifier catches a
   deleted/reordered row; a content-hash recompute catches a mutated payload.
9. **Deterministic replay** — `@pleach/replay`'s `ReplayClient` folds the durable
   log over the injected reader; fold-from-DB byte-equals `hydrateFromEvents`, and
   `createStrictHandleReplay` confirms independent replays don't diverge.

It's the executable reference for wiring your own SQL `StorageAdapter` +
`Checkpointer` + event-log writer + **event reader** (the agnostic
[`HarnessEventReader`](/docs/event-log-projections#backing-the-reader) behind
`runtime.events.iterate`) + audit ledger + observe destination.

For a non-Postgres engine, the [Custom storage adapter recipe](/docs/recipes#3-custom-storage-adapter-sqlite)
shows the same shape against SQLite (`better-sqlite3`) — note that SQLite can
attribute cost per tenant but cannot *enforce* RLS isolation; that needs
Postgres/pglite.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="SessionRuntime" href="/docs/session-runtime" description="Construct the runtime, create sessions, execute messages." />

  <Card title="Checkpointing" href="/docs/checkpointing" description="Pair the storage adapter with a checkpointer for rollback." />

  <Card title="CLI" href="/docs/cli" description="`pleach init` for scaffolding the schema bundle." />

  <Card title="Subpath exports" href="/docs/subpath-exports" description="What `@pleach/core/sessions` and `@pleach/core/schema` actually export." />
</Cards>
