pleach
Architecture

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.

Subpath@pleach/core/sessionsSourcesrc/sessions/Sourcesrc/store/

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

AdapterEnvironmentWhen to reach for it
MemoryAdapterAnyTests, local dev, ephemeral demos — non-durable (default; lost on restart)
IndexedDBAdapterBrowserOffline-first PWAs, browser extensions, multi-device drafts
PgStorageAdapterServer (Node)Recommended durable default. Any Postgres — node-postgres, Neon, RDS, pglite, Supabase's underlying pg — via an injected PgClientLike. See PgStorageAdapter.
SupabaseAdapterServer (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:

FieldDefaultEffect
tableName"harness_sessions"Override if you renamed the table in your migrations
enableRealtimefalseSubscribe via Supabase Realtime for cross-process change fan-out
chatSessionLinkregistry lookupInject 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/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

Constructed fromAuthorityUse case
SUPABASE_ANON_KEYRLS-bound to the signed-in JWTDirect-from-browser writes
SUPABASE_SERVICE_ROLE_KEYBypasses RLSAPI 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.

(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,
});
(b) Neon serverless
import { Pool } from "@neondatabase/serverless";
import { createPgStorageAdapter } from "@pleach/core/sessions";

const storage = createPgStorageAdapter(
  new Pool({ connectionString: process.env.DATABASE_URL }),
);
(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:

FieldDefaultEffect
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-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 rollupGROUP 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 forkTimeTravelAPI.fork cross-session lineage survives a restart.
  7. SQL-queryable audit ledgerharness_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 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

On this page