Skip to content

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

This example covers 8 access patterns:

  1. Get user profile — primary key lookup
  2. Get repository — primary key with composite sort key
  3. User’s owned repos — GSI1 owned collection (User + Repository)
  4. Create + update issue status — put + set with GSI recomposition
  5. Create + close pull request — same pattern, different entity
  6. User’s managed items — GSI1 managed collection (Issue + PullRequest)
  7. Repository activity — GSI2 activity collection (Repository + Issue + PullRequest)
  8. Atomic create — Transaction for issue + PR together

All four entities share a single table with 2 Global Secondary Indexes:

KeyFieldUsed by
PKpkUser (username), Repository (repoOwner), Issue (repoOwner+repoName), PullRequest (repoOwner+repoName)
SKskUser, Repository (repoName), Issue (issueNumber), PullRequest (pullRequestNumber)
GSI1 PKgsi1pkUser (username), Repository (repoOwner), Issue (username), PullRequest (username)
GSI1 SKgsi1skUser, Repository (repoName), Issue (status+issueNumber), PullRequest (status+pullRequestNumber)
GSI2 PKgsi2pkRepository (repoOwner+repoName), Issue (repoOwner+repoName), PullRequest (repoOwner+repoName)
GSI2 SKgsi2skRepository, 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).


Pure domain models with no DynamoDB concepts:

models.ts
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 const
const 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.


entities.ts
const VcsSchema = DynamoSchema.make({ name: "vcs", version: 1 })
entities.ts
const VcsTable = Table.make({
schema: VcsSchema,
entities: { Users, Repositories, Issues, PullRequests },
})

User has a simple primary key:

entities.ts
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 has a composite primary key (owner + name):

entities.ts
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 uses a composite primary key (repo + issue number):

entities.ts
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 mirrors Issue’s primary key structure:

entities.ts
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,
})

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:

collections.ts
// 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 use username/repoOwner as the partition key
  • Managed (GSI1) groups Issue + PullRequest by creator — both use username as the partition key
  • Activity (GSI2) groups Repository + Issue + PullRequest by repo — all use repoOwner + repoName as the partition key
  • status in the sort key means items naturally group by status within a partition

seed.ts
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 const
seed.ts
for (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)
}

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"

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.

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.

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.

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 collection
const { Issues: linuxIssuesAfter } = yield* db.collections.activity!({
repoOwner: "torvalds",
repoName: "linux",
}).collect()
const { PullRequests: linuxPRsAfter } = yield* db.collections.activity!({
repoOwner: "torvalds",
repoName: "linux",
}).collect()

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.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/version-control.ts

The example wires dependencies via Effect Layers:

main.ts
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),
)

ConceptHow it’s used
Single-table design4 entities, 1 table, structured key values
Index overloadingGSI1 serves Owned (User + Repository) and Managed (Issue + PullRequest) collections
3 collectionsOwned (GSI1), Managed (GSI1), Activity (GSI2) enable cross-entity queries
Status in sort keystatus as first sort key composite groups items by Open/Closed within a partition
GSI all-or-noneChanging status requires providing all composites for affected GSIs (e.g., username for Managed)
TransactionsAtomic multi-entity writes for issue + PR creation
Composite primary keysRepository, Issue, and PullRequest use multi-attribute partition and sort keys