Skip to content

fix(ingest): backfill and derive replay_timestamp from source_replay_id - #16

Merged
Afgan0r merged 5 commits into
masterfrom
fix/f12-replay-timestamp-backfill
Jun 14, 2026
Merged

Afgan0r merged 5 commits into
masterfrom
fix/f12-replay-timestamp-backfill

Conversation

@Afgan0r

@Afgan0r Afgan0r commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Problem

replays.replay_timestamp is NULL for ~1101 early replays (sg 187, mace 657, ~257 untyped) — they predate the primary (non-epoch) date source later replays carry. The F11 all-time-scope guard (and r.replay_timestamp is not null, #15) correctly excludes NULL-timestamp replays, so every player silently loses the games/kills/deaths in those replays. Proven on staging: player "Zero" roster = 894 sg replays (831 timed + 63 NULL) → all-time player_stats.games = exactly 831.

Fix

The date is recoverable: source_replay_id is the sg.zone Unix-epoch suffix (sg-zone-1624129684 → 2021-06-19). Recovered two ways — a fallback, never a replacement (NULL-only):

  1. Migration 0011 — one-time, idempotent, forward-only backfill of replay_timestamp = to_timestamp(<trailing \d{9,} epoch>) for NULL-only rows.
  2. Ingest path — pure deriveReplayTimestampFromSourceId / resolveReplayTimestamp helper; the promotion service resolves the effective timestamp before createReplay (keep primary, else fall back to the source-id epoch). Placed in the service per solidstats-server-ts-conventions §A (derivation = minimal business logic; repository stays data-access only).

The F11 guard is untouched (stays as the safety net). The migration regex and the TS helper agree (\d{9,} trailing epoch) so backfilled and newly-promoted replays are consistent. 9 digits is the smallest width excluding an incidental short numeric suffix (≥ 1e8 s = 1973).

⚠️ A full ops:stats:recalculate MUST follow deploy to fold the recovered replays into the all-time buckets.

Tests

  • Unit (replay-timestamp.test.ts): epoch parse — valid (sg-zone-…/mace-zone-…/bare/9-digit boundary), non-numeric → null, empty → null, 8-digit → null, digits-not-at-end → null; resolveReplayTimestamp keep/fallback/null.
  • Unit (service.test.ts): promotion derives from source_replay_id when staging timestamp is null; keeps a present staging timestamp.
  • Integration (repository/tests/postgres.test.ts): real service + repo + Postgres derivation on promotion; migration backfill UPDATE fills the epoch-suffixed NULL row and leaves a non-numeric id NULL.

Validation

pnpm lint / typecheck / format green; pnpm test (unit) 635 passed. Integration tests not run locally — no Docker in this environment; they run in CI (testcontainers). No OpenAPI contract change.

🤖 Generated with Claude Code

Afgan0r and others added 4 commits June 14, 2026 21:49
Early replays (~1101: sg 187, mace 657, ~257 untyped) carry a NULL
replay_timestamp because they predate the primary (non-epoch) date source
later replays have. The F11 all-time-scope guard (PR #15) correctly excludes
NULL-timestamp replays, so every player silently loses the games/kills/deaths
recorded in them.

The date is recoverable: source_replay_id is the sg.zone Unix-epoch suffix
(e.g. sg-zone-1624129684 -> 2021-06-19). Recover it two ways:

- Migration 0011: one-time, idempotent, forward-only backfill of
  replay_timestamp from to_timestamp(<trailing \d{9,} epoch>) for NULL-only
  rows. A primary timestamp is never overwritten.
- Ingest path: a pure deriveReplayTimestampFromSourceId / resolveReplayTimestamp
  helper; the promotion service resolves the effective timestamp before
  createReplay (keep primary, else fall back to the source-id epoch).

The F11 guard stays in place as the safety net. A full ops:stats:recalculate
must follow deploy to fold the recovered replays into the all-time buckets.

Tests: unit for the epoch parse (valid / non-numeric / short / non-trailing)
and the service fallback; integration for the promotion path and the migration
backfill UPDATE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

Address PR #16 review findings on the F12 source-id timestamp backfill.

- CORRECTNESS: the migration SQL and the TS helper diverged on long trailing
  digit runs (TS guarded with Number.isSafeInteger; SQL had no bound, so 14-18
  digits gave far-future dates and >=19 digits overflowed int8 and aborted).
  Bound the accepted epoch to a plausible Unix-seconds range [1e9 (2001-09) ..
  2e9 (2033-05)] on BOTH paths: the TS helper rejects out-of-range values, and
  0011 matches a trailing run of exactly 10 digits ((\D|^)\d{10}$), casts only
  the captured group, and re-checks the value BETWEEN 1000000000 AND 2000000000
  so an over-long run is skipped before the ::bigint cast instead of overflowing
  it. Backfilled and newly-promoted rows now agree exactly.
- TESTS: the integration test inlined a hand-copied UPDATE; rewrite it to read
  and execute the real 0011 migration file against seeded NULL rows (idempotent,
  NULL-only), and assert in-range, non-numeric, 13-digit, and 19-digit-overflow
  rows so drift in the .sql file is caught. Add boundary assertions to the TS
  unit test on both the accept and reject sides.
- DOCS: the 0011 down-comment referenced a promotion_evidence marker that is
  never written; replace it with a reversal that actually identifies backfilled
  rows by re-deriving the epoch from source_replay_id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TS helper used a greedy `\d{9,}$`, so a zero-padded all-numeric id
longer than 10 digits (e.g. `00000000001500000000`) was captured and
Number()-stripped to an in-range epoch (1500000000) and ACCEPTED, while
migration 0011's `(\D|^)\d{10}$` requires exactly 10 trailing digits
preceded by a non-digit or string start and leaves the same row NULL.
This broke the "TS and SQL accept/reject the same inputs" property.

Change the trailing-epoch pattern to `/(?:^|\D)(\d{10})$/`, capturing
exactly 10 trailing digits, then apply the unchanged [1e9..2e9] bound.
Both paths now accept `sg-zone-1624129684` and a bare `1624129684`, and
reject the zero-padded over-long run, 9-digit, 11+-digit, and
out-of-bound runs identically. Verified TS output matches an emulated
SQL regex+bound across an adversarial corpus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The range check bounds epochSeconds to [1e9, 2e9], so epochSeconds * 1000
lands in [1e12, 2e12] ms -- always far inside JS Date's valid range
(+/-8.64e15 ms). new Date() can therefore never yield an Invalid Date, so
`Number.isNaN(date.getTime())` is provably always false and its return-null
branch is dead code. That dead branch was the single uncovered branch
(line 51) failing the repo's 100% coverage gate in CI's Verify.

Remove the guard rather than suppress it (per conventions: delete
provably-dead code instead of adding a v8-ignore or an unreachable-case
test) and update the JSDoc to drop the now-impossible "or when it does not
yield a valid date" clause. The exact-10-digit regex, the [1e9, 2e9] bound,
and the migration-0011 SQL agreement are untouched.

replay-timestamp.ts now reports 100% statements/branches/functions/lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Afgan0r
Afgan0r marked this pull request as ready for review June 14, 2026 15:29
@Afgan0r
Afgan0r merged commit 22c07ff into master Jun 14, 2026
3 checks passed
@Afgan0r
Afgan0r deleted the fix/f12-replay-timestamp-backfill branch June 14, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant