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.
Currently v0.3.0
~ 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:8899import { 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
Schematype. There is no generate step to forget.Nothing is erased
A delete closes a
valid_tointerval. Any read takesasOfand 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.
Supports
- SQLite
- PostgreSQL
- DuckDB
- Bun
- Expo
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 });// 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);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 });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.
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.
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 keptBulk 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.
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.
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.
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.
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 ticksVector, 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.
// 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.
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, linksTyped 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.
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 patternWalks, 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.
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 directlyThe 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.
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 0Any 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.
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 elseDurable 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.
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 whyBackend 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.
const db = getDb('acme__alpha'); // file:acme__alpha.dbdefault, no driver
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'
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 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 — 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 — 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.
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.
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 streamAn 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.
{
"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.
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-paginatedIngest 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.
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.
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 referencesTyped 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.
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 levelsRerank 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.
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.
| retrieval | top-1 | top-3 | top-10 | MRR |
|---|---|---|---|---|
hybridRetrieve | 35% | 50% | 65% | 0.449 |
hybridRetrieve + rerank | 70% | 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.
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.
| Import | What it is |
|---|---|
graphx | The server SDK: schema, connections, data layer, retrieval, temporal reads, algorithms, serving |
graphx/core | The same graph engine with no driver and no Node built-ins — for browser, native and embedded hosts |
graphx/local | openLocalDb / openMemoryDb — an owned native libSQL file or RAM namespace |
graphx/browser | openBrowserDb / createWasmClient — SQLite WASM over OPFS, inside a worker |
graphx/expo | openExpoDb / createExpoClient — Expo SQLite on iOS and Android |
graphx/pg | Registers the Postgres driver with getDb (side effect) |
graphx/duck | Registers the DuckDB driver with getDb (side effect) |
graphx/bql | bql.sh: a server over Hrana, or its embedded bun:ffi driver — driver: 'bql' |
graphx/embedders | fetch-based embedders for OpenAI, Voyage and Ollama (no SDKs) |
graphx/jev | Judgments with Jev: reranking, screening, entity resolution, typing, scoring — fetch, no SDK |
graphx/blob | S3-backed blob store for node bodies |
graphx/ingest | Ingest a YAML/markdown vault into a graph (graphx/ingest/s3 for a bucket) |
graphx/react | Inference-only React Query hooks + CDC live-sync |
graphx/mcp | Backs graphx mcp — every serving route exposed as an MCP tool |
graphx/auth | Relationship-based access control (ReBAC) on graphx |
graphx/cli | The 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 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 itBoundaries
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
asOfreads reconstruct the graph exactly. - Tenant isolation is by route construction, not by a
WHEREclause. - A namespace refuses a different embedding model until
graphx reembedswitches it.
What is a judgement 3
hashEmbedis 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 benchexists, but the README holds no captured run to reference. - DuckDB allows one writer process per namespace; two rewriting the same table raise
SnapshotConflictError. Graph.atomicneeds a namespace with no embeddings, and browser writers can still hitSQLITE_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.
Scaffolded graphx project in my-app/
cd my-app
bun install # pulls graphx from npm
bun run serve # http://localhost:8899bun 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)Build a temporal graph today
bunx graphx new my-app