# graphx — Temporal GraphRAG for TypeScript

Define your graph once with [Zod](https://zod.dev). Retrieve with vector search, full-text search and typed traversal. Use `asOf` to [query any point in its history](/reference#time-travel).

Currently v0.3.0. Install: `bun add graphx`

- **16** entry points, one package
- **4** server backends
- **10** cli commands
- **11** runtime dependencies

## Principles

- **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.

**Write**

```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);
```

**Serve**

```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 });
```

**Production**

```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.

```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.

```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.

```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.

```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.

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

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

## 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.

```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`.

```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.

```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.

```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.

```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.

```sh
$ graphx doctor
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.

```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`.

```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.

**default, no driver**

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

**driver: 'postgres'**

```ts
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: 'duckdb'**

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

const db = getDb('acme__alpha', { 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.

**graphx/local, Node or Bun**

```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/browser, SQLite WASM on OPFS**

```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/expo, iOS and Android**

```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);
```

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

## 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.

```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.

```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.

```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.

```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.

```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.

```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.

```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.

```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.

| 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.

```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`.

| 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 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.

```sh
$ 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
```

```sh
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

- [Time travel](https://graphx.sh/reference#time-travel): History, diffs and the change feed.
- [Ingest a vault](https://graphx.sh/reference#ingest): Markdown and wikilinks into typed edges.
- [Judgments with Jev](https://graphx.sh/reference#judgments-with-jev): Rerank, dedupe and type links by meaning.

## Links

- [Reference](https://graphx.sh/reference)
- [GitHub](https://github.com/TimMikeladze/graphx)
- [npm](https://www.npmjs.com/package/graphx)
