graphddb
v1.0.0
Published
Graph data modeling on DynamoDB with adjacency list pattern
Readme
GraphDDB
Portable, contract-first DynamoDB access patterns.
GraphDDB lets you define your DynamoDB access patterns once in TypeScript, validate them at build time, inspect the exact execution plan before anything runs, and execute the same contract from five languages — TypeScript, Python, Rust, Go, and PHP. It is not an ORM: it is a contract compiler + runtime for explicit DynamoDB access patterns.
The access patterns become a portable, executable artifact. TypeScript is the single source of truth;
a static planner lowers each contract to a language-neutral IR (manifest.json + operations.json),
and every language runtime executes that IR with results pinned byte-identical by a shared
conformance suite. GraphQL-like traversal is one of the authoring surfaces — ergonomic, but not the
point. The point is that PK / GSI / projection / relation / cursor / operation limits stay explicit,
verifiable, and portable rather than hidden behind an abstraction.
✨ What GraphDDB does — in three layers
1. Define explicit DynamoDB access patterns
- ✅ Type-safe models —— the TS class is the single source of truth. Keys / GSIs / field types are checked at compile time.
- ✅ Graph-style traversal —— nest
@hasMany/@belongsTo/@hasOneinsideselect(an ergonomic authoring surface — arguments → key lookup, selection → projection). - ✅ CQRS contracts —— public Query/Command contracts as the read/write authoring surface (N+1-safe, context boundaries, composition across contracts).
- ✅ Maintained access paths —— declare embedded snapshots / aggregate counters kept synchronized atomically with the source write.
2. Validate & explain before anything touches DynamoDB
- ✅ Compile-time safety —— undefined relations, missing GSIs, limit-less lists, and over-depth traversals are rejected at compile time / by the linter — equally for human- and AI-written code.
- ✅ Execution plan (Explain) ——
explain()returns the exact DynamoDB operations, so RCU shape is inspectable up front. - ✅ Automatic access-pattern resolution —— the runtime picks the PK / GSI from the fields you give;
selected fields are the only ones read and the only ones in the result type. - ✅ In-memory testing —— unit-test planner / traversal / transaction / CDC with no Docker, no DynamoDB Local.
3. Execute the same contract, portably, from five languages
- ✅ Portable executable IR —— the static planner lowers each contract to a language-neutral bridge bundle (
manifest.json+operations.json, acomponents[]graph on the shared behavior-contracts vocabulary). - ✅ 5 language runtimes —— generate clients + runtimes for Python / Rust / Go / PHP (TypeScript runs the IR natively), all executing the same access patterns.
- ✅ 5-language conformance —— TS / Python / Rust / Go / PHP run the same cases against DynamoDB Local; CI pins the results byte-identical.
Operational surface (all built on the layers above): Middleware (host-side read/write hooks) ·
CDC emulator (local DynamoDB-Streams-equivalent change events) · CDC projection
(@cdcProjected() + fromChange / subscribe into typed records) · Prepared statements
(compile-once / execute-many, AOT static plans) · CloudFormation generation ·
Docs generation (Markdown + Mermaid, Handlebars-overridable).
🤔 Philosophy
- Access patterns as portable contracts —— an access pattern is defined once and becomes a language-neutral, executable artifact. The same contract is validated, explained, and then executed identically across languages, instead of being re-implemented per stack.
- Native DynamoDB access patterns —— express access patterns directly in code instead of hiding them behind an ORM abstraction. Because PK / GSI, projection, and limits stay visible, the correct design becomes the easiest path.
- Compile-time safety —— undefined relations, missing GSIs, limit-less lists, and traversals exceeding the allowed depth are rejected before execution at compile time / by the linter. The same constraints apply equally to human-written and AI-generated code.
- Runtime transparency —— the runtime is not a black box.
explain()shows the execution plan, and the plan reveals the shape of RCU usage. - Graph-style traversal is a surface, not the essence —— GraphQL's query model (arguments → key lookup, selection → projection, relation → Query/Get/BatchGet) is a convenient way to author access patterns; the essence is the explicit, portable contract underneath.
📦 Install
npm install graphddb
# AWS SDK v3 is a peer dependency. Install the clients you use:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodbtsx is an optional peer dependency, needed only when running the code-generation CLI
(graphddb generate …) directly against TypeScript definitions (library consumers do not need it):
npm install -D tsxNode.js ≥ 22 is required.
🚀 Quick Start
import { DDBModel, graphddb, model, string, key, k } from 'graphddb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
@model({ table: 'AppTable', prefix: 'USER' })
class UserModel extends DDBModel {
static readonly keys = key<{ userId: string }>((c) => ({
pk: k`USER#${c.userId}`,
sk: k`PROFILE`,
}));
@string userId!: string;
@string name!: string;
}
const User = UserModel.asModel();
// Connect the client once. GraphDDB owns throttle / transient retries, so set
// `maxAttempts: 1` to avoid double retries (SDK × library) — see "Retry & throttling" below.
graphddb.config.client(new DynamoDBClient({ maxAttempts: 1 }));
// READ —— only the fields you select appear in the result type.
const user = await User.query({ userId: 'alice' }, { name: true });
// WRITE —— a unified envelope that writes multiple items atomically (default mode: 'transaction').
await graphddb.mutate({
user: { update: User, key: { userId: 'alice' }, input: { name: 'Alice B.' } },
});
// Raw base operations on a single item (named after the DynamoDB API).
await User.putItem({ userId: 'alice', name: 'Alice' });Reads center on query (a single PK / unique GSI lookup) / list (multiple items, with a cursor).
Writes center on graphddb.mutate (a unified envelope), while putItem / updateItem / deleteItem are
positioned secondarily as raw base operations named after the DynamoDB API. For the full API and options,
see docs/spec.md.
// query: the 1st argument says "how to find it", the 2nd says "what to return". The return type is inferred from select.
const alice = await User.query({ userId: 'alice' }, { name: true });
// list: keys that return multiple items use a cursor-based list.
const page = await GroupMembership.list({ groupId: 'eng' }, { userId: true, role: true }, { limit: 20 });
// page.items / page.cursor🔎 Inspect Execution Plans (Explain)
This is what sets GraphDDB apart. explain() returns the DynamoDB operations before they run. select is
translated into a ProjectionExpression, so only the requested attributes are read. The plan can be used
for testing, debugging, and RCU estimation:
const plan = GroupMembership.explain(
{ groupId: 'eng' },
{ select: { userId: true, role: true }, limit: 10 },
);
// {
// operations: [{
// type: "Query",
// tableName: "UserPermissions",
// keyCondition: { PK: "GROUP#eng" },
// rangeCondition: { operator: "begins_with", key: "SK", value: "USER#" },
// limit: 10,
// }]
// }For the complete shape of the plan (such as the rangeCondition separation on a GSI partial match), see
docs/spec.md.
🧪 Testing (strongly recommended)
The in-memory executor in graphddb/testing lets you fully unit-test planner / relation traversal /
transaction / CDC with no Docker and no DynamoDB Local. Because it never touches a real DynamoDB and
runs in-process, you can verify model-mapping, query-plans, relation resolution, and differential
aggregation while keeping CI fast and deterministic. See docs/testing.md.
🔗 Relation Traversal
A relation is a query bound to the value of the parent entity. Declare it with @hasMany / @belongsTo /
@hasOne and traverse it by nesting inside select. Independent relation subqueries are dispatched in
parallel, and N belongsTo resolutions are aggregated into a single BatchGetItem (no N+1):
const alice = await User.query(
{ email: '[email protected]' },
{
name: true,
groups: {
select: {
role: true,
group: { select: { name: true } }, // membership -> Group (belongsTo, BatchGet)
},
limit: 20,
},
},
{ maxDepth: 2 }, // traversal defaults to depth=1. Deeper traversal must be explicitly allowed.
);hasMany / list require a default / max limit. For the complete relation model, see
docs/spec.md.
🧩 Maintained Access Paths
Declaring a relation or scalar as a maintained access path keeps a separate row in sync with the lifecycle of the source entity (all of it composed into the same atomic transaction as the source write):
- embedded snapshot ——
pattern: 'embeddedSnapshot'on@hasMany/@hasOnekeeps a projected snapshot / bounded collection synchronized onto the owner row. - aggregate counter ——
@aggregate+count()keeps a scalar aggregate synchronized with an atomicADD ±1. - materialized view / sparse view ——
@model({ kind })+@maintainedFromdeclare a view as its own model (later phases). - versioned history —— maintenance of versioned history rows.
Synchronized maintenance (updateMode: 'mutation') works in both TS and Python (conformance-verified). For
how to declare them, the pattern mapping table, and per-phase coverage, see
docs/design-patterns.md. Examples:
embedded-snapshot-pattern /
aggregate-counter.
⚙️ Runtime
The runtime's responsibilities are short. For details, see docs/spec.md:
- access pattern resolution —— pick the PK / GSI from the fields you give.
- projection —— compile
selectinto aProjectionExpression. - relation traversal —— dispatch bound queries in parallel (N+1-safe BatchGet).
- retry / throttling —— retry throttle / transient errors with full-jitter exponential backoff, no
configuration required (set the client to
maxAttempts: 1, since the library owns retries). - hydration —— reconstruct raw items into typed partial entities.
🛠 More Capabilities
Each capability is summarized here only. For details, operator lists, and procedures, see each doc:
- Filtering —— a declarative, type-safe server-side
filter(compatible withFilterExpression/ AppSyncModelFilterInput).Model.col+ thecondraw escape hatch are shared by read filters / writeconditions. For the operator list, seedocs/spec.md. - Middleware / Hooks ——
graphddb.config.usefor host-side hooks on reads / writes (logging, metrics, tenant / authorization scoping). Host-only and non-serialized. For hook points, seedocs/middleware.md. - Class hydration ——
options.hydrateloads read results into host-language domain objects (opt-in, Phase 1:querytop level).docs/class-hydration.md. - Multi-language bridge —— generate Python / Rust / Go / PHP clients + runtimes with TS as the SSoT,
kept in step by a 5-language conformance suite.
docs/python-bridge.md. - Prepared statements ——
graphddb.prepare($ => ({...}))→.execute(params)(unified read/write). "Contract-free precompilation": compile a declarative route once and execute it repeatedly with different params. With the true build-time compilation (AOT) ofgraphddb transform prepared --aot, plans are emitted as static artifacts and zero runtime compilation happens, including the first call (a stale plan is loud-rejected at load; drift is caught in CI). Without the transform, behavior stays identical through the lazy-slot + structural-memoization fallback. no-runtime-capture is loudly enforced by a build-time lint + a runtime guard.docs/prepared-statements.md. - CQRS contracts —— public Query/Command contracts (cardinality matrix, N+1 safety, context boundaries,
composition across contracts).
docs/cqrs-contract.md/docs/mutation-command-derivation.md. - CDC emulator —— change events equivalent to DynamoDB Streams, to drive and test differential
aggregation.
docs/cdc-emulator.md. - CDC projection —— a typed-consumer-IF that parses the CDC change events of a
@cdcProjected()source model into typed records.Model.fromChange(event)returns[old, new], andgraphddb.subscribe(handlers)(the sibling ofquery/mutate) produces aChangeHandlerthe consumer mounts on its own stream. graphddb owns parse→typed record only; subscription, sink delivery, and idempotency are the consumer's.docs/cdc-projection.md. - Multi-language codegen —— execution keeps 3 paths + 1 consumer surface, coexisting
permanently: (1) dynamic JSON path (default) — the
components[]ofoperations.jsonare loaded at runtime and interpreted by the sharedrun_behavior(for swapping behavior without a deploy). (2) codegen-static path (opt-in) —graphddb generate behaviors --lang <ts|python|go|rust|php>hands the same IR to the shared upstream behavior-contracts generator, emitting modules with the IR baked in as native literals. Execution is still interpreted by the sharedrun_behavior— the per-op path is identical to the dynamic path (only the IR source differs; no native execution logic is generated). A generated module checks its fingerprint against the live registry and loud-rejects when stale. Rust generated modules usestd::sync::LazyLock, so Rust >= 1.80 is required. (3) typed-native path — genuine native-execution codegen (Go / Rust) —--typed-nativeskips the interpreter entirely and emits native per-node execution code (run_native_raw_struct_<comp>: direct field access on concrete structs, a local error type, norun_behavior, zero bc-runtime import). Because the generated code has the same shape a careful engineer hand-writes against the SDK (build key → call → unmarshal into a struct → fan out), it runs at SDK-floor parity (≈0.9–1.05× the hand-written SDK baseline in both Go and Rust) and 2–3× faster than the interpreter. Runtime-freeness is compiler-verified (scripts/native-codegen-purity.mjs); shapes not natively coverable fail closed (never silently boxed); Rust can emit async (ioModel=async). Measured results:benchmark/CROSS-LANG.md. (4) typed bindings (generate python/php/rust/go) — the consumer-UX surface (typed repositories / DTOs), still graphddb-owned; not an execution engine — it delegates to the paths above. - CloudFormation generation ——
graphddb generate cloudformationemits a CloudFormation template for the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / PROVISIONED / Auto Scaling).docs/cloudformation.md. - Docs generation ——
graphddb generate docsrenders a human-facing model specification (Table Overview / ER diagram / Key Schema / Property Matrix / Entity Details / Access Patterns / Mutation Contracts / Maintained Access Paths / CDC / CloudFormation) as Markdown + Mermaid. The layout is defined by bundled Handlebars templates that users can customize with--template-dir(per-partial override) and--helpers(extra helpers).docs/docs-generation.md.
📚 Documentation
The full documentation is bundled in the npm package (under docs/). After installation you can browse
every doc at node_modules/graphddb/docs/, and if you add it as a devDependency you have all the
documentation available offline at hand. On GitHub, follow the links in the table below.
| Document | Description |
|----------|-------------|
| Specification | Core API: entities, structured keys/GSIs, query/filter, relations, batch/transaction, design rules, runtime behavior. |
| Design patterns | Mapping representative DynamoDB design patterns onto graphddb features (how to declare pattern / @model({ kind }) + @maintainedFrom / @aggregate, read & write maintenance, per-phase coverage). |
| Middleware / Hooks | The graphddb.config.use host-side middleware: hook points read R1–R5 / write W1–W5, { context }, ordering. |
| Prepared statements | graphddb.prepare / .execute (unified read/write prepared statements), the no-runtime-capture constraint, graphddb transform prepared (AOT static plans / compile-time hoisting + build-time lint + drift detection): choosing between the 3 tiers AOT > lazy slot + structural memoization > ad-hoc. |
| CQRS contract layer | Public Query/Command contracts, cardinality matrix, N+1 safety, composition across contracts, context boundaries. |
| Mutation → command derivation | The internal write-plan composition DSL behind the Command IF (entityWrites, fragment composition, derivation of atomic TransactWriteItems). |
| Class hydration | Opt-in options.hydrate that loads read results into host objects (Phase 1: query top level). |
| Multi-language bridge | Code generation with TS as the SSoT and Python / Rust / Go / PHP runtimes; 5-language conformance. |
| In-memory test adapter | Docker-free in-process testing with graphddb/testing and MemoryInspector. |
| CDC emulator | A change-event emulator for differential aggregation patterns (dev/test). |
| CDC projection | The @cdcProjected() + fromChange / subscribe typed-consumer-IF: a contract that parses CDC change events into typed records. Boundary (parse→typed is graphddb; subscription, sink delivery, idempotency are the consumer's). |
| CloudFormation generation | The generate cloudformation command: emit a CloudFormation template for the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / TableClass / SSE / PROVISIONED / Auto Scaling). All options, derivation rules, and scope boundary. |
| Docs generation | The generate docs command: generate a model specification (Markdown + Mermaid) from the TS models. All options, output sections, and customizing the Handlebars templates with --template-dir / --helpers. |
| Benchmark | Library comparison against hand-written AWS SDK, ElectroDB, DynamoDB Toolbox, and OneTable. |
💡 Examples
| Example | Description |
|---------|-------------|
| user-permissions | Manages users, groups, and permissions with Single Table Design. Includes adjacency lists, inverted indexes, relation traversal, and explain. |
| aggregate-tree-pattern | Differential tree aggregation (siteScoreAverage) driven by the CDC emulator: dirty propagation, throttled sweeps, recomputation. |
| embedded-snapshot-pattern | Treating a relation as a maintained access path: pattern: 'embeddedSnapshot' keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
| aggregate-counter | The @aggregate counter from RFC §5.2: the ThreadPost.created / removed lifecycle keeps ThreadCounter.postCount synchronized via an atomic ADD ±1 within the same TransactWriteItems as the source write. |
🏛 Architecture
A query travels from the TS Model through the Planner / Runtime to DynamoDB:
From the same TS Model (the SSoT), multiple targets are derived. The language runtimes are fanned out to
4 languages through the portable IR (the components[] graph in manifest.json + operations.json),
and the execution results of all 5 languages — TS included — are pinned byte-identical by the
conformance suite:
| Runtime | Package | Distribution |
|---------|---------|--------------|
| TypeScript | graphddb | npm |
| Python | graphddb-runtime | PyPI |
| Rust | graphddb_runtime | crates.io |
| Go | github.com/foo-log-inc/graphddb/go | go module (go/vX.Y.Z tags) |
| PHP | graphddb/runtime | bundled in-repo (not on composer) |
Multi-language execution keeps 4 permanent paths — 3 execution paths (dynamic JSON / codegen-static / typed-native) + 1 consumer surface (see "Multi-language codegen" under More Capabilities):
The figures are self-contained SVGs drawn with diagram-contracts (Draw TSX). Sources live in
diagrams/; regenerate withdiagrams/build.sh.
📄 License
MIT
