A code-generation CLI for Next.js + Prisma projects that indexes your repo as a code graph in HydraDB, grounds generation in that graph, computes the blast radius of a schema change before writing anything, and mechanically verifies the generated code against the graph before you commit.
graphyti init-graph # extract the code graph and ingest it into HydraDB
graphyti "add a heading field to Post" # retrieve → generate → blast radius → verify → write
graphyti "…" --dry-run # show blast radius + generated code, write nothing
graphyti "…" --yes # auto-confirm the blast-radius and command promptsAn LLM asked to change a Prisma schema will produce code that is locally
plausible and globally wrong: it renames Post.title to Post.heading in
schema.prisma, and leaves every API route that selects title and every
component that renders post.title untouched. Nothing in the generated file
looks like a mistake — the hallucination is structural, living in the
relationships between files rather than inside any one of them, so reading the
diff file-by-file will not catch it. Graphyti closes that gap with a code graph
of models, fields, routes, components and imports, used at three distinct
moments: grounding at generation time, so the model sees the real schema and
the real call sites instead of guessing; blast radius before writing, so
every downstream dependent of a breaking change is enumerated from the graph and
shown to you for confirmation before a single file is touched; and
mechanical verification after generation, before commit, which uses a
two-layer structural validation approach.
After code generation, Graphyti runs two independent verification passes:
-
Local deterministic check (
src/verify/verifyChange.ts) — re-parses every file in the blast radius and confirms that none still references a removed or renamed field, using the same syntactic predicate the blast radius used to select those files (src/verify/symbolRefs.ts). Sharing the predicate is what keeps the two halves honest: a verifier that judged files by a different rule would either demand changes the radius never asked for, or clear files it flagged. This is fully deterministic: no network calls, no HydraDB timing dependencies, no LLM calls. If it fails, the write is blocked immediately. -
HydraDB graph round-trip check (
src/graph/hydraVerify.ts) — stages the proposed graph state into HydraDB, queries the relations for each consumer node, and verifies that stale references to the old field are gone. This catches a class of bug that local re-parsing structurally cannot see: HydraDB's own stored relations still pointing at a deleted or renamed node, which would leave the graph out of sync with disk even after the local files are correct.
Resolution rule: the local check is the only blocking gate. If it fails, the write is blocked and the graph check is skipped entirely — there is no reason to pay for a staging/ingestion/query cycle on a result already destined for the bin.
Everything the graph check finds is a warning. That includes stale nodes, which an earlier design blocked on. Two reasons it should not:
- It is a hygiene problem, not a correctness one. When the local check has
passed, the files and the schema on disk are provably consistent. What a stale
relation degrades is future retrieval quality, until the next
init-graph— which the warning tells you to run. Rolling back correct code over that is disproportionate. - Detection is inherently racy. HydraDB re-derives relations asynchronously,
and
indexingStatus: completeddoes not mean that has happened. A correct rename shows the old and new relation side by side for as long as the previous extraction batch remains current. Blocking on it failed correct runs in practice, which is the opposite of what a gate is for.
Stale findings are still filtered before being reported: a relation is only called stale if the batch carrying it was written after the change was staged, which distinguishes a genuinely re-emitted dead reference from one that simply has not been superseded yet.
The CLI output shows both checks distinctly:
Local structural check: PASSED (3/3 files verified)
Graph structural check: PASSED (3 relations confirmed, 0 stale nodes)
so it is visibly two independent layers, not one combined pass/fail.
HydraDB is the graph store behind all four of those moments. It is not decoration: the graph it holds is what blast radius walks and what the verifier checks against. Every real call site, with line numbers:
The extracted graph is written to HydraDB as app_knowledge items, each one
carrying its relations forcefully — explicit neighbour ids on every item, so
HydraDB is given the edges rather than left to infer them from prose.
| What | Where |
|---|---|
client.context.ingest (batched upsert, upsert: "true") |
src/graph/ingest.ts:196 |
Forceful relations — relations: { ids: [...] } on every node |
src/graph/ingest.ts:167 |
nodeToAppKnowledgeItem() — node → HydraDB item, incl. tenant_metadata (node_kind, file_path) |
src/graph/ingest.ts:150 |
ingestGraph() — full-graph entry point, 200-item batches |
src/graph/ingest.ts:250 |
Per-item failure handling (errorCode / request_id surfaced, run aborts) |
src/graph/ingest.ts:230 |
client.context.delete — drop nodes that no longer exist |
src/graph/ingest.ts:304 |
client.context.status polling until graph_creation / completed |
src/graph/hydraClient.ts:79 |
Invoked by graphyti init-graph → src/cli/init-graph.ts:6.
After every write, only the changed file is re-extracted and diffed against the local graph map, so the graph stays current without a full re-ingest.
| What | Where |
|---|---|
reingestFile() — re-extract one file, diff, upsert + delete |
src/graph/incremental.ts:36 |
| Upsert/delete sets computed from the previous graph map | src/graph/incremental.ts:53 |
deleteKnowledgeIds for nodes the file no longer owns |
src/graph/incremental.ts:64 |
client.context.ingest — changed nodes only |
src/graph/incremental.ts:79 |
waitForIndexed before the run reports success |
src/graph/incremental.ts:115 |
Called per written file from src/index.ts:964.
The prompt context is a HydraDB query with graph context on — not a flat file dump. This is the "grounding at generation time" step.
| What | Where |
|---|---|
client.query({ ... }) |
src/generate/retrieveContext.ts:20 |
queryBy: "hybrid", mode: "thinking" |
src/generate/retrieveContext.ts:25 |
graphContext: true — pulls related nodes, not just text matches |
src/generate/retrieveContext.ts:27 |
buildString(result) — SDK helper formats the envelope for the LLM; no hand-rolled JSON in the prompt |
src/generate/retrieveContext.ts:33 |
HydraDBError handling — logs error_code + request_id, returns null |
src/generate/retrieveContext.ts:35 |
Consumed at src/index.ts:201.
The radius is seeded from the changed field, not from its model, and follows
edges in the direction that carries a dependency: routes that select the field,
components that render it, components that fetch an affected route, files that
import an affected file. Seeding from the model instead reaches every dependent
of every other field on that model, and through relation fields the dependents
of neighbouring models too — renaming User.phone would report the files behind
/api/posts as affected.
Every candidate then has to earn its place: the file must syntactically
reference the field (post.title, { title?: string }, select: { title }),
not merely contain the word. That gate runs in both directions — it drops graph
neighbours that never touch the field, and it picks up files the extractor could
not link, which happens whenever a field name cannot be pinned to exactly one
queried model. Files that merely mention the name (a page's
metadata = { title }, a parameter called title) are listed as advisory and
never enforced.
The local map is walked for speed, then cross-checked against HydraDB's own view of the node's relations, warning on any genuinely stale remote node (non-blocking — a stale index degrades to a warning rather than a wrong answer).
| What | Where |
|---|---|
client.context.relations — HydraDB's neighbours for the changed node |
src/graph/blastRadius.ts:266 |
checkHydraConsistency() — the cross-check itself |
src/graph/blastRadius.ts:254 |
| Endpoint names read out of the returned relation triplets | src/graph/blastRadius.ts:281 |
| remote ids absent from the local graph → agree ✅ or warn |
src/graph/blastRadius.ts:288 |
computeBlastRadius() — directed walk + reference gate + the cross-check |
src/graph/blastRadius.ts:385 |
Driven by the schema diff in src/generate/preWriteCheck.ts:295, and
consumed by the unified structural validator at src/verify/unifiedValidation.ts
which combines the local deterministic check with the HydraDB graph round-trip check.
The single shared client lives at
src/graph/hydraClient.ts:7; indexing waits are in
waitForIndexed at src/graph/hydraClient.ts:39. The database is
created with the node_kind / file_path metadata schema by
src/scripts/setup-hydra-db.ts, and
src/scripts/check-ingest.ts is a scratch script for
confirming ingestion and relations by hand.
Environment: HYDRA_DB_API_KEY, HYDRA_DB_DATABASE, HYDRA_DB_COLLECTION.
Generated command actions are never executed as free-form shell. Each one is
parsed against a structural allowlist
(src/utils/commandAllowlist.ts) that accepts only:
npm install <pkg> [<pkg>...] # optional -D / --save-dev / -E / --save-exact
npx prisma generate
npx prisma migrate dev --name <name>
Anything else — a different verb, an unrecognised flag, a shell operator, quoting
or substitution — is rejected with an explanation and skipped. Package names must
be plain npm names, optionally scoped and optionally pinned to an exact version
(zod@3.23.8); ranges, paths, URLs and git refs are refused. What matches is
printed verbatim with its working directory and requires an explicit y/n, the
same gate as the blast-radius confirmation; the canonical parsed argv is what
actually runs, via spawnSync, not the model's original string. --yes
auto-confirms; a non-interactive terminal without --yes refuses to run rather
than hanging. See src/agentPipeline.ts.
graphyti init and --legacy-context use a pre-graph, relation-free snapshot at
.dbagent/context.json. It exists only as a safety net for a HydraDB outage:
with no relations there is no blast radius and nothing for the verifier to check.
The graph path is the real one. See the header of
src/utils/context.ts