neptune-tinker
v0.2.1
Published
Neptune-compatible local sandbox using TinkerGraph. Claude-first dev tooling.
Readme
neptune-tinker
Neptune-compatible local sandbox using TinkerGraph. Claude-first dev tooling.
What This Is
Amazon Neptune implements a subset of Apache TinkerPop Gremlin, plus multi-label vertices (the :: syntax). This makes local development tricky — code that works on TinkerGraph might fail on Neptune, and multi-labels don't exist in TinkerGraph natively.
neptune-tinker solves this with:
- Docker sandbox — TinkerGraph Gremlin Server with Neptune semantics applied server-side, one command to start
- Server-side Neptune strategy — a Groovy
TraversalStrategythat emulates multi-label matching, UUID auto-generation, and set cardinality inside the Gremlin Server itself — works for all clients (Python, Java, JS, Gremlin Console) - Compatibility guard — lints Gremlin queries against Neptune's constraints, catches incompatible patterns before they reach production
- Claude skills — documentation and review procedures that make Claude aware of Neptune's quirks
Quick Start
# Install
pnpm install
# Build TypeScript
pnpm run build
# Start the sandbox (default port 8182)
pnpm run sandbox:start
# Check health
pnpm run sandbox:health
# Run a one-off query
pnpm run sandbox:run 'g.V().count()'
# Tail logs
pnpm run sandbox:logs
# Reset (clears all data)
pnpm run sandbox:reset
# Stop
pnpm run sandbox:stopInteractive REPL
The REPL drops you into a Node.js session connected to the sandbox:
pnpm run sandbox:replneptune> await g.addV("Person::Employee").property(t.id, "a1").property("name", "Alice").next()
neptune> await g.addV("Person::Manager").property(t.id, "b1").property("name", "Bob").next()
neptune> await g.V().hasLabel("Person").toList() // matches both
neptune> await g.V().count().next()
neptune> lint("g.V(123)") // lint a query string
neptune> guard("g.V(123)") // throws NeptuneCompatErrorAvailable globals:
sandbox— connectedNeptuneSandboxinstanceg— Gremlin traversal sourcet,P,TextP,order,scope,column,direction,cardinality— Gremlin enumslint(query)— check a Gremlin string for Neptune violationsguard(query)— same, but throws in strict mode
Gremlin Console
For raw Gremlin (Groovy syntax):
pnpm run sandbox:consoleAuto-connects to the server — start querying immediately:
g.addV('Person::Employee').property(id, 'a1').property('name', 'Alice')
g.V().hasLabel('Person').valueMap(true)CLI
neptune-tinker start [--port N] [--name NAME] [--no-persist]
neptune-tinker stop [--name NAME]
neptune-tinker reset [--name NAME]
neptune-tinker health [--name NAME]
neptune-tinker logs [--name NAME]
neptune-tinker console [--name NAME]
neptune-tinker repl [--port N]
neptune-tinker run '<gremlin query>' [--port N]
neptune-tinker import <file.json> [--port N]--name creates an isolated sandbox instance with an auto-assigned port (useful for running multiple sandboxes).
--no-persist disables data persistence across container restarts.
Integrating Into Another Repo
Install
pnpm add neptune-tinkerCLI (from package.json scripts)
// consumer's package.json
{
"scripts": {
"sandbox:start": "neptune-tinker start",
"sandbox:stop": "neptune-tinker stop",
"sandbox:repl": "neptune-tinker repl",
"sandbox:console": "neptune-tinker console",
"sandbox:run": "neptune-tinker run"
}
}All paths resolve automatically — no config needed.
Programmatic (in test setup, scripts, etc.)
import { startSandbox, stopSandbox, NeptuneSandbox } from 'neptune-tinker';
import gremlin from 'gremlin';
const { process: gprocess } = gremlin;
const { t } = gprocess;
// Start Docker sandbox (blocks until healthy)
startSandbox();
// Connect — returns a standard Gremlin traversal source
// All Neptune semantics are handled server-side, no special API needed
const sandbox = new NeptuneSandbox();
await sandbox.connect();
const g = sandbox.g;
// Use standard Gremlin — multi-labels, set cardinality, and UUID
// auto-generation all work transparently via the server-side strategy
await g.addV('Person::Employee').property(t.id, 'alice-1').property('name', 'Alice').next();
await g.addV('Person::Manager').property(t.id, 'bob-1').property('name', 'Bob').next();
const people = await g.V().hasLabel('Person').toList(); // matches both
const managers = await g.V().hasLabel('Manager').toList(); // matches bob only
// Lint a query string for Neptune compatibility
const issues = sandbox.lint(`g.V(123).hasLabel('A::B')`);
// → [{ rule: 'string-ids-only', ... }, { rule: 'no-hasLabel-with-delimiter', ... }]
// Submit a raw Gremlin string (guarded)
const result = await sandbox.submit(`g.V('alice-1').valueMap()`);
await sandbox.close();
// Stop when done
stopSandbox();Custom port
startSandbox({ port: 9182 });
const sandbox = new NeptuneSandbox({ port: 9182 });Or via CLI: neptune-tinker start --port 9182
How It Works
Architecture
┌─────────────────────────────────────────────────┐
│ Your App / Test / AI Agent │
│ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ NeptuneSandbox│ │ Guard / Lint │ │
│ │ .connect() │ │ .lint(query) │ │
│ │ .submit() │ │ .guard(query) │ │
│ └──────┬───────┘ └────────────────────────┘ │
│ │ Standard gremlin-javascript │
│ │ (no middleware, no magic) │
└─────────┼───────────────────────────────────────┘
│ WebSocket
┌─────────▼───────────────────────────────────────┐
│ Docker: Gremlin Server 3.7.2 + TinkerGraph │
│ │
│ NeptuneMultiLabelStrategy (server-side Groovy) │
│ ├── Multi-label hasLabel() rewriting │
│ ├── UUID auto-generation for addV() │
│ └── Set cardinality (tinkergraph.properties) │
│ │
│ Works for ALL clients: JS, Python, Java, etc. │
└─────────────────────────────────────────────────┘Server-side Neptune Strategy
A Groovy TraversalStrategy loaded at server startup (scripts/neptune-init.groovy) handles ALL Neptune semantics inside the Gremlin Server. This means any client that connects — Python, Java, JavaScript, Gremlin Console — gets Neptune-compatible behavior with no client-side changes.
Multi-label matching:
hasLabel("Person")→ matches vertices with label"Person","Person::Employee","Employee::Person", etc.hasLabel("Person::Employee")→ always returns false (Neptune behavior: compound labels inhasLabel()never match)hasLabel(P.within("A", "B"))→ each target checked with::boundary-aware matching- Chained
hasLabel("X").hasLabel("Y")→ TinkerPop merges into oneHasStep; the strategy handles all containers
UUID auto-generation:
addV("Person")without an explicitT.idproperty → server injectsUUID.randomUUID().toString(), matching Neptune's behavioraddV("Person").property(T.id, "custom-id")→ uses the explicit ID as-is
Set cardinality:
defaultVertexPropertyCardinality=setintinkergraph.properties— duplicate property values are automatically deduplicated, matching Neptune's default
Compatibility Guard
A static text-based linter that scans Gremlin query strings for patterns that would fail or behave differently on Neptune.
| Rule | Pattern | Example |
|------|---------|---------|
| no-lambdas | { ... -> }, Lambda. | g.V().filter{ it.get().value('age') > 30 } |
| no-groovy | System., java.lang, new Date() | g.V().map{ System.nanoTime() } |
| no-variables | Assignments, query not starting with g. | x = 1; g.V(x) |
| must-start-with-g | Query doesn't begin with g. | graph.traversal().V() |
| no-graph-object | References to graph | graph.features() |
| no-list-cardinality | .property(list, ...) | g.V('x').property(list, 'tag', 'a') |
| no-program | .program(...) | g.V().program(pageRank) |
| no-sideeffect-consumer | .sideEffect({ ... }) | g.V().sideEffect{ println it } |
| no-io-write | .io(...).write() | g.io('file.xml').write() |
| string-ids-only | Numeric IDs in g.V() or g.E() | g.V(123) |
| no-hasLabel-with-delimiter | hasLabel('X::Y') | g.V().hasLabel('Person::Employee') |
| no-materialize-properties | materializeProperties | Any reference to this flag |
| no-fqcn | org.apache.tinkerpop | Fully qualified class names |
| no-meta-properties | .properties().property(...) | Meta-properties on vertex properties |
Guard modes:
"strict"(default): Violations throwNeptuneCompatError. Use in CI/CD."loose": Violations logged as warnings. Use during exploration.
Neptune vs TinkerGraph — Key Differences
The relationship is: Neptune = subset(TinkerGraph) + multi-labels.
Features Neptune Removes
| Feature | TinkerGraph | Neptune | Impact |
|---------|-------------|---------|--------|
| Lambda steps | Supported | Blocked | No { it -> ... }, no Lambda.groovy(...) |
| Groovy code | Supported | Blocked | No System.nanoTime(), new Date(), java.lang.* |
| Variables | Supported | Blocked | No x = 1; g.V(x) |
| graph object | Supported | Blocked | Only g is available |
| FQCNs | Supported | Blocked | Use short enum names (single, OUT, asc) |
| Vertex/edge ID types | Any | String only | g.V(123) fails |
| list cardinality | Supported | Blocked | Only single and set |
| Default cardinality | list | set | Duplicates silently deduplicated |
| MetaProperties | Supported | Blocked | Cannot add properties to properties |
| materializeProperties | Supported | Blocked | Properties must be fetched explicitly |
| .program() | Supported | Blocked | No OLAP vertex programs |
| .sideEffect(Consumer) | Supported | Blocked | Lambda-accepting overload only |
| .from(Vertex) / .to(Vertex) | Supported | Blocked | Use .from('label') or .from(__.V('id')) |
| .io().write() | Supported | Blocked | Only .io().read() |
| Session duration | Unlimited | 10 min max | |
Features Neptune Adds
Multi-label vertices — Neptune's only major addition:
// Creation: labels joined with ::
g.addV('Person::Employee::Manager').property(id, 'alice-1')
// Querying: hasLabel() matches any single component
g.V().hasLabel('Person') // matches alice-1
g.V().hasLabel('Employee') // matches alice-1
// hasLabel('Person::Employee') does NOT match — :: is only for addV()
// Label output returns the full compound string
g.V('alice-1').label() // → "Person::Employee::Manager"Configuration
TypeScript API
| Option | Default | Description |
|--------|---------|-------------|
| host | "localhost" | Gremlin Server host |
| port | 8182 | Gremlin Server port |
| endpoint | derived from host/port | Full WebSocket URL (overrides host/port) |
| guardMode | "strict" | "strict" throws on violations; "loose" warns only |
Docker Compose (env vars)
| Variable | Default | Description |
|----------|---------|-------------|
| NEPTUNE_TINKER_PORT | 8182 | Host port to expose |
| NEPTUNE_TINKER_CONTAINER | neptune-tinker | Docker container name |
| NEPTUNE_TINKER_IMAGE | tinkerpop/gremlin-server:3.7.2 | Docker image |
| NEPTUNE_TINKER_PERSIST | true | Enable data persistence via named volume |
| JAVA_OPTIONS | -Xms512m -Xmx4096m | JVM heap and flags |
| NEPTUNE_TINKER_EVAL_TIMEOUT | 30000 | Gremlin query evaluation timeout (ms) |
Standalone Docker Deployment
The docker/ directory contains everything needed to build and deploy neptune-tinker as a standalone service — no npm package or source repo required at runtime.
Building the Image
# From the repo root
docker build -f docker/Dockerfile -t neptune-tinker:latest .
# Or via pnpm
pnpm docker:buildThe image is based on tinkerpop/gremlin-server:3.7.2 with all Neptune compatibility configs (multi-label strategy, set cardinality, UUID auto-generation) baked in.
The default platform is linux/amd64. To build for ARM (e.g. Graviton instances):
docker build -f docker/Dockerfile --platform linux/arm64 -t neptune-tinker:latest .To build a multi-arch image and push both:
docker buildx build -f docker/Dockerfile --platform linux/amd64,linux/arm64 \
-t $REGISTRY/neptune-tinker:latest --push .Running
# Minimal — defaults are sensible
docker run -d -p 8182:8182 neptune-tinker:latest
# With persistence volume
docker run -d -p 8182:8182 -v neptune-data:/opt/gremlin-server/data neptune-tinker:latest
# With custom JVM heap
docker run -d -p 8182:8182 -e JAVA_OPTIONS="-Xms1g -Xmx8g" neptune-tinker:latest
# In-memory only (no persistence)
docker run -d -p 8182:8182 -e NEPTUNE_TINKER_PERSIST=false neptune-tinker:latestOr use the provided compose file:
docker compose -f docker/docker-compose.standalone.yml up -dEnvironment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| JAVA_OPTIONS | -Xms512m -Xmx4096m | JVM heap and flags. Set according to instance memory. |
| NEPTUNE_TINKER_PERSIST | true | Set to false for in-memory only (no disk writes). |
| NEPTUNE_TINKER_EVAL_TIMEOUT | 30000 | Max query execution time in milliseconds. |
| NEPTUNE_TINKER_DATA_DIR | /opt/gremlin-server/data | Graph data persistence directory inside the container. Mount a volume here. |
The standalone compose file (docker/docker-compose.standalone.yml) also supports:
| Variable | Default | Description |
|----------|---------|-------------|
| NEPTUNE_TINKER_PORT | 8182 | Host port mapping. |
| NEPTUNE_TINKER_CONTAINER | neptune-tinker | Container name. |
| NEPTUNE_TINKER_IMAGE | neptune-tinker:latest | Image name and tag. |
Health Check
The image includes a built-in health check (TCP probe on port 8182, 5s interval, 10 retries). For orchestrators that need an external check:
# TCP probe
timeout 2 bash -c '</dev/tcp/<host>/8182'
# Or via WebSocket — a valid Gremlin request returns HTTP 400 on the HTTP endpoint
curl -sf http://<host>:8182/gremlin -o /dev/null -w '%{http_code}'
# Returns 400 (expected — it's a WebSocket endpoint, not HTTP)Persistence
Graph data is stored at /opt/gremlin-server/data/graph.json in GraphSON format. Mount a volume at NEPTUNE_TINKER_DATA_DIR (default /opt/gremlin-server/data) to persist across container restarts.
For EBS-backed persistence on ECS/EC2, mount the EBS volume at this path. The container runs as gremlin (uid 100) — ensure the volume is writable by this user.
What's Inside the Image
The image bakes in three config files from this repo:
| File | Purpose |
|------|---------|
| gremlin-server.yaml | Server config: binds 0.0.0.0:8182, GraphBinary + GraphSON v1/v2/v3 serializers |
| tinkergraph.properties | Graph engine: ANY id managers, set cardinality, persistence path |
| neptune-init.groovy | Server-side NeptuneMultiLabelStrategy — handles multi-label matching, UUID auto-generation, label-append semantics |
The entrypoint (docker/docker-entrypoint.sh) applies environment variable overrides at startup, then runs the Gremlin Server as PID 1 via exec java for clean signal handling.
CI/CD
To publish the image from CI, add a workflow that builds and pushes to your registry on release tags. Example steps:
docker build -f docker/Dockerfile -t $REGISTRY/neptune-tinker:$TAG .
docker push $REGISTRY/neptune-tinker:$TAGThe .dockerignore keeps the build context small (~16KB — only scripts/ and docker/ are included).
Claude Integration
Skills (for Claude Code)
Copy the skills/ directory into your Claude skill path:
cp -r node_modules/neptune-tinker/skills/ .claude/skills/neptune/NEPTUNE_COMPAT.md— Reference for all Neptune constraints. Claude reads this before writing Gremlin.REVIEW_QUERY.md— Step-by-step procedure for Claude to review a Gremlin query for Neptune compatibility.
Data Import / Export
Import
neptune-tinker import data.jsonOr programmatically:
import { importFile } from 'neptune-tinker';
await importFile('data.json', sandbox);Export from Neptune
python scripts/export-neptune.py --endpoint your-neptune-endpoint --output data.jsonKnown Limitations
- Guard is text-based, not AST-based. Regex patterns catch common issues but may miss edge cases in complex queries.
- Chained
addV().property(k,v).property(k,v)dedup. During vertex creation, TinkerGraph doesn't deduplicate properties set in the same traversal chain. On updates (g.V(id).property(k,v)), set cardinality works correctly. - Session duration not enforced. Neptune limits sessions to 10 minutes; the sandbox does not.
Testing
pnpm run test # all tests
pnpm run test:unit # unit tests only
pnpm run test:integration # integration (needs Docker)
pnpm run test:rival # rival repo pattern testsLicense
MIT
