Neo4j property graph
A single analysis.json is a fine artifact for one project, but it has a ceiling: it must be loaded whole into memory and it doesn’t compose across a portfolio. --emit neo4j projects the same analysis into a Neo4j property graph — a persistent, queryable system of record where many applications live in one database, each anchored at its own :TSApplication node, and whole-monorepo or cross-service questions become a Cypher traversal instead of a parse of giant JSON blobs.
:TSEntrypoint;
the TS_CALLS edge is the resolved call graph.
This guide is the deployment story: how the two writers work, how to run the analyzer as a Kubernetes job that pushes into a shared cluster, and how lightweight read-only consumers — agents, dashboards, the CLDK Python SDK — read that graph back without ever touching the source. For the per-flag mechanics, see CLI usage › Neo4j output; for the full label and relationship topology, the graph schema reference.
The two writers
Section titled “The two writers”--emit neo4j replaces the JSON output entirely — it is an alternative target, not additive. One analysis is built in memory and then projected to graph rows. Which writer runs is decided by whether --neo4j-uri is set, and the two are mutually exclusive.
With no --neo4j-uri, the projection is rendered as a self-contained graph.cypher script and written to <output>/graph.cypher (or the current directory if -o is omitted). The script is everything needed to reconstruct this application’s subgraph from nothing:
- DDL — the constraints and indexes (so it’s safe to run against an empty database).
- A scoped wipe — a
DETACH DELETEof this app’s prior subgraph, matched on(:TSApplication {name: <app-name>}). - Batched
UNWIND … MERGEfor nodes and relationships (batches of 500).
cants --input ./my-ts-project --emit neo4j --app-name my-ts-app --output ./out# -> ./out/graph.cypher
cypher-shell -u neo4j -p "$NEO4J_PASSWORD" < ./out/graph.cypherA snapshot is not incremental by design: a static script has no view of the live database, so it always rewrites this application’s subgraph from scratch. It’s the right choice for a reproducible artifact you can commit, diff, or hand to a database admin to load.
When --neo4j-uri is present, cants connects with neo4j-driver and pushes incrementally over Bolt. It ensures the constraints and indexes exist, diffs each module’s content_hash against what’s already in the database, and only touches modules that changed (batches of 1000):
export NEO4J_PASSWORD=secretcants --input ./my-ts-project \ --emit neo4j \ --app-name my-ts-app \ --neo4j-uri bolt://localhost:7687 \ --neo4j-user neo4j \ --neo4j-database neo4jFor each changed module, in a single write transaction, cants deletes that module’s outgoing edges, detach-deletes the declarations it no longer emits, then MERGE-upserts its current nodes and the edges it owns. Nodes are never blindly deleted, so a declaration that another unchanged module still references survives. The driver is imported dynamically, so it stays entirely off the JSON code path.
flowchart TB
SRC["TypeScript / JavaScript project"] --> AN["cants analyze (in memory)"]
AN --> IR["TSApplication
symbol_table · call_graph · external_symbols"]
IR -->|"--emit json (default)"| JSON["analysis.json"]
IR -->|"--emit neo4j"| PROJ["project to graph rows"]
PROJ -->|"no --neo4j-uri"| SNAP["graph.cypher snapshot
DDL · scoped wipe · batched MERGE"]
PROJ -->|"--neo4j-uri bolt://…"| BOLT["incremental Bolt push
content_hash diff · MERGE-upsert"]
SNAP -->|"cypher-shell < graph.cypher"| DB[("Neo4j
property graph")]
BOLT --> DB
One database, many applications
Section titled “One database, many applications”--app-name sets the name property of the single :TSApplication anchor node — the MERGE key for the whole graph, enforced by a uniqueness constraint. Every module hangs off that node via TS_HAS_MODULE, and the schema_version (2.0.0) is stamped onto it. When omitted it defaults to the input directory’s basename, but in a shared database you should always set it explicitly and keep it stable: it is the scoping handle the CLDK SDK reads the graph back by.
It is also the multi-tenant boundary. The scoped wipe matches (:TSApplication {name: <app-name>}) and detach-deletes only that application’s modules and declarations, so loading billing-service never clobbers web-frontend in the same database. The genuinely shared nodes — :TSExternal library symbols, :TSPackage nodes, and :TSDecorator definitions — carry no _module property; they are MERGE-only and survive across every application, so a package imported by ten apps is stored once.
// Every external package any application in the database importsMATCH (a:TSApplication)-[:TS_HAS_MODULE]->(:TSModule)-[:TS_IMPORTS]->(p:TSPackage)RETURN a.name AS application, collect(DISTINCT p.name) AS packagesORDER BY applicationThat query is the whole point of the graph: with each analysis.json you’d open and parse N files; here cross-portfolio analysis is one traversal.
Deploying the producer/consumer split
Section titled “Deploying the producer/consumer split”The architecture that scales splits cleanly into a producer and consumers.
- The producer is
cants --emit neo4j --neo4j-uri …. It is the heavy half — it materializesnode_modules, type-checks, and builds the call graph — so run it out-of-band, on its own schedule, against a managed or clustered Neo4j (Aura, Neo4j Enterprise, or a self-hosted cluster). - The consumers — agents, the CLDK SDK, dashboards — are lightweight, read-only Bolt clients. They never analyze anything; they query a database that’s already populated, so they scale independently of the analysis pods and need only read-only credentials.
Many producer jobs write app-scoped subgraphs into one shared cluster; reads fan out from it.
flowchart LR
subgraph CI["CI / Kubernetes"]
J1["cants Job
billing-service"] --> DB
J2["cants Job
web-frontend"] --> DB
C["CronJob
nightly full re-push"] --> DB
end
DB[("Neo4j cluster
Aura / Enterprise")]
DB --> SDK["CLDK Python SDK
(read-only)"]
DB --> AG["agents"]
DB --> DSH["dashboards"]
As a Kubernetes Job
Section titled “As a Kubernetes Job”A per-application analysis is a natural fit for a Job (one-shot, on a push) or a CronJob (a scheduled refresh). The producer needs the project source mounted, the cants binary (self-contained — no Bun or Node at runtime), and the Bolt credentials from a Secret:
apiVersion: batch/v1kind: Jobmetadata: name: analyze-billing-servicespec: template: spec: restartPolicy: Never containers: - name: cants image: ghcr.io/your-org/cants:latest args: - --input=/src - --emit=neo4j - --app-name=billing-service - --neo4j-uri=bolt://neo4j.data.svc:7687 - --neo4j-database=neo4j - --eager env: - name: NEO4J_USERNAME valueFrom: { secretKeyRef: { name: neo4j-writer, key: username } } - name: NEO4J_PASSWORD valueFrom: { secretKeyRef: { name: neo4j-writer, key: password } } volumeMounts: - { name: src, mountPath: /src, readOnly: true } volumes: - name: src # e.g. a checkout from an initContainer, or a PVC emptyDir: {}Because NEO4J_PASSWORD and NEO4J_USERNAME are read from the environment, the secret never appears in the pod’s command line. Give the producer a read/write role scoped to the application’s labels; give every consumer a read-only role. That RBAC split is what makes the graph governed infrastructure rather than a shared mutable blob.
Incremental loads in CI
Section titled “Incremental loads in CI”On the Bolt path, --target-files (-t) flips the run from full to targeted — push only the modules a commit touched, served from cache for the rest:
export NEO4J_PASSWORD=secretcants --input ./my-ts-project \ --emit neo4j \ --app-name billing-service \ --neo4j-uri bolt://neo4j.data.svc:7687 \ --target-files src/billing.ts src/invoice.tsWhy this is enterprise-grade
Section titled “Why this is enterprise-grade”The graph is meant to be governed infrastructure your tools can depend on, not a one-off export:
- Multi-tenant by construction. The
--app-nameanchor plus the per-application scoped wipe mean apps never clobber each other in a shared database. - Incremental. Content-hash diffing (alongside
last_modifiedandfile_size) means a re-load only touches changed modules — cheap enough to run on every commit. - A versioned schema contract.
schema_version(2.0.0) is stamped on every:TSApplicationnode, and--emit schemapublishes the machine-readable contract (schema.json) so a consumer can detect a producer/consumer version mismatch before it queries. The contract is bundled in every release and enforced by a conformance test, so the emitter can’t drift from it. - Read-only credentials and RBAC. Consumers never need write access; the producer’s writer role and the consumers’ reader role are separate Neo4j roles.
- HA and clustering. Point the producer and consumers at Neo4j Aura or an Enterprise cluster; the Bolt URI is the only thing that changes.
- Code search built in. The projection creates a fulltext index,
code_fts, overTSCallable.codeand its docstring, plus name indexes on callables and decorators — so searching every callable in the portfolio is one Cypher call:
CALL db.index.fulltext.queryNodes('code_fts', 'createConnection') YIELD node, scoreMATCH (a:TSApplication)-[:TS_HAS_MODULE]->(:TSModule)-[:TS_DECLARES]->(node)RETURN a.name AS application, node.signature AS callable, scoreORDER BY score DESC LIMIT 25Reading the graph back with CLDK
Section titled “Reading the graph back with CLDK”The payoff of producing analysis once, centrally, is that consumers read it cheaply, everywhere. CLDK ships a read-only Neo4j backend: point CLDK.typescript(backend=Neo4jConnectionConfig(...)) at the Bolt URI and it reconstructs the same typed model objects and the same networkx call graph as the in-process analyzer — with no JDK, no native binary, and no project source on the consumer. It only needs the graph and read-only credentials.
-
Install the driver extra:
Terminal window pip install "cldk[neo4j]" # or: pip install neo4j -
Construct the facade against the Bolt URI. The
application_namemust equal the--app-namethe graph was loaded with — it’s how every query is scoped to one application:from cldk import CLDKfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfiganalysis = CLDK.typescript(backend=Neo4jConnectionConfig(uri="bolt://localhost:7687",username="neo4j",password="neo4j", # read-only credentials sufficedatabase=None, # None => server default DBapplication_name="my-ts-app", # == the --app-name the graph was loaded with),) -
Read the model. The
get_*methods return the identical typed objects the in-process backend produces:classes = analysis.get_classes() # Dict[str, TSClass]externals = analysis.get_external_symbols() # phantom library targets, for source->sink reachabilitycg = analysis.get_call_graph() # networkx.DiGraph
The backend bulk-fetches nodes and relationships in a handful of Cypher queries and rebuilds the same TSApplication (a TSModule symbol table, the call edges, and external symbols) the analyzer would have produced in process. The full read surface — get_symbol_table(), get_call_graph(), get_classes(), get_interfaces(), get_functions(), get_external_symbols(), get_callers(), get_callees(), get_class_hierarchy(), get_decorators() — is available on the Neo4j-backed facade.