graphx

Temporal GraphRAG for TypeScript

Define your graph once with Zod. Retrieve with vector search, full-text search and typed traversal. Use asOf to query any point in its history.

bun add graphx
curl https://graphx.sh/llms.txt

Currently v0.3.0

terminal
~ bunx graphx new my-app
Scaffolded graphx project in my-app/

  cd my-app
  bun install        # pulls graphx from npm
  bun run serve      # http://localhost:8899
graphx.config.ts
import { defineConfig, defineGraphSchema, hashEmbed } from 'graphx';
import { z } from 'zod';

export const schema = defineGraphSchema({
	nodes: {
		site: z.object({ name: z.string(), region: z.enum(['us', 'eu']) }),
		gateway: z.object({ name: z.string(), firmware: z.string() }),
		alert: z.object({ severity: z.enum(['low', 'high']) }),
	},
	edges: {
		deployedAt: { from: 'gateway', to: 'site', single: true },
		raised: { from: 'gateway', to: 'alert' },
	},
});

export default defineConfig({ schema, embedder: hashEmbed(), namespace: 'graphx' });
Entry points, one package
16
Server backends
4
CLI commands
10
Runtime dependencies
11

Runs where your data already is

SQLite and libSQL, Postgres with pgvector, DuckDB, bql.sh, SQLite WASM in a browser tab and Expo on a phone. Served through Hono and OpenAPI, typed by Zod, read by React Query and MCP clients.

  • One schema, no codegen

    Writes, routes, hooks and MCP tools infer from one Schema type. There is no generate step to forget.

  • Nothing is erased

    A delete closes a valid_to interval. Any read takes asOf and reconstructs that instant exactly.

  • One contract everywhere

    Every type, route and payload is identical across backends. The README examples compile against the build.

Write it, query it, serve it

The same Graph object writes, reads and walks. match compiles a typed pattern to one SQL statement, and createApp serves all of it with a generated OpenAPI contract.

Visit Documentation

Supports

  • SQLite
  • PostgreSQL
  • DuckDB
  • Bun
  • Expo
+ browser and bql.sh
app.ts
import { getDb, init, Graph, hashEmbed } from 'graphx';
import { schema } from './graphx.config.ts';

const embedder = hashEmbed();
const db = getDb('acme__alpha'); // one cached client per namespace (tenant)
await init(db, embedder); // tables, indexes, and the vector table at the embedder's width
const g = new Graph(db, schema, { embedder });

const site = await g.addNode({ type: 'site', data: { name: 'us-east-1', region: 'us' } });
const gw = await g.addNode({
	type: 'gateway',
	data: { name: 'gw-1', firmware: '2.1.0' },
	body: 'free text — indexed for FTS and embedded for vector search by the graph itself',
});
await g.addEdge({ rel: 'deployedAt', src: gw.id, dst: site.id });
query.ts
// GraphRAG: vector seeds, then a time-respecting walk out from them
await g.retrieve({ query: 'overheating sensor', k: 10, maxDepth: 2 });

// Pattern match — rows typed per alias, no codegen
const q = await match(schema, db)
	.node('g', 'gateway')
	.out('raised')
	.node('a', 'alert')
	.select('g', 'a');
const rows = await q.run(); // rows[0].g.data, rows[0].a.data

// Time travel: every version of a node, and what moved between two instants
await history(db, id);
await diff(db, t1, t2);
server.ts
const { app, tenant, project, user } = await createApp({
	schema,
	embedder: hashEmbed(),
	db: 'iot_demo',
	cors: true,
	openapi: { title: 'iot-fleet', servers: [{ url: 'http://localhost:8899' }] },
	seed: async (g) => {
		await g.addNode({ type: 'site', data: { name: 'us-east-1', region: 'us' } });
	},
});

Bun.serve({ port: 8899, fetch: app.fetch });
prod.ts
const app = createApp({
	control, // the shared registry of tenants, projects and memberships
	schema,
	embedder: openai(),
	authenticate: async (c) => verifyJwt(c.req.header('authorization')), // → { userId, tenantId }
	limits: { maxRows: 5_000 },
	metrics,
	readiness, // /ready stays 503 until sync-before-serve finishes
});

One schema, no codegen

Describe nodes and edges with Zod objects in defineGraphSchema. Typed writes, pattern matching, HTTP routes, hooks and MCP tools are all inferred from that one Schema type, so there is no generate step to run.

graphx.config.ts
const schema = defineGraphSchema({
	nodes: {
		site: z.object({ name: z.string(), region: z.enum(['us', 'eu', 'apac']) }),
		gateway: z.object({ name: z.string(), firmware: z.string() }),
		alert: z.object({ severity: z.enum(['low', 'high']) }),
	},
	edges: {
		// `from`/`to` constrain the endpoints; `single` makes the rel single-valued per source,
		// so each addEdge closes the previous live one. `data` validates edge payloads.
		deployedAt: { from: 'gateway', to: 'site', single: true },
		raised: { from: 'gateway', to: 'alert', data: z.object({ at: z.number() }) },
	},
	// Optional per-type embedding policy: what text a node is embedded from (default: its `body`),
	// and whether long inputs are split into chunks. A data-only type stays searchable this way.
	embedding: {
		site: { text: (d) => `${d.name} (${d.region})` },
		alert: { chunk: { size: 1200, overlap: 120 } },
	},
});

type Schema = typeof schema;

Typed writes, versioned

Call addNode, updateNode and addEdge with data checked against the schema. Pass expectedRevision and a concurrent writer surfaces as RevisionConflict instead of a silent overwrite.

app.ts
const gw = await g.addNode({
	type: 'gateway',
	data: { name: 'gw-1', firmware: '2.1.0' },
	body: '…',
});

await g.updateNode(gw.id, { data: { firmware: '2.2.0' } }); // shallow merge, opens a new version
await g.updateNode(gw.id, { body: 'edited' }); // the embedding input changed ⇒ re-embedded
await g.addEdge({ rel: 'raised', src: gw.id, dst: alert.id, data: { at: Date.now() } });
await g.deleteEdge(edgeId);
await g.deleteNode(gw.id); // closes the version and drops its vectors; the history is kept

Bulk loading in batches

bulkLoad and bulkEdges insert history-shaped rows with a shared loadTs and batched embedding. On libSQL the ANN index and FTS trigger are rebuilt once, after the load.

load.ts
import { bulkLoad, bulkEdges } from 'graphx';

await bulkLoad(db, schema, rows, { embedder, chunkSize: 500 });
await bulkEdges(db, schema, edgeRows);

Group writes into one commit

Graph.write(fn) folds a body into one DuckDB snapshot commit. On SQLite and libSQL, Graph.atomic(fn) runs one callback inside a single transaction.

atomic.ts
await g.write(async (graph) => {
	await graph.addNode({ type: 'site', data: { name: 'eu-west-1', region: 'eu' } });
	await graph.addNode({ type: 'gateway', data: { name: 'gw-2', firmware: '2.2.0' } });
});

const note = await g.atomic((scope) => scope.addNode({ type: 'note', data: { path: 'A.md' } }));

Every write is bitemporal

Versions carry valid_from and valid_to, so a delete closes an interval instead of erasing a row. Pass asOf to any read to see the graph as it stood at that instant.

Want the change stream instead? Tail changeFeed, or mount useChangeFeedSync from graphx/react.

$ bun run examples/basic-demo.tscaptured output
listNodes  1 alerts
match      [
  [ "gw-1", "high" ]
]
…
history    2 versions
asOf t0    [
  {
    name: "gw-1",
    firmware: "2.1.0",
  }
]

History, diffs and a change feed

history, diff, changeFeed and timeline read the append-only log. diff(db, t1, t2) returns the nodes and edges added, changed and removed between two instants.

time.ts
import { history, diff, changeFeed, timeline } from 'graphx';

await history(db, id); // every version of a node, oldest first
await diff(db, t1, t2); // nodes and edges added, changed and removed between two instants
await changeFeed(db, cursor, { limit: 500 }); // CDC: keyset stream of node + edge versions
await timeline(db, { buckets: 120 }); // change-point extent + density histogram + snap ticks

Vector, text and graph together

Call hybridRetrieve to fuse vector and full-text results with reciprocal rank fusion, then walk out from the seeds along edges valid at that time. Each row says which leg matched it in via.

retrieve.ts
// Vector seeds, then a time-respecting walk out from them
await g.retrieve({ query: 'overheating sensor', k: 10, maxDepth: 2, rels: ['raised'] });

// Vector + full-text fused with reciprocal rank fusion, then the same walk
await g.hybridRetrieve({
	query: 'overheating sensor',
	k: 10,
	rrfK: 60,
	rerank: async (query, candidates) => …, // optional — jevRerank() below, or your own
	mmr: { k: 10, lambda: 0.7 }, // optional diversification
});

Every read takes asOf

getNode, neighbors, listNodes and listEdges all accept asOf, limits and metrics. Served over HTTP, ServeConfig.limits caps them and a client cannot raise it.

read.ts
await g.getNode(id); // AnyNode<S> | null
await g.getNodeVersion(id); // the version row: data, body, uri, revision, valid_from/valid_to
await g.getNodeContent(id); // body + content_type + hash
await g.neighbors(id, { rels: ['deployedAt'], direction: 'forward' }); // AnyNode[]
await g.neighborsPage(id, { rels: ['raised'], limit: 100 }); // keyset-paginated
await g.listNodes({ type: 'alert', q: 'overheating', limit: 50 }); // { nodes, nextCursor }
await g.listNodeVersions({ type: 'alert', limit: 50 }); // the same page, each row with body + revision
await g.listEdges({ rel: 'raised', source: 'jev' }); // { edges: [{ id, src, dst, weight, data, … }] }
await g.graphSlice({ type: 'gateway' }); // canvas projection: ids, labels, links

Typed pattern matching

Chain match(schema, db) with .node(), .out() and .in(). It compiles to one SQL statement and returns rows typed per alias, with page() for keyset pagination over the same pattern.

match.ts
import { match } from 'graphx';

const q = await match(schema, db)
	.node('d', 'device')
	.in('raised') // .out(), .in(), .both(); rel options take weight, data and direction filters
	.node('a', 'alert')
	.select('d', 'a');

const rows = await q.run(); // rows[0].d.data and rows[0].a.data are typed per alias
const page = await q.page({ limit: 100 }); // keyset pagination over the same pattern

Walks, paths and PageRank

journey follows only edges valid at each step. pagerank, community and centrality run over a compressed mirror and persist their scores, so topNodes reads them back.

algorithms.ts
import { journey, shortestPath, pagerank, community, centrality, topNodes, buildCSR } from 'graphx';

// A time-respecting walk: only edges valid at each step are followed. `from` (epoch ms) is required.
await journey(db, { start: id, from: 0, maxDepth: 6, direction: 'forward' });

await shortestPath(db, srcId, dstId, { weighted: true, rels: ['deployedAt'] });
await pagerank(db, { damping: 0.85 }); // Map<id, score>
await community(db); // label propagation → Map<id, community>
await centrality(db, 'degree'); // 'degree' | 'in' | 'out'
await topNodes(db, { by: 'pagerank', type: 'gateway', limit: 10 }); // reads persisted analytics
await topNodes(db, { by: 'score:risk', type: 'alert' }); // or a persisted score — see scoreNodes
await buildCSR(db); // the compressed mirror the analytics run over, if you want it directly

The graph owns embedding

Every write embeds through the graph’s embedder, and re-embeds only when the input hash changes. Run graphx doctor to see the stored model, its width and how many nodes are stale.

$ graphx doctorcaptured output
namespace      graphx (libsql)
stored model   hash:768  dim=768
configured     hash:768  dim=768
live nodes     3
embedded       3  (3 vector rows)
unembedded     0
stale          0

Any embedder, one fetch

graphx/embedders ships openai, voyage and ollama as single fetch calls with no dependency. hashEmbed and fixtureEmbed run offline, and defineEmbedder wraps anything else.

embedders.ts
import { openai, voyage, ollama } from 'graphx/embedders';
import { hashEmbed, fixtureEmbed, defineEmbedder } from 'graphx';

openai('text-embedding-3-small'); // OPENAI_API_KEY, optional `dim`, `baseUrl`, `batchSize`
voyage('voyage-3'); // VOYAGE_API_KEY
ollama('nomic-embed-text'); // local, no key
hashEmbed(); // deterministic and model-free — tests, demos, offline
fixtureEmbed({ path: './fixtures/emb.json' }); // record real vectors once, replay them offline
defineEmbedder({ id: 'acme:v1', embed: async (texts) => … }); // anything else

Durable triggers on an outbox

Set events: { outbox: true } and each event is co-written in the mutation’s own transaction. A TriggerRunner delivers at least once, retries, and keeps deadLetters.

triggers.ts
import { TriggerRunner, embedTrigger, webhookAction, deadLetters } from 'graphx';

const runner = new TriggerRunner(g, {
	name: 'alerts',
	triggers: [
		{
			name: 'notify',
			match: { op: 'node.create', label: 'alert' }, // an absent field matches anything
			action: webhookAction({ url: 'https://example.com/hook', secret: process.env.HOOK }),
			retries: 5,
		},
		embedTrigger(), // the other half of `embedding: 'lazy'`
	],
});
await runner.runOnce(); // or .start() to poll
await deadLetters(db, { subscription: 'alerts' }); // what exhausted its retries, and why

Backend is configuration

Pick a store with the driver option on getDb. Every public type, method, route and payload is identical across libSQL, Postgres with pgvector and DuckDB over an object store.

libsql
const db = getDb('acme__alpha'); // file:acme__alpha.db

default, no driver

postgres
import 'graphx/pg'; // registers the Postgres driver (side effect)

const db = getDb('acme__alpha', {
	driver: 'postgres',
	connectionString: 'postgresql://user:pass@host:5432/graphx',
	// ssl?: boolean | tls.ConnectionOptions
	// poolMax?: number
});

driver: 'postgres'

duckdb
import 'graphx/duck'; // registers the DuckDB driver (side effect)

const db = getDb('acme__alpha', { driver: 'duckdb' });

driver: 'duckdb'

Runs in browsers and phones

Import from graphx/core and hand it a DbClient. openLocalDb, openBrowserDb and openExpoDb each own their connection and verify the pragmas they depend on, so a host without durable storage fails loudly.

Want it over the network instead? Serve the same graph with createApp.

node.ts
// Node or Bun — an owned native libSQL file, or a private RAM namespace
import { openLocalDb, openMemoryDb } from 'graphx/local';
import { Graph, init } from 'graphx/core';

const client = await openLocalDb('/Users/me/vault.db'); // WAL + FULL sync, verified
await init(client);
const g = new Graph(client, schema);

graphx/local, Node or Bun

browser.ts
// Browser — SQLite WASM on OPFS, inside a dedicated worker with COOP/COEP set
import { openBrowserDb } from 'graphx/browser';

const client = await openBrowserDb(sqlite3, '/vault.sqlite3');
await init(client);

graphx/browser, SQLite WASM on OPFS

expo.ts
// Expo — native SQLite on iOS and Android
import { openExpoDb } from 'graphx/expo';
import * as SQLite from 'expo-sqlite';

const client = await openExpoDb(SQLite, 'vault.db');
await init(client);

graphx/expo, iOS and Android

Typed HTTP with OpenAPI

Pass your schema to createApp and get typed routes, a generated GET /openapi.json and an interactive reference at /docs. Every route sits under /t/{tenant}/p/{project}, so tenant isolation holds by construction.

server.ts
import { createApp, hashEmbed } from 'graphx';
import { schema } from './schema.ts';

const { app } = await createApp({
	schema,
	embedder: hashEmbed(), // every write embeds through it; no dimension to configure
	db: 'iot_demo',
	openapi: { title: 'iot-fleet' },
});

// Typed routes + GET /openapi.json + an interactive reference at /docs
Bun.serve({ port: 8899, fetch: app.fetch });

Hooks with no generated client

createGraphHooks<Schema>() types every React Query hook from the schema type alone. The browser bundle carries no SDK runtime, and useChangeFeedSync invalidates exactly the keys that moved.

hooks.tsx
import { GraphProvider, createGraphHooks } from 'graphx/react';
import type { Schema } from './schema.ts';

const g = createGraphHooks<Schema>();

// <GraphProvider bootstrap="/demo"> fetches the tenant/project/user ids itself
g.useNode(id, 'gateway'); // NodeOf<Schema,'gateway'> | null
g.useNeighbors(id, { rel: 'deployedAt' }); // site[]
g.useListNodes({ type: 'alert' }); // alert[]
g.useMatch((q) => q.node('d', 'device').in('raised').node('a', 'alert').select('d', 'a'));
g.useRetrieve({ query: 'overheating sensor', k: 10 });
g.useAddNode(); // mutations: add/update/delete node, add/delete edge, bulk load
g.useChangeFeedSync(); // tails /changes and invalidates exact keys
g.useGraphEvents(); // subscribes to the SSE stream

An MCP server for free

Run graphx mcp and every serving route becomes a tool over stdio, validated against your graphx.config.ts. Add --read-only to expose only the read tools.

mcp.json
{
	"mcpServers": {
		"graphx": { "command": "bunx", "args": ["graphx", "mcp", "-c", "./graphx.config.ts"] }
	}
}

Access control in the graph

graphx/auth stores relationship tuples as edges, so auth.check is a temporal graph query. Pass asOf to ask what a user could do last week.

auth.ts
import { Auth, defineAuthModel, rel, tupleToUserset } from 'graphx/auth';

const model = defineAuthModel({
	user: {},
	folder: { parent: rel(), editor: rel(), viewer: rel().or('editor') },
	doc: {
		parent: rel(),
		editor: rel(),
		banned: rel(),
		// (direct ∪ editor ∪ viewer-of-the-parent-folder) − banned
		viewer: rel().self().or('editor').or(tupleToUserset('parent', 'viewer')).minus('banned'),
	},
});

const auth = new Auth(g, model);
await auth.write([{ object: 'doc:readme', relation: 'editor', subject: 'user:tim' }]);
await auth.check('doc:readme', 'viewer', 'user:tim'); // true
await auth.check('doc:readme', 'viewer', 'user:tim', { asOf: lastWeek }); // as it stood then
await auth.expand('doc:readme', 'viewer'); // the userset tree
await auth.listObjects('user:tim', 'viewer', 'doc', { limit: 100 }); // keyset-paginated

Ingest a markdown vault

ingestDir turns frontmatter into node data and [[wikilinks]] into typed edges. Re-running is a diff: unchanged files are skipped by content hash, and prune: true retracts deleted ones.

ingest.ts
import { ingestDir, watchDir } from 'graphx/ingest';

await ingestDir({
	dir: './vault',
	graph: g,
	source: 'notes', // namespaces the node `uri`, so two vaults never reconcile each other
	idField: 'id', // frontmatter key giving stable identity across renames
	edgeFields: { author: 'written_by' }, // frontmatter field → typed edge
	assets: { type: 'asset' }, // ![[embeds]] become nodes
	dangling: { type: 'stub' }, // links to unwritten notes become stubs
	tags: { type: 'tag' }, // #tags become shared nodes (off by default — they make hubs)
	prune: true, // retract nodes whose files vanished, scoped to this source
});

Large bodies in a blob store

createBlobStore puts bytes in S3, content-addressed, and hands back a uri for the node. presign issues a short-lived URL and gc drops what no live node references.

blob.ts
import { createBlobStore } from 'graphx/blob';

const blobs = createBlobStore({ client: s3, bucket: 'graphx', inlineLimit: 32_768 });
const ref = await blobs.put(bytes, 'application/pdf');
await g.addNode({ type: 'doc', data: { title }, uri: ref.uri, content_hash: ref.hash });
await blobs.presign(ref.uri, 900); // a short-lived download URL
await blobs.gc(liveHashes); // drop what no live node references

Typed judgments with Jev

createJev asks typed questions about one state in one request: choice, noul and score. Every answer is typed from its question and carries a confidence to gate on.

jev.ts
import { choice, createJev, noul, score } from 'graphx/jev';

const jev = createJev(); // model 'jev-latest'; retries 429, 529 and 5xx with backoff
const { answers } = await jev.ask(
	{ alert: 'gw-7 probe read 96C, fan failed' },
	{
		team: choice('Which team owns this?', { hardware: null, firmware: null, network: null }),
		urgent: noul('Does this need action today?'),
		severity: score('How severe is it?', ['cosmetic', 'degraded', 'down']),
	},
);
answers.team.choice; // 'hardware' | 'firmware' | 'network', plus probabilities and confidence
answers.urgent.noul; // probability of yes
answers.severity.score; // 0–2, landing between levels

Rerank by meaning

Pass rerank: jevRerank() to hybridRetrieve. It asks one relevance question per candidate in parallel, and onError: 'keep' falls back to the fused order.

rerank.ts
import { jevRerank } from 'graphx/jev';

// Reads TYPESAFE_API_KEY
await g.hybridRetrieve({ query: 'overheating sensor', k: 10, rerank: jevRerank() });

jevRerank({
	minScore: 0.2, // drop candidates Jev judges unlikely to be relevant
	concurrency: 8, // requests in flight
	onError: 'keep', // an outage returns the fused order instead of failing the search
	guard: { onFlagged: (c, p) => console.warn('injection', c.id, p) }, // see below
});

Rerank, measured

examples/pantheon-graph/eval-rerank.ts hides 171 figures among 1,533 records described in different words. Adding jevRerank doubles top-1 over the lexical hashEmbed baseline.

retrievaltop-1top-3top-10MRR
hybridRetrieve35%50%65%0.449
hybridRetrieve + rerank70%77%77%0.730

Resolve duplicate entities

resolveEntities finds likely pairs with graph search and asks Jev whether to leave, review or link each one. There is no threshold to tune, and rels writes the sameAs edges.

dedupe.ts
import { judgePairs, resolveEntities } from 'graphx/jev';

const report = await resolveEntities(g, {
	type: 'deity',
	fields: ['name', 'pantheon'], // what Jev sees and compares — leave `source` out
	candidates: 5, // nearest same-type neighbours judged per node
	rels: { same: 'sameAs', review: 'maybeSameAs' }, // omit to report without writing
});
report.pairs.filter((p) => p.outcome === 'review'); // the curator's queue, with per-field agreement

// Or judge pairs you already have — an audit of an earlier matcher's links, say.
await judgePairs(g, [[srcId, dstId]], { fields: ['name', 'pantheon'] });

One package, many entry points

Everything is a subpath of graphx, and each is a separate entry point. An optional peer such as pg only lands on your import path if you import graphx/pg.

ImportWhat it is
graphxThe server SDK: schema, connections, data layer, retrieval, temporal reads, algorithms, serving
graphx/coreThe same graph engine with no driver and no Node built-ins — for browser, native and embedded hosts
graphx/localopenLocalDb / openMemoryDb — an owned native libSQL file or RAM namespace
graphx/browseropenBrowserDb / createWasmClient — SQLite WASM over OPFS, inside a worker
graphx/expoopenExpoDb / createExpoClient — Expo SQLite on iOS and Android
graphx/pgRegisters the Postgres driver with getDb (side effect)
graphx/duckRegisters the DuckDB driver with getDb (side effect)
graphx/bqlbql.sh: a server over Hrana, or its embedded bun:ffi driver — driver: 'bql'
graphx/embeddersfetch-based embedders for OpenAI, Voyage and Ollama (no SDKs)
graphx/jevJudgments with Jev: reranking, screening, entity resolution, typing, scoring — fetch, no SDK
graphx/blobS3-backed blob store for node bodies
graphx/ingestIngest a YAML/markdown vault into a graph (graphx/ingest/s3 for a bucket)
graphx/reactInference-only React Query hooks + CDC live-sync
graphx/mcpBacks graphx mcp — every serving route exposed as an MCP tool
graphx/authRelationship-based access control (ReBAC) on graphx
graphx/cliThe graphx binary

Nine commands, one config

Every command except new loads graphx.config.ts. serve, mcp and triggers run the graph; doctor, reembed and dedupe maintain it.

graphx --help
graphx new      <dir>                     Scaffold a starter project
graphx serve    [-c config] [-p 8899]     Typed HTTP routes + /openapi.json + /docs
graphx ingest   <dir> [options]           Ingest a vault into the graph
graphx triggers [-c config]               Run declarative triggers over the event outbox
graphx mcp      [-c config] [--read-only] Serve the graph to an MCP client over stdio
graphx reembed  [-c config] [--dry-run]   Re-embed every live node (also switches models)
graphx doctor   [-c config]               Embedding model, width and health of the namespace
graphx fork     <namespace> [-c config]   Branch the namespace into an empty one (--as-of <ms|ISO>)
graphx dedupe   <type> [-c config] [...]  Find duplicate nodes of a type and judge them with Jev
graphx ask      "<question>" [-c config]  Plan a plain-language question as a graph call, and run it

Boundaries

Three lists, counted. The first is exercised by the test suite, the second is opinion, and the third is what you should not assume.

What holds 4

  • Every TypeScript block in the README is compiled against the built package by the test suite.
  • History is append-only: a delete closes a version, and asOf reads reconstruct the graph exactly.
  • Tenant isolation is by route construction, not by a WHERE clause.
  • A namespace refuses a different embedding model until graphx reembed switches it.

What is a judgement 3

  • hashEmbed is lexical and model-free. It suits tests and demos; retrieval quality in production is your embedder’s.
  • Delivery of triggers is at-least-once, so actions must be idempotent. That is a design choice, not a bug to be fixed.
  • Calling it “temporal GraphRAG” is our description of retrieve-then-walk, not a benchmarked claim.

What is not here yet 4

  • No storage or latency benchmarks on this page. bun run bench exists, but the README holds no captured run to reference.
  • DuckDB allows one writer process per namespace; two rewriting the same table raise SnapshotConflictError.
  • Graph.atomic needs a namespace with no embeddings, and browser writers can still hit SQLITE_BUSY.
  • The admin SPA is not published. It runs from the repository.

Start with a scaffold

Run graphx new to write a runnable graphx.config.ts, then bun run serve. Contributing to graphx itself runs the same gate CI does.

$ bunx graphx new my-appcaptured output
Scaffolded graphx project in my-app/

  cd my-app
  bun install        # pulls graphx from npm
  bun run serve      # http://localhost:8899
contributing
bun install       # from the repo root
bun test          # the full suite — runs in a temp dir that is deleted afterwards
bun run clean:db  # sweep stray scratch databases (also runs on install and pre-commit)
GRAPHX_TEST_DRIVER=bql bun test  # the suite on bql.sh, with `bun run bql:serve`'s two variables set
bun run type-check
bun run build     # bunup, every entry point
bun run dev:admin # the operator SPA against a dev server
bun run bench     # the benchmark suites
bun run bench:benchable --latest --out bench/benchable.json  # newest result as a Benchable run
bunx benchable submit --metrics bench/benchable.json           # send it (BENCHABLE_KEY, or benchable login)
bun run release                            # bump, rebuild site + package README, commit, tag, push
npm publish                                # build and publish `graphx` (root .npmrc targets it; prompts for OTP)

Guides

Build a temporal graph today

Documentation
bunx graphx new my-app