Example: Version Control
A version control platform managing users, repositories, issues, and pull requests in a single DynamoDB table. Adapted from the ElectroDB Version Control example to showcase effect-dynamodb’s approach to the same problem.
What you’ll learn:
- 4 entities sharing one table (single-table design)
- 2 GSIs with index overloading
- 3 cross-entity collection patterns (
owned,managed,activity) - Status-based sort key composition for filtered queries
- Entity-scoped
set()for status updates with GSI all-or-none constraints - Atomic issue + PR creation via
Transaction.transactWrite
Access Patterns
Section titled “Access Patterns”This example covers 8 access patterns:
- Get user profile — primary key lookup
- Get repository — primary key with composite sort key
- User’s owned repos — GSI1
ownedcollection (User + Repository) - Create + update issue status — put + set with GSI recomposition
- Create + close pull request — same pattern, different entity
- User’s managed items — GSI1
managedcollection (Issue + PullRequest) - Repository activity — GSI2
activitycollection (Repository + Issue + PullRequest) - Atomic create — Transaction for issue + PR together
Table Design
Section titled “Table Design”All four entities share a single table with 2 Global Secondary Indexes:
| Key | Field | Used by |
|---|---|---|
| PK | pk | User (username), Repository (repoOwner), Issue (repoOwner+repoName), PullRequest (repoOwner+repoName) |
| SK | sk | User, Repository (repoName), Issue (issueNumber), PullRequest (pullRequestNumber) |
| GSI1 PK | gsi1pk | User (username), Repository (repoOwner), Issue (username), PullRequest (username) |
| GSI1 SK | gsi1sk | User, Repository (repoName), Issue (status+issueNumber), PullRequest (status+pullRequestNumber) |
| GSI2 PK | gsi2pk | Repository (repoOwner+repoName), Issue (repoOwner+repoName), PullRequest (repoOwner+repoName) |
| GSI2 SK | gsi2sk | Repository, Issue (status+issueNumber), PullRequest (status+pullRequestNumber) |
GSI1 is overloaded — it serves the owned collection (User + Repository by owner) and the managed collection (Issue + PullRequest by username). GSI2 serves the activity collection (Repository + Issue + PullRequest by repo).
Step 1: Models
Section titled “Step 1: Models”Pure domain models with no DynamoDB concepts:
class User extends Schema.Class<User>("User")({ username: Schema.String, fullName: Schema.String, bio: Schema.String, location: Schema.String,}) {}
class Repository extends Schema.Class<Repository>("Repository")({ repoName: Schema.String, repoOwner: Schema.String, username: Schema.String, about: Schema.String, description: Schema.String, isPrivate: Schema.Boolean, defaultBranch: Schema.String,}) {}
const IssueStatus = { Open: "Open", Closed: "Closed" } as constconst IssueStatusSchema = Schema.Literals(Object.values(IssueStatus))
class Issue extends Schema.Class<Issue>("Issue")({ issueNumber: Schema.String, repoName: Schema.String, repoOwner: Schema.String, username: Schema.String, subject: Schema.String, body: Schema.String, status: IssueStatusSchema,}) {}
class PullRequest extends Schema.Class<PullRequest>("PullRequest")({ pullRequestNumber: Schema.String, repoName: Schema.String, repoOwner: Schema.String, username: Schema.String, subject: Schema.String, body: Schema.String, status: IssueStatusSchema,}) {}Issue and PullRequest both have a status field using Schema.Literals — this field participates in sort key composition for status-based filtering.
Step 2: Schema, Table, and Entities
Section titled “Step 2: Schema, Table, and Entities”Schema and Table
Section titled “Schema and Table”const VcsSchema = DynamoSchema.make({ name: "vcs", version: 1 })const VcsTable = Table.make({ schema: VcsSchema, entities: { Users, Repositories, Issues, PullRequests },})User Entity
Section titled “User Entity”User has a simple primary key:
const Users = Entity.make({ model: User, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["username"] }, sk: { field: "sk", composite: [] }, }, indexes: { owned: { collection: "owned", name: "gsi1", pk: { field: "gsi1pk", composite: ["username"] }, sk: { field: "gsi1sk", composite: [] }, }, }, timestamps: true,})Repository Entity
Section titled “Repository Entity”Repository has a composite primary key (owner + name):
const Repositories = Entity.make({ model: Repository, entityType: "Repository", primaryKey: { pk: { field: "pk", composite: ["repoOwner"] }, sk: { field: "sk", composite: ["repoName"] }, }, indexes: { owned: { collection: "owned", name: "gsi1", pk: { field: "gsi1pk", composite: ["username"] }, sk: { field: "gsi1sk", composite: ["repoName"] }, }, activity: { collection: "activity", name: "gsi2", pk: { field: "gsi2pk", composite: ["repoOwner", "repoName"] }, sk: { field: "gsi2sk", composite: [] }, }, }, timestamps: true,})Issue Entity
Section titled “Issue Entity”Issue uses a composite primary key (repo + issue number):
const Issues = Entity.make({ model: Issue, entityType: "Issue", primaryKey: { pk: { field: "pk", composite: ["repoOwner", "repoName"] }, sk: { field: "sk", composite: ["issueNumber"] }, }, indexes: { managed: { collection: "managed", name: "gsi1", pk: { field: "gsi1pk", composite: ["username"] }, sk: { field: "gsi1sk", composite: ["status", "issueNumber"] }, }, activity: { collection: "activity", name: "gsi2", pk: { field: "gsi2pk", composite: ["repoOwner", "repoName"] }, sk: { field: "gsi2sk", composite: ["status", "issueNumber"] }, }, }, timestamps: true,})PullRequest Entity
Section titled “PullRequest Entity”PullRequest mirrors Issue’s primary key structure:
const PullRequests = Entity.make({ model: PullRequest, entityType: "PullRequest", primaryKey: { pk: { field: "pk", composite: ["repoOwner", "repoName"] }, sk: { field: "sk", composite: ["pullRequestNumber"] }, }, indexes: { managed: { collection: "managed", name: "gsi1", pk: { field: "gsi1pk", composite: ["username"] }, sk: { field: "gsi1sk", composite: ["status", "pullRequestNumber"] }, }, activity: { collection: "activity", name: "gsi2", pk: { field: "gsi2pk", composite: ["repoOwner", "repoName"] }, sk: { field: "gsi2sk", composite: ["status", "pullRequestNumber"] }, }, }, timestamps: true,})Collections
Section titled “Collections”GSI access patterns are now defined as entity-level indexes. Multi-entity collections (owned, managed, activity) are auto-discovered from matching collection names across entities:
// GSI access patterns are now defined as entity-level indexes above.// Multi-entity collections (owned, managed, activity) are auto-discovered// from matching collection names across entities.Key design decisions:
Owned(GSI1) groups User + Repository by owner — both useusername/repoOwneras the partition keyManaged(GSI1) groups Issue + PullRequest by creator — both useusernameas the partition keyActivity(GSI2) groups Repository + Issue + PullRequest by repo — all userepoOwner + repoNameas the partition keystatusin the sort key means items naturally group by status within a partition
Step 3: Seed Data
Section titled “Step 3: Seed Data”const users = { octocat: { username: "octocat", fullName: "The Octocat", bio: "GitHub mascot", location: "San Francisco, CA", }, torvalds: { username: "torvalds", fullName: "Linus Torvalds", bio: "Creator of Linux and Git", location: "Portland, OR", },} as const
const repos = { helloWorld: { repoName: "hello-world", repoOwner: "octocat", username: "octocat", about: "My first repository on GitHub!", description: "A simple hello world project for learning Git", isPrivate: false, defaultBranch: "main", }, linux: { repoName: "linux", repoOwner: "torvalds", username: "torvalds", about: "Linux kernel source tree", description: "The Linux kernel", isPrivate: false, defaultBranch: "master", },} as const
const issues = { helloWorldBug: { issueNumber: "1", repoName: "hello-world", repoOwner: "octocat", username: "torvalds", subject: "Bug: README has typo", body: "There is a typo in the README file on line 3.", status: "Open" as const, }, helloWorldFeature: { issueNumber: "2", repoName: "hello-world", repoOwner: "octocat", username: "octocat", subject: "Feature: Add contributing guide", body: "We should add a CONTRIBUTING.md file.", status: "Closed" as const, }, linuxBug: { issueNumber: "1", repoName: "linux", repoOwner: "torvalds", username: "octocat", subject: "Bug: Kernel panic on boot", body: "Kernel panic when booting with specific hardware configuration.", status: "Open" as const, },} as const
const pullRequests = { helloWorldPR: { pullRequestNumber: "1", repoName: "hello-world", repoOwner: "octocat", username: "torvalds", subject: "Fix README typo", body: "Fixes the typo mentioned in issue #1.", status: "Open" as const, }, linuxPR: { pullRequestNumber: "1", repoName: "linux", repoOwner: "torvalds", username: "octocat", subject: "Fix boot panic for hardware X", body: "Addresses kernel panic on boot with specific hardware.", status: "Open" as const, },} as constfor (const user of Object.values(users)) { yield* db.entities.Users.put(user)}for (const repo of Object.values(repos)) { yield* db.entities.Repositories.put(repo)}for (const issue of Object.values(issues)) { yield* db.entities.Issues.put(issue)}for (const pr of Object.values(pullRequests)) { yield* db.entities.PullRequests.put(pr)}Step 4: Fulfilling the Access Patterns
Section titled “Step 4: Fulfilling the Access Patterns”Pattern 1: Get User Profile
Section titled “Pattern 1: Get User Profile”Look up a user by their username (primary key).
const octocat = yield* db.entities.Users.get({ username: "octocat" })// octocat.fullName → "The Octocat"// octocat.bio → "GitHub mascot"// octocat.location → "San Francisco, CA"
const torvalds = yield* db.entities.Users.get({ username: "torvalds" })// torvalds.fullName → "Linus Torvalds"Pattern 2: Get Repository
Section titled “Pattern 2: Get Repository”Look up a repository by owner and name (composite primary key).
const helloWorld = yield* db.entities.Repositories.get({ repoOwner: "octocat", repoName: "hello-world",})// helloWorld.about → "My first repository on GitHub!"// helloWorld.defaultBranch → "main"
const linux = yield* db.entities.Repositories.get({ repoOwner: "torvalds", repoName: "linux" })// linux.about → "Linux kernel source tree"// linux.defaultBranch → "master"Pattern 3: User’s Owned Repos (GSI1 — owned Collection)
Section titled “Pattern 3: User’s Owned Repos (GSI1 — owned Collection)”See all repositories owned by a user.
User and Repository share GSI1 via the Owned collection. Both use the owner’s username as the partition key, so a single partition holds the user profile and all their repositories.
const { Repositories: octocatRepos } = yield* db.collections.owned!({ username: "octocat",}).collect()
const { Repositories: torvaldsRepos } = yield* db.collections.owned!({ username: "torvalds",}).collect()
const { Users: octocatProfile } = yield* db.collections.owned!({ username: "octocat" }).collect()Both queries hit GSI1 with the same partition key. DynamoDB serves both from the same item collection.
Pattern 4: Create Issue + Update Status
Section titled “Pattern 4: Create Issue + Update Status”Create a new issue, then close it.
Creating an issue is a simple put. Closing it requires an update with set(). Because status appears in GSI sort keys, changing it triggers GSI key recomposition — the all-or-none rule means you must provide all composites for affected GSIs.
// #region pattern-4 const newIssue = yield* db.entities.Issues.put({ issueNumber: "3", repoName: "hello-world", repoOwner: "octocat", username: "torvalds", subject: "Enhancement: Add CI pipeline", body: "We should add GitHub Actions for CI/CD.", status: "Open", }) assertEq(newIssue.issueNumber, "3", "new issue number") assertEq(newIssue.status, "Open", "new issue status")
const closedIssue = yield* db.entities.Issues.update({ repoOwner: "octocat", repoName: "hello-world", issueNumber: "3", }).set({ status: "Closed", username: "torvalds" }) assertEq(closedIssue.status, "Closed", "closed issue status") assertEq(closedIssue.subject, "Enhancement: Add CI pipeline", "closed issue preserves subject")The username field must be included in the set() even though it hasn’t changed — it’s a composite of the managed collection alongside status, and the all-or-none rule requires all composites for any GSI that’s being recomposed.
Pattern 5: Create Pull Request + Close
Section titled “Pattern 5: Create Pull Request + Close”Create a PR, then merge/close it.
The same pattern as issues — status in the sort key means the GSI keys must be fully recomposed on status change.
// #region pattern-5 const newPR = yield* db.entities.PullRequests.put({ pullRequestNumber: "2", repoName: "hello-world", repoOwner: "octocat", username: "octocat", subject: "Add CONTRIBUTING.md", body: "Adds a contributing guide as requested in issue #2.", status: "Open", }) assertEq(newPR.pullRequestNumber, "2", "new PR number") assertEq(newPR.status, "Open", "new PR status")
const closedPR = yield* db.entities.PullRequests.update({ repoOwner: "octocat", repoName: "hello-world", pullRequestNumber: "2", }).set({ status: "Closed", username: "octocat" }) assertEq(closedPR.status, "Closed", "closed PR status") assertEq(closedPR.subject, "Add CONTRIBUTING.md", "closed PR preserves subject")Pattern 6: User’s Managed Items (GSI1 — managed Collection)
Section titled “Pattern 6: User’s Managed Items (GSI1 — managed Collection)”See all issues and PRs created by a user.
Issue and PullRequest share GSI1 via the Managed collection. Both use username as the partition key. The collection returns grouped results with all member types.
const { Issues: torvaldsIssues } = yield* db.collections.managed!({ username: "torvalds",}).collect()
const { PullRequests: torvaldsPRs } = yield* db.collections.managed!({ username: "torvalds",}).collect()
const { Issues: octocatIssues } = yield* db.collections.managed!({ username: "octocat",}).collect()
const { PullRequests: octocatPRs } = yield* db.collections.managed!({ username: "octocat",}).collect()Pattern 7: Repository Activity (GSI2 — activity Collection)
Section titled “Pattern 7: Repository Activity (GSI2 — activity Collection)”See all issues and PRs for a specific repository.
Repository, Issue, and PullRequest share GSI2 via the Activity collection. All use repoOwner + repoName as the partition key, grouping everything related to a repository in one partition.
const { Issues: hwIssues } = yield* db.collections.activity!({ repoOwner: "octocat", repoName: "hello-world",}).collect()
const hwOpenIssues = hwIssues.filter((i: any) => i.status === "Open")
const { PullRequests: hwPRs } = yield* db.collections.activity!({ repoOwner: "octocat", repoName: "hello-world",}).collect()
const { Issues: linuxIssues } = yield* db.collections.activity!({ repoOwner: "torvalds", repoName: "linux",}).collect()
const { PullRequests: linuxPRs } = yield* db.collections.activity!({ repoOwner: "torvalds", repoName: "linux",}).collect()
const { Repositories: hwRepoActivity } = yield* db.collections.activity!({ repoOwner: "octocat", repoName: "hello-world",}).collect()Because status is in the sort key for Issues and PullRequests on GSI2, items within a partition naturally group by status. Closed items sort before Open items (lexicographic order), so issues and PRs cluster by status within each repository partition.
Pattern 8: Atomic Create (Transaction)
Section titled “Pattern 8: Atomic Create (Transaction)”Create an issue and PR together atomically.
Transaction.transactWrite ensures both items are created as a single atomic operation — if either fails, neither is persisted.
yield* Transaction.transactWrite([ Issues.put({ issueNumber: "2", repoName: "linux", repoOwner: "torvalds", username: "torvalds", subject: "Track: Memory leak in driver", body: "Tracking issue for the memory leak fix.", status: "Open", }), PullRequests.put({ pullRequestNumber: "2", repoName: "linux", repoOwner: "torvalds", username: "torvalds", subject: "Fix memory leak in driver X", body: "Fixes the memory leak described in issue #2.", status: "Open", }),])
// Both created atomically — verify via activity collectionconst { Issues: linuxIssuesAfter } = yield* db.collections.activity!({ repoOwner: "torvalds", repoName: "linux",}).collect()
const { PullRequests: linuxPRsAfter } = yield* db.collections.activity!({ repoOwner: "torvalds", repoName: "linux",}).collect()Running the Example
Section titled “Running the Example”The complete runnable example is at examples/version-control.ts in the repository. It seeds data, runs all 8 access patterns, and verifies each with assertions.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/version-control.tsLayer Setup
Section titled “Layer Setup”The example wires dependencies via Effect Layers:
const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), VcsTable.layer({ name: "vcs-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Single-table design | 4 entities, 1 table, structured key values |
| Index overloading | GSI1 serves Owned (User + Repository) and Managed (Issue + PullRequest) collections |
| 3 collections | Owned (GSI1), Managed (GSI1), Activity (GSI2) enable cross-entity queries |
| Status in sort key | status as first sort key composite groups items by Open/Closed within a partition |
| GSI all-or-none | Changing status requires providing all composites for affected GSIs (e.g., username for Managed) |
| Transactions | Atomic multi-entity writes for issue + PR creation |
| Composite primary keys | Repository, Issue, and PullRequest use multi-attribute partition and sort keys |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Indexes & Collections — Access pattern design and collection patterns
- Queries — Query combinators, filtering, and pagination
- Human Resources Example — 3 entities, 5 GSIs, soft delete, and salary range queries