Storage
Choose storage for the lifetime and deployment of your project. Your graph can continue using LangGraph's getStore() interface when you change the backend.
| Your project | Choose | What to verify |
|---|---|---|
| Development or isolated tests | Memory | Graph behavior; development snapshots are not production durability |
| Desktop app, CLI, daemon, or one Node owner | SQLite | Reopen the same file and read the thread, checkpoints, and memory |
| Shared server | Postgres | Resources and checkpoints survive restart |
| Multiple server instances | Postgres plus Redis | Queueing and stream observation work across instances |
SQLite supports text/filter search. Semantic indexing uses Postgres with pgvector or an external store. Start with SQLite embedding, Postgres deployment, or the driver reference below.
Graph checkpoints hold conversation state/history; store items hold values across conversations. Choose the correct lifetime before adding memory: see state and persistence and agent memory.
Verify your storage
Create a test thread, run a known input, and read its state. Write an item in a test namespace. With SQLite or Postgres, restart using the same storage configuration and read both again. For multi-instance servers, repeat the read through another instance. An embedded memory backend loses its state on process exit; the CLI's development snapshots are a separate convenience. Clean up the test thread and item afterward. Follow the project persistence checks for the expected outcomes and local crash-recovery limits.
Drivers
@skein-js/storage-memory (dev/tests — and the Redis-less production path)
- In-process maps; zero external dependencies.
- Paired with an in-memory queue and a
MemorySavercheckpointer forskein dev. storesemantic search falls back to a naive scan/embedding compare.- Not only a dev driver.
embedPostgresGraphsuses this queue and event bus whenever no Redis URL is configured, so its retention bounds apply to real traffic — see embedding.md forSKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUNandSKEIN_MEMORY_BUS_MAX_RETAINED_RUNS.
@skein-js/storage-postgres (prod)
- Backed by
pg; owns tables for assistants (+ assistant_versions)/threads/runs/store items + migrations.store.migrate()applies them on boot — idempotent, tracked in askein_migrationstable, and serialized by an advisory lock so concurrent boots queue rather than collide. The SQL is compiled into the package (no filesystem access at runtime), so the driver bundles with zero externals — see bundling.md. - Uses
@langchain/langgraph-checkpoint-postgres(PostgresSaver.fromConnString) for graph checkpoints — we wrap it rather than reimplement checkpointing. https://github.com/langchain-ai/langgraphjs/tree/main/libs/checkpoint-postgres - pgvector for semantic store search, configured from
langgraph.json'sstore.index.{embed, dims, fields}(see langgraph-cli-compat.md). pgvector is opt-in: the base schema needs no extension, so skein runs on a stock managed Postgres out of the box. Only whenstore.indexis set doesmigrate()runCREATE EXTENSION IF NOT EXISTS vectorand add theembeddingcolumn — which requires a Postgres that ships pgvector (see the provider table in deploy.md).
Indexes, and the one thing to watch on upgrade
Migration 0005_performance_indexes adds the indexes the list/search paths need: composite (created_at, thread_id) and (updated_at, thread_id) on threads matching the ORDER BY … , <id> the queries actually emit, (status, created_at) for the status filter, GIN jsonb_path_ops on threads.metadata and assistants.metadata for the metadata @> … containment the auth ownership check performs on every request, runs (thread_id, created_at), and store_items (created_at, key). runs_thread_id_idx is dropped, superseded by the composite with the same leading column.
They are built with CREATE INDEX CONCURRENTLY, so the boot migration does not hold a write-blocking lock while indexing an existing table — a plain CREATE INDEX on a large threads would stall writes for minutes, at boot, during a rolling deploy. Concurrency has one cost: such a migration cannot be transactional, so a failure partway leaves some indexes created and the ledger row unwritten, and the next boot retries the whole migration. Every statement is IF NOT EXISTS for that reason.
If an index build is interrupted (the pod is killed mid-migration, say), Postgres leaves an invalid index behind — and IF NOT EXISTS will then skip it forever, so queries silently never use it. Nothing breaks; it just stays slow. To check and fix:
-- Any invalid indexes?
SELECT c.relname FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid WHERE NOT i.indisvalid;
-- Drop each one; the next boot rebuilds it.
DROP INDEX CONCURRENTLY <name>;Semantic search: exact by default, HNSW opt-in
With store.index configured, semantic search ranks by cosine distance over an unindexedembedding column — an exact scan of every row. Correct, and fine until the store is large. Add "hnsw": true to opt into an HNSW index:
{
"store": {
"index": { "embed": "openai:text-embedding-3-small", "dims": 1536, "hnsw": true }
}
}Off by default deliberately: HNSW is an approximate nearest-neighbour index, so turning it on changes which rows a search returns. That is a semantic change, not something to inherit from an upgrade.
Enabling it also pins the column to vector(dims) — pgvector cannot index a dimensionless vector, which is how the column is created so the base schema works without knowing dims. If rows already exist at a different dimensionality (an embedder or model change), boot fails with an error saying so rather than a raw Postgres one; re-embed or clear those rows before enabling it.
Three things to know before turning it on:
- The first boot is not free. Pinning the column rewrites the table under
ACCESS EXCLUSIVE, which blocks reads and writes onstore_itemsfor the duration and queues behind any long-running query already touching it. The index build that follows is concurrent and does not block, but it does hold boot until it finishes. If pgvector warns that the graph no longer fits inmaintenance_work_mem, raise it — that is the biggest lever on build time. - Namespace-filtered search needs pgvector ≥ 0.8. HNSW selects a fixed candidate set (
hnsw.ef_search) and the namespace predicate is applied after it, so a prefixed search can return fewer rows than exist — or none. skein setshnsw.iterative_scan = strict_orderon every connection to prevent that, which requires pgvector 0.8. On an older server it warns once at startup and the post-filter behaviour stands; leavehnswoff there. - Turning it back off does not unpin the column.
hnsw: falseskips theALTER, it does not reverse it, so a laterdimschange still fails on the pinned column. Undo it by hand:ALTER TABLE store_items ALTER COLUMN embedding TYPE vector;
An interrupted build leaves an invalid index; the next boot detects it, drops it concurrently and rebuilds. That check exists because pinning a column whose index is invalid rebuilds that index inline and non-concurrently, holding ACCESS EXCLUSIVE for the whole build.
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
const checkpointer = PostgresSaver.fromConnString(process.env.POSTGRES_URI!);
await checkpointer.setup(); // idempotent migrations for checkpoint tablesBringing your own store (store.adapter)
Long-term memory is the one repo you can swap without implementing the other five. Point store.adapter at a "path:export" and skein uses it for /store/* and for the getStore() handed to every graph run; assistants, threads, runs, crons and idempotency keep using the configured driver.
{ "store": { "adapter": "./src/my-store.ts:store" } }// src/my-store.ts — LangChain's own JS long-term-memory guide builds on this exact class.
import { PostgresStore } from "@langchain/langgraph-checkpoint-postgres/store";
export const store = PostgresStore.fromConnString(process.env.POSTGRES_URI!);
await store.setup(); // top-level await: `store.adapter` imports a *ready* storeThe export may be either a LangGraph BaseStore — PostgresStore, InMemoryStore, your own — or a skein StoreRepo. They are told apart structurally (BaseStore has batch), and a mis-shaped export fails at startup naming the missing method, rather than at the first request that reaches it. Prefer BaseStore: it is the wider ecosystem, and PostgresStore brings pgvector with HNSW and IVFFlat plus a "text" | "vector" | "hybrid" | "auto" search mode — hybrid search being a capability skein's own driver does not have.
skein re-applies its own semantics rather than forwarding them, because forwarding would be wrong: InMemoryStore's search matches namespace prefixes as a raw string (so ["users"] also matches ["users2", …] — a cross-tenant read the moment a prefix is derived from a principal), ignores query without a vector index, and coerces filter operands with Number(). So the adapted store supplies a candidate set and its vector ranking; skein applies filtering, ordering and paging on top. The price is over-fetching; measure this cost with your dataset before switching adapters.
Four things worth knowing before you switch:
put does a read-back | StoreRepo.put returns the stored item; BaseStore.put returns void. So the adapter writes then reads, costing a round trip — and it is racy: a concurrent write to the same key returns the other writer's item. |
| TTL is partly expressible | store.ttl.default_ttl works — the adapter stamps it onto every write. But refresh_on_read cannot be expressed through BaseStore.get, and it defaults to enabled, so store.ttl is refused unless you set refresh_on_read: false and accept write-based expiry. A store with no sweepExpiredItems() (InMemoryStore) refuses store.ttl outright; PostgresStore has it. A silently ignored retention policy is worse than a boot error. |
| A prefix-less search fans out | Some stores reject an empty namespace prefix (PostgresStore does), so search({}) enumerates namespaces and searches each. Scores are re-sorted globally afterwards, so semantic ranking still holds — but give a prefix when you can. |
| The store may have its own rules | PostgresStore rejects namespace labels containing ., %, _ or \, and a root label of "langgraph". Items written under such a namespace by another driver are not reachable through it. |
Your store owns its items. skein imports into an adapter — skein import-langgraph, a restored .skein/dev-state.json, the one-time langgraph dev auto-import — but never snapshots back out of it, because writing whatever store you brought into .skein/dev-state.json every few seconds is not something to do to somebody's database. So an adapted InMemoryStore loses its items across a skein dev restart while a PostgresStore keeps them. skein dev prints this at startup rather than letting you find it by losing items.
store.index is refused alongside an adapter. It configures pgvector on the store the adapter replaces, so it could only ever have no effect on search. Configure the index on your own store instead — PostgresStore and InMemoryStore both take one in their constructor.
In code (no langgraph.json), the same seam is ProtocolDeps.storeItems — wrap a BaseStore with fromBaseStore first. Pass it as its own field rather than composing { ...store, store: mine }: the bundled drivers expose maxPageSize and durable as class getters over private fields, so a spread silently loses them and a prototype clone throws on read. withStoreItems is the supported way to do it by hand.
Checkpointer selection
| Storage selection | skein-js uses |
|---|---|
Memory (the default skein dev) | MemorySaver |
| Postgres | PostgresSaver |
| SQLite store/queue pair | SqliteSaver sharing the owned resource connection |
You rarely wire these drivers by hand. @skein-js/runtime assembles the PostgresSkeinStore + PostgresSaver + Redis queue/bus (and their dispose()) for you, two ways: buildRuntime from a langgraph.json (the skein dev/skein up path), and embedPostgresGraphs from a graph you hold in code (the durable sibling of embedInMemoryGraphs — see embedding.md).
Protocol resources (SkeinStore) stay separate from LangGraph checkpoints, which is what allows an in-memory dev experience with no database while the checkpoint format stays 100% LangGraph-native — so history endpoints and interrupt/resume behave exactly as LangGraph expects.
For SQLite, use embedSqliteGraphs from @skein-js/local, or the CLI's explicit SQLite pair. It assembles resources, the queue, and the upstream saver with one file owner. See local deployment for lifecycle and native packaging.
Local SQLite storage
@skein-js/storage-sqlite implements the full resource store and a persisted run queue in one exclusively owned file. @skein-js/local assembles it with LangGraph's SQLite saver. Unclaimed work persists; abandoned attempts become failures without automatic input replay.
Per-operation indexing and retention
The bundled memory, SQLite and Postgres stores implement optional StoreRepo.putWithOptions:
await store.putWithOptions!(namespace, key, value, { index: ["title"], ttl: null });
await store.get(namespace, key, { refreshTtl: false });
await store.search({ prefix: namespace, query: "meeting", refreshTtl: true });index: false stores the item without making it a text/vector search candidate. A field list limits indexed content; $ includes the whole value. Exact key reads still return the complete item. An explicit nullable TTL disables the configured default for that write. Existing put and its ttl?: number options remain source compatible. Unsupported custom stores reject requested advanced controls. The LangGraph bridge forwards indexing, write TTL and read/search refresh options, including batch operations. An external BaseStore must expose the corresponding controls; Skein does not emulate persistent expiry with process-local timers.
Thread TTL retains deletion as the default. An opt-in keep_latest strategy prunes old history without deleting the thread, root checkpoint, referenced child state or pending writes. The retention window slides when state changes; the sweep skips an active run and rearms its next check after pruning. A null TTL pins the thread. The SQLite and Postgres migrations preserve existing pins: older rows without a recorded TTL remain pinned rather than acquiring an unexpected expiry.
Pruning and rollback use LangGraph’s public saver methods. A scratch copy is written under a random, operation-specific ID, checked for existing checkpoints, before erasure. Fixed names such as <thread>::skein-rollback and <thread>::skein-prune are never used as scratch IDs because they may belong to real threads. If replay fails, the backup's actual ID is included in the error for operator recovery; retain that copy until recovery is complete. These operations require the same thread execution gate as state writes. They preserve namespace coordinates when copying history.
Checkpoint pruning retains current root/child namespaces, explicit checkpoint references and pending writes. Delta channels may also require ancestor writes until a full snapshot; those necessary ancestors remain readable after prune and copy. keep_latest removes expendable history, not checkpoint data required to reconstruct current state. Exact payload accounting is available at GET /threads/{thread_id}/storage; see diagnostics.
Retention readback
Bundled memory, SQLite, and Postgres drivers enrich thread resources with optional ttl (minutes), expires_at, and ttl_strategy. Store Item and SearchItem resources expose optional ttl and expiresAt in repository results; HTTP responses serialize expiry as expires_at. An absent field means the driver cannot report it; null means known no expiry. Thread expiry means eligible for sweeping, rather than a promise of immediate deletion. Refresh-enabled reads return the new expiry.
These fields reuse existing storage columns, require no migration, and add no required custom-driver methods. Passive tools can use refreshTtl: false to inspect without extending a sliding lifetime.
SkeinStore interface
A single interface, implemented by each driver, covering the protocol resources:
interface SkeinStore {
// assistants (derived from langgraph.json graphs, plus user-created)
assistants: AssistantRepo;
// threads: metadata + status (idle | busy | interrupted | error), plus the latest turn's error
threads: ThreadRepo;
// runs: status + queue rows (pending | running | success | error | cancelled | timeout),
// and — for a failed run — why (see errors-and-logging.md)
runs: RunRepo;
// schedules that fire runs on a cadence
crons: CronRepo;
// long-term memory: namespace/key items with optional semantic search
store: StoreRepo;
// recorded responses for `Idempotency-Key`, so a provider's retry replays instead of re-running
idempotency: IdempotencyRepo;
}Each repo exposes CRUD + list/search shaped to the Agent Protocol endpoints. Memory, SQLite, and Postgres implement the same resource contract. Their queue ownership and crash semantics differ; see local deployment.
Page bound (SKEIN_MAX_PAGE_SIZE)
Every list and search path is bounded — including when the caller passes no limit at all. The default is 1000 rows. This is a memory bound: a thread row carries the thread's mirrored graph state, so an unbounded POST /threads/search on a large deployment materializes the table twice over (the rows, then the JSON response string) inside one request.
| Surface | Bound |
|---|---|
limit on a search request | rejected above 1000 by the wire schema |
limit omitted, or a list() call | the first SKEIN_MAX_PAGE_SIZE rows (default 1000) |
assistants.count(), threads.count() | not bounded — they answer "how many match" in total |
runs.listByThread() | not bounded — run rows carry no graph state |
runs.latestForThread() | one row by construction — the thread state path uses this |
runs.listActiveRuns() (all threads) | bounded — the whole-server sweep behind POST /runs/cancel |
POST /threads/{id}/history | 100 checkpoints by default, 1000 max — a separate bound |
A custom driver's runs.latestForThread() must return the newest run by created_at descending, tie-broken on run_id descending. This ordering makes ties deterministic within the driver.
Sizing guidance is in performance.md. Set SKEIN_MAX_PAGE_SIZE to change the driver bound (maxPageSize on the store constructor and on embedPostgresGraphs do the same in code). Lowering it is the useful direction on a small container. Raising it widens what an omitted limit returns, but not the wire cap: a client-supplied limit is still rejected above 1000, deliberately, so a single request can't be made arbitrarily expensive from outside.
Truncation is not signalled on the response today — a short page is indistinguishable from the end of the results, so page with offset until you get fewer rows than you asked for. Lowering the bound below 1000 clamps a client-supplied limit silently for the same reason, so page by what you received rather than by what you requested.
Server-enforced metadata (enforcedMetadata)
ThreadSearchQuery carries a second metadata subset alongside metadata, AND-ed with it. It is set by the server, never read from a request body, and exists so the auth ownership filter is a WHERE clause instead of a full read plus an in-process pass — see agent-protocol.md.
Two subsets rather than one merged object, because a merge would silently drop one side on a key collision: a caller filtering owner: "bob" while the ownership filter requires owner: "alice" must match nothing, not one or the other. Custom drivers must apply both subsets with the same containment semantics as metadata.
Assistant versioning
AssistantRepo carries full CRUD plus a version history (LangGraph parity — see the assistants endpoints). The model is deliberately simple so nothing else has to change:
- Each assistant keeps an append-only list of immutable version snapshots (
graph_id/name/description/config/context/metadataat that version). - The live assistant row mirrors the currently-active version and carries its
versionnumber. So every existing reader —get,list, the run engine's graph resolution, thread history — keeps working unchanged; a run always resolves the assistant's active version. createseeds version 1.updatemints a new version (max + 1) and makes it active.setLatest(version)rolls the live row back to an existing snapshot without minting a new one.listVersionsreturns history newest-first (filterable by metadata, paginated). Deleting an assistant cascades its versions.
Keep migrations enabled when upgrading a persistent deployment.
Long-term memory in the graph (getStore())
Use LangGraph's getStore() inside a node to read and write values across threads. For example, a support assistant can remember a customer's preferred language between conversations. Skein supplies the store to each run; your application chooses the namespace and access rules.
Follow the memory guide for profile and collection patterns and checks that a second thread can retrieve the saved preference. Semantic search requires a configured embedding index; SQLite supports text and filter search.
Filtering and namespace traversal
Two ways to narrow a store read: by content (search's filter) and by namespace shape (listNamespaces' prefix/suffix/maxDepth). Both are honoured over HTTP and through getStore(), across the bundled storage drivers.
await store.search(["users", userId], { filter: { topic: "coffee", score: { $gte: 3 } } });
await store.listNamespaces({ prefix: ["users", "*"], suffix: ["facts"], maxDepth: 3 });filter applies to the top-level keys of an item's value, never a nested JSON path — "a.b" is the literal key "a.b". Keys are ANDed, as are multiple operators on one key. The operators are LangGraph's — $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin — and a bare scalar means equality. It is applied before paging, so a page is a page of matches.
| Case | Behaviour |
|---|---|
Ordering ($gt/$gte/…) | Numbers only on both sides. A string, boolean, null, object, array or absent key never matches |
Key absent from value | $ne and $nin match; $eq, a bare scalar, $in and every ordering operator do not |
Present JSON null | Distinguishable from absent — { deleted: null } means "present and null" |
Empty operator bag {} | States no conditions, so it matches everything |
Unknown $op, non-scalar value | 400 at the boundary, not a silent narrowing |
The numbers-only ordering rule is a deliberate departure from LangGraph, which coerces both sides with Number() and so treats "5" > 3 as true. Postgres cannot reproduce that ('abc'::numeric throws), and faithful reproduction would also make true > 0 and null >= 0 true. The divergence is limited to numeric strings.
Namespace matching is positional. "*" stands for exactly one segment, prefix anchors at the first and suffix at the last, and a path longer than the namespace never matches. A path shorter than the namespace still matches, which is what makes prefix select a subtree: ["users","*"] matches ["users","1"] and ["users","1","memories"] alike. maxDepth truncates each match to that many segments and de-duplicates, before sorting and paging — so { maxDepth: 1, limit: 10 } is ten roots, not the roots of the first ten namespaces.
Namespaces come back in ascending, element-wise order, shorter first on a shared prefix. Per-segment ordering follows the driver's collation (Postgres's database collation; UTF-16 code units in memory), which agree for ordinary segments and can differ on exotic ones like "a-b" vs "ab". The contract is the element-wise order, not a byte-exact one.
Driver authors:
StoreRepo.listNamespacesaccepts aStoreNamespaceQuery({ prefix, suffix, maxDepth, limit, offset }). Apply the driver's page bound whenlimitis omitted.
Multi-tenancy is yours to define
prefix,suffixandfilterare request parameters, not access control. An omitted prefix matches every namespace, so any caller who can reachPOST /store/items/searchcan read every item in the store — no wildcard required.
skein offers no store-scoping mechanism of its own, deliberately: who owns what, and how a namespace encodes it, is the policy that varies most between deployments. Scope it with an @auth.on.store handler that rewrites value.namespace — the pattern, and its two limits, are in agent-protocol.md.
Store item TTL
Store items can expire, matching LangGraph's store TTL. Configure it in langgraph.json under store.ttl (all durations in minutes):
{
"store": { "ttl": { "default_ttl": 1440, "refresh_on_read": true, "sweep_interval_minutes": 60 } }
}default_ttl— lifetime applied to aputthat doesn't pass its ownttl. APUT /store/itemsbody may include a per-itemttl(minutes) that overrides the default for that item.refresh_on_read(defaulttrue) — agetextends a live item's expiry by its own TTL.sweep_interval_minutes(default60) — how often the background sweeper deletes expired rows.
Expiry is enforced two ways: lazily (an expired item reads as absent from get/search/ listNamespaces even before it's swept) and by the sweeper (a periodic DELETE). With no store.ttl set, items never expire. The sweeper runs in the production runtime (skein up/build, and skein dev --store postgres); pure in-memory skein dev still enforces expiry lazily on read.
Thread TTL
Threads can expire too, configured under checkpointer.ttl — the shape LangGraph Platform documents, so the same langgraph.json works under both. Durations in minutes:
{
"checkpointer": {
"ttl": { "default_ttl": 43200, "strategy": "delete", "sweep_interval_minutes": 60 }
}
}default_ttl— lifetime applied to a thread created without its ownttl.POST /threadsaccepts a per-threadttl(minutes) that overrides it, and an explicitnullpins the thread so no TTL ever collects it.PATCH /threads/{id}can change or clear it later; a new value restarts the clock.strategy—"delete"(default) removes the expired thread;"keep_latest"retains current state, referenced namespace checkpoints and necessary ancestor writes.sweep_interval_minutes(default60) — how often the sweeper runs.
An expired thread remains readable through GET /threads/{id} and search until the sweeper collects it. With the delete strategy, collection stops any active run and removes the thread, its runs, and checkpoints.
Deleting a thread also deletes its thread-scoped schedules. Pin a thread with
ttl: nullwhen its cron must keep firing, or use a stateless cron.
Per-thread TTLs are collected even without a checkpointer.ttl configuration block. Threads without a configured or per-thread TTL live until explicitly deleted.