The database module (createDatabase) defines 4 DynamoDB tables using the TableV2 construct — the recommended CDK DynamoDB API. All tables use on-demand billing (Billing.onDemand()) with no provisioned capacity, meaning the application pays per request rather than per provisioned capacity unit.
| Aspect | Detail |
|---|---|
| File | lib/database.ts |
| Function | createDatabase(scope: Construct): DatabaseOutput |
| Resources | 4 dynamodb.TableV2 |
| Return type | { configTable, bansTable, milestonesTable, historyTable } |
export function createDatabase(scope: Construct): DatabaseOutput {
const configTable = new dynamodb.TableV2(scope, 'ConfigTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
});
const bansTable = new dynamodb.TableV2(scope, 'BansTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
});
const milestonesTable = new dynamodb.TableV2(scope, 'MilestonesTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
globalSecondaryIndexes: [
{
indexName: 'TriggerAtIndex',
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'triggerAt', type: dynamodb.AttributeType.NUMBER },
projectionType: dynamodb.ProjectionType.ALL,
},
],
});
const historyTable = new dynamodb.TableV2(scope, 'HistoryTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
globalSecondaryIndexes: [
{
indexName: 'XOriginalIndex',
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'x_original', type: dynamodb.AttributeType.NUMBER },
projectionType: dynamodb.ProjectionType.INCLUDE,
nonKeyAttributes: ['y_original', 'offset_x', 'offset_y', 'colorIndex'],
},
],
});
return { configTable, bansTable, milestonesTable, historyTable };
}

All four tables share a common design pattern: a composite primary key of PK (Partition Key, STRING) and SK (Sort Key, STRING). Each entity type uses a distinct PK prefix rather than living in separate columns — an approach the project calls “single-table-design-lite.”
| Attribute | Value |
|---|---|
| Primary Key | PK (STRING), SK (STRING) |
| GSIs | None |
| Item count | 1 (singleton) |
| Access pattern | Read on every server startup; write on admin config change |
Item structure:
| PK | SK | Additional Attributes |
|---|---|---|
| CONFIG | SINGLETON | width, height, cooldownMs, globalOffsetX, globalOffsetY, locked |
{
"PK": "CONFIG",
"SK": "SINGLETON",
"width": 960,
"height": 540,
"cooldownMs": 300000,
"locked": false
}
| Attribute | Value |
|---|---|
| Primary Key | PK (STRING), SK (STRING) |
| GSIs | None |
| Access pattern | Lookup by user ID or IP address |
Item structure:
| PK | SK | Additional Attributes |
|---|---|---|
| BAN#user# | BAN | reason, bannedBy, bannedAt, expiresAt |
| BAN#ip# | BAN | reason, bannedBy, bannedAt, expiresAt |
{ "PK": "BAN#user#123456789", "SK": "BAN", "reason": "spam", "bannedAt": 1700000000 }
{ "PK": "BAN#ip#203.0.113.42", "SK": "BAN", "reason": "bot", "bannedAt": 1700000001 }
| Attribute | Value |
|---|---|
| Primary Key | PK (STRING), SK (STRING) |
| GSI | TriggerAtIndex (PK, triggerAt NUMBER) |
| Access pattern | Query unfired milestones sorted by triggerAt ascending |
Item structure:
| PK | SK | GSI SK (triggerAt) | Additional Attributes |
|---|---|---|---|
| MILESTONE | epoch seconds | direction, amount, label, fired |
{
"PK": "MILESTONE",
"SK": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"triggerAt": 1700000000,
"direction": "expand",
"amount": 200,
"label": "First expansion",
"fired": false
}
| Attribute | Value |
|---|---|
| Primary Key | PK (STRING), SK (STRING) |
| GSI | XOriginalIndex (PK, x_original NUMBER) |
| Access pattern | Rectangle queries by x_original range, filtered by y_original |
Item structure:
| PK | SK | GSI SK (x_original) | Projected Attributes |
|---|---|---|---|
| HISTORY | ts#<epoch_ms># | pixel x coordinate | y_original, offset_x, offset_y, colorIndex |
{
"PK": "HISTORY",
"SK": "ts#1700000000000#abc-def-ghi",
"x_original": 42,
"y_original": 100,
"offset_x": 0,
"offset_y": 0,
"colorIndex": 5
}
| Table | Traffic Pattern | Why On-Demand |
|---|---|---|
| Config | Near-zero (read once at startup, written rarely) | Provisioned capacity would waste money |
| Bans | Very low (admin operations only) | Same — sub-1-RCU traffic |
| Milestones | Very low (a few items, queried periodically) | Same — sub-1-RCU traffic |
| History | Variable (spikes during placement events) | On-demand absorbs spikes without throttling; no capacity planning needed |
The History table is the only table with meaningful traffic, and its write pattern is spiky — users place pixels in bursts. On-demand billing absorbs these spikes automatically, while provisioned capacity would either throttle during spikes or waste money during quiet periods.
Two deliberate omissions in the database design:
Canvas pixel data is not stored in DynamoDB. DynamoDB items have a 400 KB size limit — the canvas at 4 bits per pixel grows to 32 MiB at maximum dimensions (8000×8000 pixels at 4 bpp). The canvas binary is stored in S3 as a nibble-packed blob (canvas/canvas.bin).
No DynamoDB TTL is configured on any table:
The ECS task role and Lambda execution role both receive DynamoDB permissions scoped to exactly these four tables and their indexes:
function allTableArns(db: DatabaseOutput): string[] {
const tables = [db.configTable, db.bansTable, db.milestonesTable, db.historyTable];
return tables.flatMap((t) => [t.tableArn, `${t.tableArn}/index/*`]);
}
db.configTable.grantReadWriteData(taskRole);
db.bansTable.grantReadWriteData(taskRole);
db.milestonesTable.grantReadWriteData(taskRole);
db.historyTable.grantReadWriteData(taskRole);
Using grantReadWriteData() (rather than hand-written policies) ensures the permissions are always correct — CDK generates the minimal policy with the exact table and index ARNs.