Table overview
All persistent storage lives in DynamoDB. There are no relational databases. Each table is owned by a single repository, though multiple services may read from it via that repository.
Table details
cricket-fixtures
The central table. Stores every match the system knows about.
| Key | Type | Description |
|---|
matchId (PK) | String | SportMonks fixture ID |
GSIs:
| GSI name | Partition key | Sort key | Purpose |
|---|
leagueCode-startTime-index | leagueCode | startTime | Query fixtures by league, sorted by start time |
live-index | live | startTime | Find all currently live matches |
Accessed by: FixtureService (read/write), FixtureSyncJob (write), DisplayStateService (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get fixture by ID | Both | GetItem | PK=matchId | GET /matches/:id, LiveScoreJob | Per request / every 2.5s per live match |
| Batch get fixtures | Both | BatchGetItem | PK=matchId (up to 100) | GET /matches, FixtureCacheService | Per request / per sync cycle |
| Get matches by date range | User | Query (GSI) | leagueCode-startTime-index, leagueCode = :code AND startTime BETWEEN :from AND :to | GET /matches?league=&from=&to= | Per user request |
| Get starting-soon matches | System | Query (GSI) | leagueCode-startTime-index, startTime BETWEEN :now AND :until with filter attribute_not_exists(liveUpdatedAt) AND attribute_not_exists(archivedAt) | PrematchNotificationJob | Every 60s |
| Scan live fixtures | System | Scan (GSI) | live-index full scan | LiveScoreJob reconciliation | Every 2.5s |
| Batch write fixtures | System | BatchWriteItem | PK=matchId (batches of 25) | FixtureSyncJob | Per sync cycle |
| Update fixture fields | System | UpdateItem | PK=matchId with ConditionExpression: attribute_exists(matchId) | LiveScoreJob, FixtureSyncJob | Per live match per tick |
| Archive fixture | System | UpdateItem | PK=matchId, SETs archivedAt, REMOVEs liveUpdatedAt | LiveScoreJob reconciliation | When match leaves livescores |
| Delete replay fixture | System | DeleteItem | PK=matchId | LiveScoreJob reconciliation | When replay expires |
| Get fixtures by league (admin) | User | Scan + filter | FilterExpression: leagueId = :lid | Admin endpoints | Rare, admin-only |
The live-index GSI is sparse — only fixtures with liveUpdatedAt set appear in it. The poller SETs liveUpdatedAt on every tick for live matches and REMOVEs it during archival, which drops the fixture from the GSI. At peak, the GSI holds 10-20 items, making a full Scan cost less than 1 RCU.
The archiveFixture operation uses a conditional write (attribute_exists(matchId)) to prevent accidental upserts. It also REMOVEs liveUpdatedAt in the same UpdateExpression, which atomically drops the fixture from the sparse GSI.
cricket-teams
Static team reference data synced from SportMonks.
| Key | Type | Description |
|---|
teamId (PK) | String | SportMonks team ID |
Accessed by: TeamService (read/write), FixtureEnrichmentProvider (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get team by ID | Both | GetItem | PK=teamId | GET /teams/:id, FixtureEnrichmentProvider | Per request / per enrichment |
| Get all teams | User | Scan | Full table scan (< 100 items) | GET /teams | Per request, cached |
| Batch write teams | System | BatchWriteItem | PK=teamId (batches of 25) | TeamSyncJob | Per sync cycle |
| Update team fields | User | UpdateItem | PK=teamId with ConditionExpression: attribute_exists(teamId) | PATCH /teams/:id (admin) | Rare, admin-only |
cricket-team-seasons
Per-season stats for each team within a league season.
| Key | Type | Description |
|---|
seasonId (PK) | String | SportMonks season ID |
teamId (SK) | String | SportMonks team ID |
Accessed by: TeamSeasonService (read/write)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get teams for a season | Both | Query | PK=seasonId | GET /seasons/:id/teams, FixtureEnrichmentProvider | Per request / per enrichment |
| Write season-team records | System | BatchWriteItem | PK=seasonId, SK=teamId (batches of 25) | TeamSyncJob | Per sync cycle |
cricket-leagues
League/competition reference data.
| Key | Type | Description |
|---|
leagueId (PK) | String | SportMonks league ID |
Accessed by: LeagueService (read/write), FixtureEnrichmentProvider (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get league by ID | Both | GetItem | PK=leagueId | GET /leagues/:id, FixtureEnrichmentProvider | Per request / per enrichment |
| Get all leagues | User | Scan | Full table scan (< 30 items) | GET /leagues | Per request, cached in-memory |
| Get active leagues | System | Scan + filter | FilterExpression: active = true | PrematchNotificationJob, FixtureSyncJob | Per job cycle, cached |
| Create/replace league | System | PutItem | PK=leagueId | LeagueSyncJob | Per sync cycle |
| Update league fields | User | UpdateItem | PK=leagueId with ConditionExpression: attribute_exists(leagueId) | PATCH /leagues/:id (admin) | Rare, admin-only |
| Delete league | User | DeleteItem | PK=leagueId | Admin endpoint | Rare |
cricket-devices
Maps users to their registered push notification device tokens.
| Key | Type | Description |
|---|
userId (PK) | String | Cognito user ID |
deviceToken (SK) | String | APNs device token |
TTL: Yes — stale device registrations expire automatically.
Accessed by: DeviceService (read/write), PushNotificationService (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get device for user | Both | Query | PK=userId, Limit: 1 | GET /user/device, PushNotificationService | Per push send / per request |
| Register device (upsert) | User | Query + Delete + UpdateItem | Query PK=userId to find stale records, Delete orphans, then UpdateItem PK=userId, SK=deviceToken | POST /user/device | On app launch |
| Update push-to-start token | User | GetItem + UpdateItem | PK=userId, SK=deviceToken | PUT /user/device/push-to-start | On token refresh |
| Delete device | User | DeleteItem | PK=userId, SK=deviceToken with ReturnValues: ALL_OLD | DELETE /user/device | User action |
Device registration enforces single-device-per-user: it queries all records for the user, deletes any with a different deviceToken, then upserts the current one. This is a multi-step operation (not transactional) but safe because each user only ever has 1-2 records.
cricket-match-follows
Tracks which users are following which matches (for push notifications on score changes).
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
userId (SK) | String | Cognito user ID |
Accessed by: MatchFollowService (read/write), PushNotificationService (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Check if user follows match | User | GetItem | PK=matchId, SK=userId | GET /user/follow/:matchId | Per request |
| Get all followers for match | System | Query | PK=matchId (paginated) | TossDetectionJob, LiveActivityPushJob | Every 2.5s per live match |
| Follow a match | User | UpdateItem | PK=matchId, SK=userId, SET teamId, expiresAt, createdAt = if_not_exists(...) | POST /user/follow/:matchId | User action |
| Unfollow a match | User | DeleteItem | PK=matchId, SK=userId with ReturnValues: ALL_OLD | DELETE /user/follow/:matchId | User action |
| Update follow team | User | UpdateItem | PK=matchId, SK=userId with ConditionExpression: attribute_exists(matchId) | PATCH /user/follow/:matchId | User action |
| Remove all follows for match | System | BatchDeleteItem | Query PK=matchId, then batch delete all | Match completion cleanup | Once per match end |
updateFollowTeam uses a conditional write (attribute_exists(matchId)) to prevent creating a follow record if one doesn’t already exist. Returns false on ConditionalCheckFailedException.
expiresAt is set to match start time + 48h buffer. DynamoDB TTL automatically cleans up follow records for completed matches — no cron job needed.
cricket-activity-subscriptions
Tracks Live Activity subscriptions. When a user starts a Live Activity on iOS, their push token for that activity is stored here.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
userId (SK) | String | Cognito user ID |
TTL: Yes — subscriptions expire after the match ends.
Accessed by: ActivitySubscriptionService (read/write), LiveActivityPushService (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get all subscriptions for match | System | Query | PK=matchId (paginated) | LiveActivityPushJob | Every 2.5s per live match |
| Register LA subscription | User | UpdateItem (upsert) | PK=matchId, SK=pushToken, SET userId, teamId, expiresAt, createdAt = if_not_exists(...) | POST /user/activity/:matchId | When iOS starts Live Activity |
| Remove single subscription | User | DeleteItem | PK=matchId, SK=pushToken | DELETE /user/activity/:matchId | When iOS ends Live Activity |
| Remove user’s subscriptions for match | User | Query + Delete | Query PK=matchId with FilterExpression: userId = :userId, then delete each | Unfollow cascade | On unfollow |
| Remove all subscriptions for match | System | BatchDeleteItem | Query PK=matchId, then batch delete all | Match completion cleanup | Once per match end |
The SK is pushToken (the Live Activity push token), not userId. This enables multi-device support — a user with iPhone + iPad has 2 separate subscription records for the same match.
Live Activity push tokens are different from regular APNs tokens. They are short-lived and tied to a specific activity instance, not the device.
cricket-prematch-notifications
Tracks whether a prematch reminder has been sent for a user-match pair. Prevents duplicate notifications.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
userId (SK) | String | Cognito user ID |
Accessed by: PrematchNotificationService (read/write)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Check if notification sent | System | GetItem | PK=matchId | PrematchNotificationJob | Per match approaching start |
| Mark notification sent | System | PutItem | PK=matchId, with 48h TTL | PrematchNotificationJob | Once per match |
This table is system-only — no user-facing API touches it. TTL is set to 48 hours after write, so records auto-expire without cleanup.
cricket-locks
Distributed locking table. Prevents multiple backend instances from processing the same match concurrently.
| Key | Type | Description |
|---|
lockKey (PK) | String | Lock identifier (usually match:{matchId}) |
Key attributes: owner (instance ID), expiresAt (epoch timestamp for auto-release)
Accessed by: LockService (read/write)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Acquire lock | System | PutItem (conditional) | PK=lockId with ConditionExpression: attribute_not_exists(lockId) OR expiresAt < :now | LiveScoreJob, any concurrent job | Every 2.5s per live match |
| Release lock | System | DeleteItem (conditional) | PK=lockId with ConditionExpression: holder = :holder | LiveScoreJob completion | After each processing tick |
| Extend lock | System | UpdateItem (conditional) | PK=lockId with ConditionExpression: holder = :holder | Long-running jobs | During processing |
All three operations use conditional writes for atomicity. ConditionalCheckFailedException on acquire means another instance holds the lock — this is expected behavior, not an error. Default TTL is 10 seconds, acting as an auto-release safety net if the holder crashes.
cricket-state-hashes
Stores a hash fingerprint of the last-pushed match state. Used to detect whether a match state has actually changed before sending pushes.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
Accessed by: StateHashService (read/write), DisplayStateService (read)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get stored hash | System | GetItem | PK=matchTeamKey (composite: {matchId}#{teamId}) | LiveScoreJob change detection | Every 2.5s per live match per team |
| Update stored hash | System | PutItem | PK=matchTeamKey | LiveScoreJob (when state changed) | On each state change |
| Delete hash | System | DeleteItem | PK=matchTeamKey | Match completion cleanup | Once per match end |
The PK is a composite key {matchId}#{teamId} — hashes are stored per-team so that neutral vs team-perspective pushes can be independently deduplicated. This table is system-only; no user API reads from it.
DisplayStateCache
Caches the fully-computed display state for a match. Avoids recomputing on every API request.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
TTL: Yes — cache entries expire and are rebuilt on next request.
Accessed by: DisplayStateCacheService (read/write)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get cached display state | Both | GetItem | PK=cacheKey (composite: DISPLAY#{matchId}#{teamId}) | GET /matches/:id/display, LiveScoreJob | Per request / per tick |
| Set cached display state | Both | PutItem | PK=cacheKey with TTL | DisplayStateService after computation | After each state computation |
The cache key is DISPLAY#{matchId}#{teamId}, allowing per-team display state caching. Client-side TTL validation is performed in addition to DynamoDB TTL — if ttl has passed, the GetItem returns null even before DynamoDB deletes the record.
cricket-fixture-cache
Caches raw fixture data from SportMonks to reduce API calls.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
TTL: Yes — cached data expires based on match state (live matches have shorter TTLs).
Accessed by: FixtureCacheService (read/write), SportMonksClient (via provider)
GSIs:
| GSI name | Partition key | Sort key | Purpose |
|---|
leagueId-startingAt-index | leagueId | startingAt | Query cached fixtures by league |
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get cached fixture | Both | GetItem | PK=matchId | GET /matches/:id, LiveScoreJob | Per request / per tick |
| Batch get cached fixtures | Both | BatchGetItem | PK=matchId (up to 100, with retry) | GET /matches, FixtureSyncJob | Per request / per sync |
| Get fixtures by league | Both | Query (GSI) | leagueId-startingAt-index, leagueId = :lid with filter ttl > :now | GET /matches?league=, LiveScoreJob | Per request / per tick |
| Get all cached fixtures | System | Scan | Full table scan (30-50 items) | FixtureSyncJob | Per sync cycle |
| Put single fixture | System | PutItem | PK=matchId | LiveScoreJob | Per live match per tick |
| Batch put fixtures | System | BatchWriteItem | PK=matchId (batches of 25, with retry) | FixtureSyncJob | Per sync cycle |
| Update enrichment only | System | UpdateItem | PK=matchId with ConditionExpression: attribute_exists(matchId) | ActivityPushJob | Per live match per tick |
putBatch performs a read-before-write to preserve existing enrichment data: it BatchGets all items first, merges enrichment if the incoming entry has none, then BatchWrites. This prevents the sync job from overwriting enrichment data written by the activity push job.
match-processing-state
Tracks ball-by-ball processing progress for a match. Ensures the system can resume processing from the last known ball after restarts.
| Key | Type | Description |
|---|
matchId (PK) | String | Fixture match ID |
Key attributes: ballByBall (full ball data), lastProcessedBall (pointer to resume from)
Accessed by: MatchProcessingService (read/write)
Access patterns:
| Pattern | Type | Operation | Key condition | Triggered by | Frequency |
|---|
| Get last processed ball ID | System | GetItem (on cache miss) | PK=matchId | LiveScoreJob ball-by-ball processing | Per live match per tick (usually in-memory) |
| Set last processed ball ID | System | PutItem (async write-through) | PK=matchId with 7-day TTL | LiveScoreJob after processing a ball | Per new ball detected |
| Hydrate cache from DynamoDB | System | GetItem per match or Scan | PK=matchId per active match, or full scan | Service startup | Once on boot |
This service uses a hybrid storage pattern: an in-memory Map is the primary store for O(1) reads, with DynamoDB as a durable backup via async write-through. On startup, hydrateFromDynamo populates the in-memory cache from DynamoDB. The Scan path is only used when no active match list is available.
Access pattern summary
| Service | Tables accessed | Mode |
|---|
| FixtureService | cricket-fixtures | Read/Write |
| TeamService | cricket-teams | Read/Write |
| TeamSeasonService | cricket-team-seasons | Read/Write |
| LeagueService | cricket-leagues | Read/Write |
| DeviceService | cricket-devices | Read/Write |
| MatchFollowService | cricket-match-follows | Read/Write |
| ActivitySubscriptionService | cricket-activity-subscriptions | Read/Write |
| PrematchNotificationService | cricket-prematch-notifications | Read/Write |
| LockService | cricket-locks | Read/Write |
| StateHashService | cricket-state-hashes | Read/Write |
| DisplayStateCacheService | DisplayStateCache | Read/Write |
| FixtureCacheService | cricket-fixture-cache | Read/Write |
| MatchProcessingService | match-processing-state | Read/Write |
| PushNotificationService | cricket-devices, cricket-match-follows | Read |
| LiveActivityPushService | cricket-activity-subscriptions | Read |
| DisplayStateService | cricket-fixtures, cricket-state-hashes | Read |
| FixtureEnrichmentProvider | cricket-teams, cricket-leagues | Read |