ADR-004: AWS DynamoDB schema
Date: 2025-02-15 (Phase 4) Status: Accepted Supersedes: — Superseded by: —
Context
The AWS adapter runs IRC state on DynamoDB (single table family, not single-table design). Unlike Cloudflare Durable Objects, DynamoDB does not serialize access to a record by default — concurrent writes can interleave. Nick uniqueness, channel membership atomicity, and gone-connection cleanup all require explicit concurrency control.
The schema must support: connection records, channel metadata, channel
membership (roster), nick registry, and SASL account credentials. It must
also handle the Lambda invocation model: each $default invocation is a
fresh process that reads state, runs a reducer, and writes back — there is no
in-memory state between invocations.
Decision
Five tables, each with a clear partition-key boundary:
| Table | Partition Key | Sort Key | TTL | Purpose |
|---|---|---|---|---|
Connections |
connectionId |
— | idleSince |
Per-connection state: nick, user, host, caps, joined channels, away |
ChannelMeta |
channelName |
— | — | Topic, modes, key, limit |
ChannelMembers |
channelName |
connectionId |
— | Roster: membership + prefix flags (op/voice) |
Nicks |
nickLower |
— | — | Nick → connectionId registry |
Accounts |
account |
— | — | SASL credentials (scrypt-hashed, never plaintext) |
Concurrency invariants:
- Nick uniqueness: enforced via conditional
PutItem(ConditionExpression: attribute_not_exists(nickLower)). Two racing Puts for the same nick — exactly one succeeds; the other gets aConditionalCheckFailedException→433 ERR_NICKNAMEINUSE. - Membership atomicity:
TransactWriteItemsspansConnections(updatejoinedChannels) andChannelMembers(add/remove member) in a single atomic transaction. Concurrent JOIN/PART by the same connection cannot leave inconsistent state. - Gone connections:
ApiGatewayManagementApi.postToConnectionthrowsGoneExceptionfor a stale connection. The handler catches this, triggers lazy cleanup (removes theConnectionsrow +ChannelMembersrows), and broadcasts QUIT to peers (TICKET-074). A sweeper Lambda reaps rows whoseidleSinceTTL has expired. - Environment isolation: physical table names are environment-prefixed
(
<Env>Connections, e.g.StagingConnections) so staging and production can coexist in the same account + region (TICKET-066).
SASL credentials (Accounts): stored as scrypt HashedAccountCredential
rows ({ account, algorithm, salt, hash }, base64-encoded). The Lambda
pre-loads all rows at cold start into an in-memory DynamoAccountStore; the
reducer's AccountStore.verify() stays synchronous (reducers are pure — no
async). Seed tooling (tools/seed-aws-accounts.ts) writes hashed rows; never
plaintext.
Consequences
Positive:
- Clear separation of concerns: each table is one authority (matches ADR-002). A channel's metadata, membership, and each member's connection record are independently addressable.
- Conditional writes +
TransactWriteItemsgive race-free semantics without locks — the natural fit for DynamoDB. idleSinceTTL auto-reaps idle connections even if the sweeper misses a tick; the sweeper is a belt-and-suspenders cleanup, not the only path.- Pay-per-request billing means idle tables cost nothing; the schema does not over-provision.
Negative:
- Five tables (vs. a single-table design) means more
TransactWriteItemsspanning multiple partitions.TransactWriteItemsconsumes more RCUs than individual writes and has a 100-item-per-transaction cap. Membership transactions are 2 items (well within limits), but a future feature that touches many channels in one command could approach the cap. - No cross-table GSI for "all channels a connection is in" — the
Connectionsrow storesjoinedChannelsas a set, which must be updated on every JOIN/PART. This duplicates membership data betweenConnectionsandChannelMembers, kept consistent by the transaction. Accountspre-loading at cold start means every Lambda cold start scans the table. For deployments with thousands of accounts, this adds latency. Mitigated by Lambda reuse (warm starts skip the scan); a cache layer or on-demand lookup is a documented follow-up.
References
- PLAN §4 — state model & authoritative ownership
- PLAN §6.2 — DynamoDB tables
packages/aws-adapter/src/cdk-table-defs.ts—TABLE_DEFS(source of truth)packages/aws-adapter/src/aws-runtime.ts—AwsRuntimeimplementation- TICKET-039 — CDK stack
- TICKET-041 — DynamoDB transactions for membership
- TICKET-066 — environment-name prefixing
- TICKET-072 —
Accountstable scrypt-hashedAccountStore