Hierarchical versioned storage on PostgreSQL ltree. Scoped retrieval, built-in versioning, zero runtime deps.
const lithium = new Lithium(drizzleAdapter(db));
await lithium.clusters.create({ name: "infra" });
await lithium.clusters.create({ name: "database", parentPath: "infra" });
const context = await lithium.getContext({ path: "infra" });
Memory graphs don't scale for tree-structured data. Graph traversal becomes a bottleneck. Vector search gives you "similar to X" when you need "everything under X."
PostgreSQL's ltree
handles tree queries significantly faster. Index-backed subtree lookups, not traversal. Lithium wraps it in a clean TypeScript API with built-in versioning.
| Lithium | Graph DBs | Vector DBs | |
|---|---|---|---|
| Structure | |||
| Tree hierarchy | Arbitrary graph | Flat | |
| Query speed | |||
| ltree index-backed | Graph traversal | ANN search | |
| Retrieval | |||
| Deterministic, scoped | Pattern matching | Fuzzy, similarity | |
| Versioning | |||
| Built-in, immutable | Manual | Overwrite | |
| Infrastructure | |||
| Your existing Postgres | Separate service | Separate service |
| Package | What | Size |
|---|---|---|
@lithium-ai/core |
||
| Zero-dep storage engine | ||
@lithium-ai/postgres |
||
| PostgreSQL ltree adapter | ||
@lithium-ai/drizzle |
||
| Drizzle ORM adapter | ||
@lithium-ai/mcp |
||
| MCP server for AI tools |
Prerequisites: PostgreSQL with ltree
extension.
With Drizzle:
npm install @lithium-ai/core @lithium-ai/drizzle drizzle-orm
js
import { Lithium } from "@lithium-ai/core";
import { drizzleAdapter } from "@lithium-ai/drizzle";
const lithium = new Lithium(drizzleAdapter(db));
With raw postgres:
npm install @lithium-ai/core @lithium-ai/postgres postgres
js
import { Lithium } from "@lithium-ai/core";
import { postgresAdapter } from "@lithium-ai/postgres";
import postgres from "postgres";
const sql = postgres("postgres://...");
const lithium = new Lithium(postgresAdapter(sql));
Then:
// Create hierarchy
const infra = await lithium.clusters.create({ name: "infra" });
await lithium.clusters.create({ name: "database", parentPath: "infra" });
// Create versioned entries
const entry = await lithium.entries.create({ clusterId: infra.value.id });
await lithium.entries.update({ id: entry.value.entry.id });
// Scoped retrieval: everything under "infra"
const context = await lithium.getContext({ path: "infra" });
npm install @lithium-ai/mcp
js
// server.ts
import { Lithium } from "@lithium-ai/core";
import { postgresAdapter } from "@lithium-ai/postgres";
import { serveMcp } from "@lithium-ai/mcp";
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
const lithium = new Lithium(postgresAdapter(sql), async (versionIds) => {
const rows = await sql`
SELECT entry_version_id, title, content
FROM your_content_table
WHERE entry_version_id = ANY(${versionIds})
`;
return new Map(rows.map((r) => [r.entry_version_id, r]));
});
serveMcp(lithium);
Add to Claude Code:
{
"mcpServers": {
"lithium": {
"command": "npx",
"args": ["tsx", "server.ts"]
}
}
}
Entries are pure structure. Your content lives in your own tables, referenced by entry version IDs.
Cluster
id, parentId, path ("infra.database"), name, description, createdAt
Entry
id, clusterId, createdAt
EntryVersion
id, entryId, version (auto-incremented), createdAt
Your Content Table
entryVersionId (FK), title, content, ...whatever you want
| Method | What |
|---|---|
create({ name, parentPath?, description? }) |
|
| Create cluster, resolve parent | |
findByPath({ path }) |
|
| Find by dot-path | |
list() |
|
| All clusters ordered by path | |
listDescendantIds({ path }) |
|
| ltree subtree query |
| Method | What |
|---|---|
create({ clusterId }) |
|
| New entry + version 1 | |
update({ id }) |
|
| Auto-increment version | |
get({ id, version? }) |
|
| Entry + version (latest or specific) | |
list({ clusterIds }) |
|
| Entries by cluster IDs | |
listWithLatestVersion({ clusterIds }) |
|
| Entries + latest versions (batch) |
| Method | What |
|---|---|
getContext({ path }) |
|
| Scoped retrieval with optional content resolver |
Every method returns Result<T, E>
. No thrown exceptions.
const result = await lithium.clusters.create({ name: "infra" });
if (!result.success) {
// result.error is ValidationError | NotFoundError | SystemError
// Discriminate via error.kind or instanceof
}
Drizzle users: Import the schemas and use drizzle-kit push
:
export { clusters, entries, entryVersions } from "@lithium-ai/drizzle";
npx drizzle-kit push
Raw SQL: Run the reference migrations from @lithium-ai/postgres
:
psql -d your_db -f node_modules/@lithium-ai/postgres/src/migrations/001_clusters.sql
psql -d your_db -f node_modules/@lithium-ai/postgres/src/migrations/002_entries.sql
Requires CREATE EXTENSION IF NOT EXISTS ltree;
before running.
- Core storage engine (
@lithium-ai/core
) - PostgreSQL ltree adapter (
@lithium-ai/postgres
) - MCP server (
@lithium-ai/mcp
) - Content resolver callback for
getContext
- Drizzle ORM adapter (
@lithium-ai/drizzle
) - GitHub Actions CI
-
Integration tests (testcontainers)
-
Transaction support (atomic createEntry)
-
MCP write tools (create_cluster, create_entry)
-
Example projects
-
Prisma adapter
-
AI agent memory (structured retrieval, scoped context)
-
Decision tracking across teams
-
Config versioning
-
Documentation hierarchies
Read more: Memory Graphs Don't Scale
Issues and PRs welcome.
MIT