Storage
MemoryAdapter, IndexedDBAdapter, SupabaseAdapter — pick the one that matches the environment and wire it into SessionRuntime.
@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.
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";Cross-restart durability requires wiring an adapter
The runtime 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 (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 as eventLogWriter.
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.
The state-and-persistence cluster
Storage adapter is one of three concepts paired with checkpointing (the rewind axis) and sync version vector (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; the rest of this page is the deep dive on the 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. |
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. |
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 below for the exact shape.
MemoryAdapter
Zero config; everything lives in process memory and disappears on restart.
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
Persists sessions to the browser's IndexedDB store. Survives page reloads; available offline.
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
and @pleach/core/sync.
SupabaseAdapter
Persists sessions to a Postgres database via the Supabase client. RLS-bound when constructed from a per-request client.
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
The adapter expects the harness schema bundle to be applied. Scaffold it once per project:
npx pleach init --apply --target ./supabase/migrationsThen 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
| 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
The recommended durable default when you are not on hosted Supabase.
PgStorageAdapter implements the same StorageAdapter interface as
SupabaseAdapter, but talks raw SQL through an injected 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:
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.
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,
});import { Pool } from "@neondatabase/serverless";
import { createPgStorageAdapter } from "@pleach/core/sessions";
const storage = createPgStorageAdapter(
new Pool({ connectionString: process.env.DATABASE_URL }),
);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
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
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:
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.
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).
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:
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 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
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
You don't need a cloud account to see durability work. pleach dev --sql
backs the playground route with a local pglite
(Postgres-in-WASM) store — so chats and checkpoints survive a server
restart, with no Docker and no Supabase project:
npm install @electric-sql/pglite # optional peer, one time
npx pleach dev --sql # store lives under ./.pleach-dev-sqlBecause 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:
- Restart-restore reconstructed in-graph — a fresh
SessionRuntimepointed at the durable store callsresumeSession, then a follow-up turn continues the conversation through real graph execution (not a detachedreconstructSessionStatefold). - Per-tenant SQL cost rollup —
GROUP BY tenant_idover the event log. - RLS tenant isolation — the database refuses a cross-tenant read.
- Durable checkpoints — survive a restart at
_durability.level: "disk". @pleach/observepostgres()destination — each provider call recorded as a SQL-queryable row inpleach_observe_calls.- Time-travel fork —
TimeTravelAPI.forkcross-session lineage survives a restart. - SQL-queryable audit ledger —
harness_auditable_callsFinOps (GROUP BY tenant_id) + SOC2 (every call classified) rollups. - Tamper-evident hash chain — a recursive-CTE linkage verifier catches a deleted/reordered row; a content-hash recompute catches a mutated payload.
- Deterministic replay —
@pleach/replay'sReplayClientfolds the durable log over the injected reader; fold-from-DB byte-equalshydrateFromEvents, andcreateStrictHandleReplayconfirms 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 behind
runtime.events.iterate) + audit ledger + observe destination.
For a non-Postgres engine, the Custom storage adapter recipe
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
Async tasks
Long-running job dispatch + cancellation — the manager, the executor contract, and the per-thread reducer.
Cache & memoization
Runtime-side cache for prepared LLM inputs — the CacheBackend contract, the always-on memory default, and the four fingerprint gaps that keep hits correct across modes.