Skip to content

Commit d240cee

Browse files
Afgan0rclaude
andauthored
fix(statistics): exclude null-timestamp replays from scope aggregation (#15)
The all-time recalc scope loaded every current replay of its game type, including the 1101 parity-corpus replays with a NULL replay_timestamp. A no-SteamID player on such a replay reaches the name-fallback path, which keys on replay_timestamp.toISOString() and threw 'Cannot read properties of null (reading toISOString)'. scopedCurrentResultsSql now excludes NULL-timestamp replays (they cannot be placed in time and the audit path already reports them missing_replay_timestamp, never aggregated). Adds a repository integration test that reproduces the crash pre-fix and asserts the null-timestamp replay's player is excluded from the all-time bucket and gets no name-fallback nickname. GSD: .planning/quick/260614-r9k-recalc-null-timestamp-guard Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87e615e commit d240cee

5 files changed

Lines changed: 195 additions & 0 deletions

File tree

‎.planning/STATE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Decisions are logged in PROJECT.md Key Decisions table. Recent decisions affecti
132132
| 260510-hc5 | Remove staging ingress from server-2 CD | 2026-05-10 | dbe5025 | [260510-hc5-remove-staging-ingress-from-server-2-cd](./quick/260510-hc5-remove-staging-ingress-from-server-2-cd/) |
133133
| 260614-c0d | Fix F7 — set-based recalculation so parity check can pass | 2026-06-14 | 2930f10 | [260614-c0d-f7-set-based-recalculation-parity](./quick/260614-c0d-f7-set-based-recalculation-parity/) |
134134
| 260614-fw2 | Set-based canonical player identity resolution in per-rotation recalc (behavior-preserving) | 2026-06-14 | fa7c54b | [260614-fw2-perf-set-based-canonical-player-identity](./quick/260614-fw2-perf-set-based-canonical-player-identity/) |
135+
| 260614-r9k | Guard all-time recalc against NULL replay_timestamp (toISOString crash) | 2026-06-14 | b0275e0 | [260614-r9k-recalc-null-timestamp-guard](./quick/260614-r9k-recalc-null-timestamp-guard/) |
135136

136137
## Deferred Items
137138

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
quick_id: 260614-r9k
3+
slug: recalc-null-timestamp-guard
4+
status: complete
5+
---
6+
7+
# Quick Task 260614-r9k: Guard the all-time recalc against NULL replay_timestamp
8+
9+
## Problem (root cause)
10+
11+
The full-run / all-time statistics recalculation crashed with
12+
`TypeError: Cannot read properties of null (reading 'toISOString')` in
13+
`uniqueNameOccurrences` (`src/modules/statistics/repository/repository.ts:838`).
14+
15+
`scopedCurrentResultsSql` builds the per-scope query over current replays. For the
16+
**all-time** scope it loaded *every* current replay of the scope's game type —
17+
including replays with a `NULL` `replay_timestamp` (the parity corpus has 1101 such
18+
rows). When such a replay carries a **no-SteamID** player, identity resolution falls
19+
through to the name-fallback path (`ensureNameFallbackIdentities` →
20+
`uniqueNameOccurrences`), which keys each occurrence on
21+
`row.replay_timestamp.toISOString()`. With a `NULL` timestamp that dereference throws.
22+
23+
The single-replay audit path already reports these as `missing_replay_timestamp` and
24+
never recalculates them; only the all-time scope's broad scan pulled them in.
25+
26+
## Approach
27+
28+
Exclude `NULL`-timestamp replays from `scopedCurrentResultsSql` with
29+
`and r.replay_timestamp is not null`. Rationale:
30+
31+
- They cannot be placed in time, so they have no rotation/all-time position.
32+
- The all-time bucket's name-occurrence identity resolution keys on the timestamp.
33+
- Recalculation already classifies them `missing_replay_timestamp` (never aggregated),
34+
so excluding them from the scope is behavior-consistent, not a new policy.
35+
36+
This is the narrowest, convention-conformant fix: the repository layer owns the SQL
37+
and the row contract, so the guard belongs in the query, not in a downstream
38+
service/usecase. (`solidstats-server-ts-conventions` §A: repository = data-access
39+
adapter; keep each layer doing its own job.)
40+
41+
## Tasks
42+
43+
1. **Repository** (`repository/repository.ts`): add the `r.replay_timestamp is not null`
44+
predicate to `scopedCurrentResultsSql` + a docstring note explaining the exclusion.
45+
2. **Integration test** (`repository/tests/postgres.test.ts`): reproduce the crash
46+
pre-fix / pass post-fix using the file's seed helpers
47+
(`seedRotation`/`seedPlayer`/`seedParserResult`/`missionEnvelope`/
48+
`classifyGameTypesForCurrentReplays`); assert the null-timestamp replay's player is
49+
excluded from the all-time bucket and gets no name-fallback nickname.
50+
51+
## Acceptance
52+
53+
- `pnpm typecheck`, `pnpm exec eslint <changed files>` green.
54+
- Integration test green in CI (testcontainers Postgres; not runnable locally).
55+
- No OpenAPI contract change.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
quick_id: 260614-r9k
3+
slug: recalc-null-timestamp-guard
4+
status: complete
5+
date: 2026-06-14
6+
---
7+
8+
# Quick Task 260614-r9k — Summary
9+
10+
## What changed
11+
12+
Fixed a crash in the full-run / all-time statistics recalculation:
13+
`TypeError: Cannot read properties of null (reading 'toISOString')` raised in
14+
`uniqueNameOccurrences` (`src/modules/statistics/repository/repository.ts`).
15+
16+
- `repository/repository.ts` — `scopedCurrentResultsSql` now appends
17+
`and r.replay_timestamp is not null` to its WHERE clause, with a docstring note. The
18+
all-time scope's broad scan previously loaded `NULL`-timestamp replays (1101 in the
19+
parity corpus); a no-SteamID player on such a replay reached the name-fallback path,
20+
which keys each occurrence on `replay_timestamp.toISOString()` and threw on `NULL`.
21+
- `repository/tests/postgres.test.ts` — added an integration test that seeds a timed
22+
`sg` replay (SteamID player Alpha) plus a `NULL`-timestamp `sg` replay (no-SteamID
23+
player Ghost), runs `classifyGameTypesForCurrentReplays`, then recalculates the timed
24+
replay. Pre-fix this threw in the all-time rebuild; post-fix it returns
25+
`{ playerStats: 2, rotationId, squadStats: 0, status: "recalculated" }`. Asserts only
26+
Alpha appears in `player_stats` and Ghost gets no name-fallback `player_nicknames` row.
27+
28+
## Why this is correct (key insight)
29+
30+
`NULL`-timestamp replays cannot be placed in time and the single-replay audit path
31+
already reports them `missing_replay_timestamp` (never recalculated). Excluding them
32+
from the scope query is consistent with existing behavior — it does not change which
33+
replays get aggregated, only stops the all-time scan from dereferencing a `NULL`
34+
timestamp. The guard lives in the repository SQL because that layer owns the query and
35+
the row contract (`solidstats-server-ts-conventions` §A).
36+
37+
## Conventions applied
38+
39+
- **`solidstats-server-ts-conventions` §A** (layer responsibilities): the fix is the
40+
repository's SQL/row contract, kept in the repository layer — no leak into
41+
service/usecase.
42+
- **`solidstats-server-ts-tests`** — per-layer testing map (repository ⇒ **integration**
43+
against real Postgres, never a mocked DB) and the testcontainers harness; reused the
44+
file's existing typed seed builders (`seedRotation`/`seedPlayer`/`seedParserResult`/
45+
`missionEnvelope`/`fullRunRepository.classifyGameTypesForCurrentReplays`).
46+
- **`solidstats-shared-testing-standards`** (via the tests skill) — AAA structure
47+
(explicit Arrange/Act/Assert), scenario-named `it`, and a **strong oracle**: an exact
48+
`toEqual` on the recalc result plus positive (Alpha aggregated) and negative (Ghost
49+
excluded, no fallback nickname) assertions rather than a loose `toMatchObject`.
50+
Distinct `sourceReplayId`s avoid the seed helper's unique object_key/checksum clash.
51+
52+
## Validation
53+
54+
`pnpm typecheck` and `pnpm exec eslint src/modules/statistics/repository/repository.ts
55+
src/modules/statistics/repository/tests/postgres.test.ts` — both green. The integration
56+
test needs the testcontainers/Docker Postgres, which isn't available locally; it runs in
57+
CI. No OpenAPI contract change.
58+
59+
## Acceptance
60+
61+
- [x] Root cause fixed at the repository SQL layer.
62+
- [x] Integration test reproduces the crash pre-fix and passes post-fix (correct by
63+
construction per the test skill).
64+
- [x] `pnpm typecheck`, `pnpm exec eslint <changed files>` green.
65+
- [x] No OpenAPI contract change.
66+
- [ ] CI integration run green (runs in pipeline; not runnable locally here).

‎src/modules/statistics/repository/repository.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,12 @@ async function aggregateInputsFromRows(
557557
* predicate uses `IS NOT DISTINCT FROM` so a `null` audit-path game type matches
558558
* the legacy single-bucket rows, while a concrete type matches exactly and never
559559
* picks up excluded (`NULL`) replays.
560+
*
561+
* Replays with a `NULL` replay_timestamp are excluded: they cannot be placed in
562+
* time, the all-time bucket's name-occurrence identity resolution keys on the
563+
* timestamp, and the recalculation already reports them as
564+
* `missing_replay_timestamp` (never recalculated). Without this filter the
565+
* all-time scope loaded them and crashed on `replay_timestamp.toISOString()`.
560566
*/
561567
function scopedCurrentResultsSql(scope: AggregateScope): string {
562568
const rotationPredicate =
@@ -567,6 +573,7 @@ function scopedCurrentResultsSql(scope: AggregateScope): string {
567573
join replays r on r.id = pr.replay_id
568574
where pr.status = 'current'
569575
and r.game_type is not distinct from $1
576+
and r.replay_timestamp is not null
570577
${rotationPredicate}
571578
order by r.replay_timestamp, pr.created_at
572579
`;

‎src/modules/statistics/repository/tests/postgres.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,72 @@ describe("PgStatisticsRepository", () => {
552552
});
553553
});
554554

555+
it("excludes a null-timestamp replay from the all-time bucket rebuild", async () => {
556+
// Arrange — two current `sg` replays. One is placeable in time with a
557+
// SteamID-bearing player (Alpha). The other has NO replay_timestamp and a
558+
// no-SteamID player (Ghost), so its identity can only be resolved via the
559+
// name-fallback path, which keys on `replay_timestamp.toISOString()`. The
560+
// all-time bucket rebuild scans EVERY current `sg` replay, so without the
561+
// `replay_timestamp is not null` filter in scopedCurrentResultsSql it loads
562+
// Ghost's row and crashes with
563+
// `Cannot read properties of null (reading 'toISOString')`. Distinct
564+
// sourceReplayIds keep the replays' unique object_key/checksum apart.
565+
const rotationId = await seedRotation(),
566+
playerAlpha = await seedPlayer("Alpha", "steam-a"),
567+
timedResultId = await seedParserResult({
568+
rawSnapshot: {
569+
contract_version: "3.0.0",
570+
parser: {},
571+
players: [{ eid: 101, n: "Alpha", sid: "steam-a" }],
572+
replay: missionEnvelope("sg_assault"),
573+
source: {},
574+
status: "success",
575+
},
576+
replayTimestamp: "2026-02-01T12:00:00.000Z",
577+
sourceReplayId: "sg-timed-replay",
578+
});
579+
await seedParserResult({
580+
rawSnapshot: {
581+
contract_version: "3.0.0",
582+
parser: {},
583+
players: [{ eid: 202, n: "Ghost" }],
584+
replay: missionEnvelope("sg_assault"),
585+
source: {},
586+
status: "success",
587+
},
588+
replayTimestamp: null,
589+
sourceReplayId: "sg-null-timestamp-replay",
590+
});
591+
await fullRunRepository.classifyGameTypesForCurrentReplays();
592+
593+
// Act — recalculate the timed replay; this rebuilds the sg per-rotation AND
594+
// sg all-time buckets, the latter scanning the null-timestamp replay too.
595+
const result =
596+
await repository.recalculatePlayerAndSquadStatsForParserResult(
597+
timedResultId,
598+
);
599+
600+
// Assert — recalc completes (no toISOString crash) and aggregates only the
601+
// timed replay: Alpha across the sg per-rotation + sg all-time scopes
602+
// (playerStats: 2), no squads. The null-timestamp replay contributes
603+
// nothing: no all-time player_stats row and no name-fallback nickname for
604+
// its no-SteamID player.
605+
expect(result).toEqual({
606+
playerStats: 2,
607+
rotationId,
608+
squadStats: 0,
609+
status: "recalculated",
610+
});
611+
const aggregatedPlayerIds = await pool.query<{ player_id: string }>(
612+
"select distinct player_id from player_stats",
613+
);
614+
expect(aggregatedPlayerIds.rows).toEqual([{ player_id: playerAlpha }]);
615+
const ghostNicknames = await pool.query<{ count: string }>(
616+
"select count(*) from player_nicknames where lower(nickname) = 'ghost'",
617+
);
618+
expect(ghostNicknames.rows[0]?.count).toBe("0");
619+
});
620+
555621
it("assigns replay rotation and replaces commander side aggregates", async () => {
556622
const rotationId = await seedRotation(),
557623
playerA = await seedPlayer("Alpha", "steam-a"),

0 commit comments

Comments
 (0)