> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Data model

> All DynamoDB tables, their keys, GSIs, and which services access them.

## 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.

<Note>
  LA v2 replaced several tables: `activity-subscriptions` is now `live-activities`, `match-processing-state` is now `active-matches`, and `prematch-notifications` and `state-hashes` have been removed entirely.
</Note>

```mermaid theme={null}
erDiagram
    cricket-fixtures {
        string matchId PK
        string leagueCode
        string startTime
        boolean live
        object displayState
        number ttl
    }

    cricket-teams {
        string teamId PK
        string name
        string code
        string imageUrl
    }

    cricket-team-seasons {
        string seasonId PK
        string teamId SK
        string leagueId
        object stats
    }

    cricket-leagues {
        string leagueId PK
        string name
        string code
        string imageUrl
    }

    cricket-devices {
        string userId PK
        string deviceToken SK
        string platform
        string apnsToken
        string osVersion
        string appVersion
        number ttl
    }

    cricket-match-follows {
        string matchId PK
        string userId SK
    }

    live-activities {
        string matchId PK
        string visitorKey SK
        string updateToken
        string status
        string sessionStartedAt
        string endedAt
        string endReason
        string suspendedAt
        number ttl
    }

    active-matches {
        string matchId PK
        string status
        string instanceId
        string lastTickAt
        string matchPhase
        string previousBallId
        object previousScore
        string previousStatus
        object previousBatting
        object previousBowling
        list last30Balls
        map cooldowns
        number ttl
    }

    cricket-locks {
        string lockKey PK
        string owner
        number expiresAt
    }

    DisplayStateCache {
        string matchId PK
        object displayState
        number ttl
    }

    cricket-fixture-cache {
        string matchId PK
        object rawFixture
        number ttl
    }

    cricket-fixtures ||--o{ cricket-match-follows : "users follow"
    cricket-fixtures ||--o{ live-activities : "LA sessions"
    cricket-fixtures ||--o| active-matches : "processing state"
    cricket-fixtures ||--o| DisplayStateCache : "cached display"
    cricket-fixtures ||--o| cricket-fixture-cache : "cached raw data"
    cricket-teams ||--o{ cricket-team-seasons : "season stats"
    cricket-leagues ||--o{ cricket-team-seasons : "league season"
```

## 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 |

**Accessed by:** FixtureService (read/write), FixtureSyncJob (write), DisplayStateBuilder (read)

**Access patterns:**

| Pattern                   | Type       | Operation      | Key condition                                                                          | Triggered by                     | Frequency                     |
| ------------------------- | ---------- | -------------- | -------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------- |
| Get fixture by ID         | **Both**   | GetItem        | `PK=matchId`                                                                           | `GET /matches/:id`, ScorePoller  | Per request / every poll tick |
| Batch get fixtures        | **Both**   | BatchGetItem   | `PK=matchId` (up to 100)                                                               | `GET /matches`, FixtureSyncJob   | 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              |
| 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)`                     | ScorePoller, FixtureSyncJob      | Per live match per tick       |
| Archive fixture           | **System** | UpdateItem     | `PK=matchId`, SETs `archivedAt`, REMOVEs `liveUpdatedAt`                               | LiveMatchReconciler              | When match leaves livescores  |

<Note>
  The `live-index` GSI has been removed. The **active-matches** table now serves as the source of truth for which matches are currently being polled, replacing the sparse GSI pattern.
</Note>

***

### live-activities

Tracks Live Activity sessions. When a user starts a Live Activity on iOS, their update token and session metadata are stored here. Replaces the old `activity-subscriptions` table.

| Key                    | Type   | Description                                             |
| ---------------------- | ------ | ------------------------------------------------------- |
| `matchId` (PK)         | String | Fixture match ID                                        |
| `userId#deviceId` (SK) | String | Composite sort key: Cognito user ID + device identifier |

**GSIs:**

| GSI name       | Partition key | Sort key | Purpose                                             |
| -------------- | ------------- | -------- | --------------------------------------------------- |
| `userId-index` | `userId`      | -        | Query all Live Activities for a user across matches |

**Key attributes:** `updateToken` (APNs Live Activity push token), `status` (active/ended/suspended), `sessionStartedAt`, `endedAt`, `endReason`, `suspendedAt`, `ttl`

**TTL:** Yes — sessions expire after the match ends.

**Accessed by:** LiveActivityRepository (read/write), LACoordinator (read), ApnsDeliveryService (read)

**Access patterns:**

| Pattern                       | Type       | Operation           | Key condition                                                            | Triggered by                                       | Frequency                      |
| ----------------------------- | ---------- | ------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | ------------------------------ |
| Get all LA sessions for match | **System** | Query               | `PK=matchId`                                                             | LACoordinator delivery                             | Every poll tick per live match |
| Get user's LA sessions        | **User**   | Query (GSI)         | `userId-index`, `userId = :uid`                                          | `GET /user/activities`                             | Per request                    |
| Register LA session           | **User**   | UpdateItem (upsert) | `PK=matchId, SK=userId#deviceId`                                         | `POST /user/activity/:matchId`                     | When iOS starts Live Activity  |
| Update token                  | **User**   | UpdateItem          | `PK=matchId, SK=userId#deviceId`                                         | Token rotation from iOS                            | On token refresh               |
| End LA session                | **Both**   | UpdateItem          | `PK=matchId, SK=userId#deviceId`, SET `status=ended, endedAt, endReason` | `DELETE /user/activity/:matchId`, match completion | User action / match end        |
| Suspend LA session            | **System** | UpdateItem          | `PK=matchId, SK=userId#deviceId`, SET `status=suspended, suspendedAt`    | APNs token rejection                               | On push failure                |

<Note>
  The SK is `userId#deviceId` (composite), enabling multi-device support. A user with iPhone + iPad has 2 separate session records for the same match. The `userId-index` GSI allows querying all of a user's active sessions across matches.
</Note>

***

### active-matches

Tracks all matches currently being polled, along with their processing state. Replaces both the old `match-processing-state` table and the `live-index` GSI on fixtures. Each record is approximately 5KB and contains everything the event detection pipeline needs to detect changes.

| Key            | Type   | Description      |
| -------------- | ------ | ---------------- |
| `matchId` (PK) | String | Fixture match ID |

**Key attributes:** `status`, `instanceId` (which server instance owns this match), `lastTickAt`, `matchPhase`, `previousBallId`, `previousScore`, `previousStatus`, `previousBatting`, `previousBowling`, `last30Balls`, `cooldowns`, `ttl`

**TTL:** Yes — records expire after the match ends + buffer.

**Accessed by:** ActiveMatchRepository (read/write), MatchOrchestrator (write), ScorePoller (read/write), MatchEventBus (read)

**Access patterns:**

| Pattern                | Type       | Operation             | Key condition                                                                                           | Triggered by                     | Frequency                  |
| ---------------------- | ---------- | --------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------- |
| Get active match state | **System** | GetItem               | `PK=matchId`                                                                                            | ScorePoller, MatchEventBus       | Every poll tick per match  |
| Get all active matches | **System** | Scan                  | Full table scan (typically 5-20 items)                                                                  | MatchOrchestrator reconciliation | Every reconciliation cycle |
| Register active match  | **System** | PutItem (conditional) | `PK=matchId` with `ConditionExpression: attribute_not_exists(matchId) OR status = :idle`                | MatchOrchestrator                | When match goes live       |
| Update match state     | **System** | UpdateItem            | `PK=matchId`, SET `previousScore, previousBatting, previousBowling, last30Balls, cooldowns, lastTickAt` | ScorePoller after each tick      | Every poll tick            |
| Update cooldowns       | **System** | UpdateItem            | `PK=matchId`, SET `cooldowns`                                                                           | CooldownFilter                   | After event filtering      |
| Deactivate match       | **System** | UpdateItem            | `PK=matchId`, SET `status=ended, ttl`                                                                   | MatchOrchestrator                | When match completes       |
| Delete active match    | **System** | DeleteItem            | `PK=matchId`                                                                                            | Cleanup after TTL expiry         | Rare, manual               |

<Note>
  The `previousScore`, `previousBatting`, `previousBowling`, and `last30Balls` fields store the state from the last poll tick. The event detection pipeline diffs current data against these previous values to determine what changed. The `cooldowns` map tracks per-event-type cooldown expiry timestamps.
</Note>

<Tip>
  The Scan on this table is intentional and acceptable — at peak there are only 5-20 active matches, so a full scan costs less than 1 RCU. This replaces the old sparse GSI pattern on the fixtures table.
</Tip>

***

### cricket-teams

Static team reference data synced from SportMonks.

| Key           | Type   | Description        |
| ------------- | ------ | ------------------ |
| `teamId` (PK) | String | SportMonks team ID |

**Accessed by:** TeamService (read/write), DisplayStateBuilder (read)

**Access patterns:**

| Pattern            | Type       | Operation      | Key condition                                                    | Triggered by                          | Frequency                    |
| ------------------ | ---------- | -------------- | ---------------------------------------------------------------- | ------------------------------------- | ---------------------------- |
| Get team by ID     | **Both**   | GetItem        | `PK=teamId`                                                      | `GET /teams/:id`, DisplayStateBuilder | 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`, DisplayStateBuilder | 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), DisplayStateBuilder (read)

**Access patterns:**

| Pattern               | Type       | Operation     | Key condition                                                        | Triggered by                            | Frequency                     |
| --------------------- | ---------- | ------------- | -------------------------------------------------------------------- | --------------------------------------- | ----------------------------- |
| Get league by ID      | **Both**   | GetItem       | `PK=leagueId`                                                        | `GET /leagues/:id`, DisplayStateBuilder | 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`                                    | MatchOrchestrator, 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. Now supports multi-device with additional device metadata.

| Key                | Type   | Description       |
| ------------------ | ------ | ----------------- |
| `userId` (PK)      | String | Cognito user ID   |
| `deviceToken` (SK) | String | APNs device token |

**Key attributes:** `platform`, `apnsToken`, `pushToStartToken`, `osVersion`, `appVersion`, `ttl`

**TTL:** Yes — stale device registrations expire automatically.

**Accessed by:** DeviceService (read/write), ApnsDeliveryService (read)

**Access patterns:**

| Pattern                    | Type     | Operation                   | Key condition                                                                                                                     | Triggered by                            | Frequency                   |
| -------------------------- | -------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------- |
| Get device for user        | **Both** | Query                       | `PK=userId`, `Limit: 1`                                                                                                           | `GET /user/device`, ApnsDeliveryService | 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` with `osVersion, appVersion` | `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                 |

<Note>
  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. The `osVersion` and `appVersion` fields are captured on registration for debugging push delivery issues.
</Note>

***

### 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), ApnsDeliveryService (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)                                                         | LACoordinator                  | Every poll tick 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             |

<Tip>
  `expiresAt` is set to match start time + 48h buffer. DynamoDB TTL automatically cleans up follow records for completed matches -- no cron job needed.
</Tip>

***

### 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` | ScorePoller, MatchOrchestrator | Every poll tick per live match |
| Release lock | **System** | DeleteItem (conditional) | `PK=lockId` with `ConditionExpression: holder = :holder`                                 | ScorePoller completion         | After each processing tick     |
| Extend lock  | **System** | UpdateItem (conditional) | `PK=lockId` with `ConditionExpression: holder = :holder`                                 | Long-running jobs              | During processing              |

<Warning>
  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.
</Warning>

***

### 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`, ScorePoller | Per request / per tick       |
| Set cached display state | **Both** | PutItem   | `PK=cacheKey` with TTL                                  | DisplayStateBuilder after computation   | After each state computation |

<Note>
  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.
</Note>

***

### 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`, ScorePoller     | 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=`, ScorePoller | 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`                                                            | ScorePoller                         | Per live match per tick |
| Batch put fixtures        | **System** | BatchWriteItem | `PK=matchId` (batches of 25, with retry)                                | FixtureSyncJob                      | Per sync cycle          |

***

## Removed tables

The following tables existed pre-LA v2 and have been removed:

| Old table                        | Replaced by       | Reason                                                                                         |
| -------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------- |
| `cricket-activity-subscriptions` | `live-activities` | New table with composite SK (`userId#deviceId`), GSI, and session lifecycle tracking           |
| `match-processing-state`         | `active-matches`  | New table stores full previous state for event detection diffing                               |
| `cricket-prematch-notifications` | MatchOrchestrator | Orchestrator manages the full match lifecycle including pre-match, no separate tracking needed |
| `cricket-state-hashes`           | *(removed)*       | The pipeline skips delivery if nothing changed — no need for explicit hash comparison          |

## Access pattern summary

| Service / Provider       | 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 |
| LiveActivityRepository   | live-activities                                   | Read/Write |
| ActiveMatchRepository    | active-matches                                    | Read/Write |
| LockService              | cricket-locks                                     | Read/Write |
| DisplayStateCacheService | DisplayStateCache                                 | Read/Write |
| FixtureCacheService      | cricket-fixture-cache                             | Read/Write |
| LACoordinator            | live-activities, cricket-match-follows            | Read       |
| ApnsDeliveryService      | cricket-devices, live-activities                  | Read       |
| DisplayStateBuilder      | cricket-fixtures, cricket-teams, cricket-leagues  | Read       |
| MatchOrchestrator        | active-matches, cricket-fixtures                  | Read/Write |
| ScorePoller              | active-matches, cricket-fixture-cache             | Read/Write |
| HubDataService           | active-matches, live-activities, cricket-fixtures | Read       |
