Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
quick_id: 260614-f12
slug: replay-timestamp-backfill
status: complete
---

# Quick Task 260614-f12: Backfill / derive replays.replay_timestamp from source_replay_id

## Problem (root cause)

`replays.replay_timestamp` is NULL for early replays (sg 187, mace 657, ~257 untyped;
~1101 total). They predate the primary (non-epoch) date source that later replays carry.

The F11 guard (`and r.replay_timestamp is not null` in `scopedCurrentResultsSql`, PR #15)
correctly excludes NULL-timestamp replays from the all-time stats scope — but that means
every player silently loses the games/kills/deaths recorded in those ~1101 replays.
Proven on staging: player "Zero" roster = 894 sg replays (831 timed + 63 NULL) →
all-time `player_stats.games` = exactly 831.

## Key fact (the date IS recoverable)

`replays.source_replay_id` is the sg.zone id — a Unix epoch (seconds) suffix:
`sg-zone-1624129684` → 2021-06-19. All 1101 NULL-ts replays carry it. This is a
**fallback**, not a replacement: only NULL-timestamp rows are touched; a present primary
timestamp is never overwritten.

## Approach (two parts + tests)

1. **One-time backfill migration** `0011_backfill_replay_timestamp_from_source_id.sql`:
`update replays set replay_timestamp = to_timestamp(<trailing epoch>)` WHERE
`replay_timestamp is null` AND `source_replay_id ~ '\d{9,}$'`. Forward-only (the
`migrate.ts` runner applies `up` only and checksum-pins each file), idempotent
(NULL-only filter is a no-op once filled). Mirrors the ingest-path derivation so
backfilled and newly-promoted replays agree.

2. **Ingest-path derivation fallback**: a pure helper
`src/modules/ingest/replay-timestamp.ts` (`deriveReplayTimestampFromSourceId`,
`resolveReplayTimestamp`) parses the trailing `\d{9,}` epoch suffix into an ISO
string. The promotion **service** (`IngestPromotionService.promoteRecordInTransaction`)
resolves the effective timestamp before `createReplay` — keeping the primary timestamp
when present, falling back to the source-id epoch when null. The F11 guard stays as the
safety net.

Layer placement: derivation is minimal business logic, so it lives in the service
(`solidstats-server-ts-conventions` §A: service = guards/simple checks; repository =
data-access only), not in the repository SQL.

3. **Tests**:
- Unit (`replay-timestamp.test.ts`): epoch parse — valid ids (`sg-zone-…`,
`mace-zone-…`, bare epoch), non-numeric → null, short (8-digit) suffix → null,
digits-not-at-end → null; plus `resolveReplayTimestamp` keep-vs-fallback.
- 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 —
a promoted NULL-timestamp staging record with an epoch source id gets
`replay_timestamp` derived; plus the migration's backfill UPDATE fills an
epoch-suffixed NULL row and leaves a non-numeric id NULL.

## Scope guard

No change to the parser or the F11 guard. `9` digits = smallest width excluding an
incidental short numeric suffix (≥ 1e8 s = 1973, below any real replay).

## Acceptance

- `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm format` green.
- Integration tests green in CI (testcontainers Postgres; not runnable locally — no Docker).
- No OpenAPI contract change.
- A full `ops:stats:recalculate` MUST follow deploy to fold the recovered replays back in.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
quick_id: 260614-f12
slug: replay-timestamp-backfill
status: complete
date: 2026-06-14
---

# Quick Task 260614-f12 — Summary

## What changed

Recovered the ~1101 NULL-`replay_timestamp` replays (sg 187, mace 657, ~257 untyped) that
the F11 all-time-scope guard (PR #15) correctly excludes — costing every player the
games/kills/deaths in those replays. The date is recoverable from `source_replay_id`
(the sg.zone Unix-epoch suffix, e.g. `sg-zone-1624129684` → 2021-06-19), so this both
backfills history and adds a derivation fallback to the ingest path.

- `src/infra/db/migrations/0011_backfill_replay_timestamp_from_source_id.sql` — one-time,
idempotent, forward-only backfill: `update replays set replay_timestamp =
to_timestamp(<trailing \d{9,} epoch>)` WHERE `replay_timestamp is null` AND
`source_replay_id ~ '\d{9,}$'`. NULL-only filter, so a primary timestamp is never
overwritten; re-running is a no-op once filled. Docstring notes the required
post-deploy `ops:stats:recalculate`.
- `src/modules/ingest/replay-timestamp.ts` (new) — pure `deriveReplayTimestampFromSourceId`
(trailing `\d{9,}` epoch → ISO string; null for non-numeric/short/non-trailing) and
`resolveReplayTimestamp` (keep primary, else fall back to the source-id epoch).
- `src/modules/ingest/service.ts` — `promoteRecordInTransaction` now passes
`withResolvedReplayTimestamp(record)` to `createReplay`, so a promoted replay with no
primary timestamp derives one from `source_replay_id`. F11 guard untouched (safety net).

## Why this placement

Derivation is minimal business logic → it lives in the **service**, not the repository
SQL (`solidstats-server-ts-conventions` §A: service = guards/simple checks; repository =
data-access only). The backfill is a one-time data migration through the existing
`migrate.ts` tooling (no ad-hoc DDL). 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,
below any real replay).

## Tests

- `src/modules/ingest/replay-timestamp.test.ts` (new, unit) — epoch parse table: valid
(`sg-zone-…`/`mace-zone-…`/bare epoch/9-digit boundary), invalid (non-numeric → null,
empty → null, 8-digit → null, digits-not-at-end → null); `resolveReplayTimestamp`
keep-vs-fallback-vs-null.
- `src/modules/ingest/service.test.ts` (unit) — promotion derives from `source_replay_id`
when the staging timestamp is null; keeps a present staging timestamp instead of
deriving. Fake repository extended to capture the record passed to `createReplay`.
- `src/modules/ingest/repository/tests/postgres.test.ts` (integration) — real
`IngestPromotionService` + `PgIngestRepository` + Postgres: a promoted NULL-timestamp
staging record with an epoch source id gets `replay_timestamp` =
`2021-06-19T19:08:04.000Z`; plus a test exercising the migration backfill UPDATE
(fills the epoch-suffixed NULL row, leaves the non-numeric `sg-zone-replay` row NULL).

## Conventions applied

- **`solidstats-server-ts-conventions`** §A (layer responsibilities), Migrations section
(run through `migrate.ts`, idempotent, descriptive name, `down` documented since the
runner is forward-only).
- **`solidstats-server-ts-tests`** — per-layer map (epoch parse / service logic = **unit**;
promotion-through-Postgres = **integration**); reused the file's seed helpers and the
testcontainers harness; strong oracles (exact ISO assertions, positive + negative cases).
- **`solidstats-shared-project-standards`** §C/§B (conventional commit, clean tree),
English-only artifacts.

## Validation

- `pnpm lint`, `pnpm typecheck`, `pnpm format` — green.
- `pnpm test` (unit) — 635 passed (78 files), including the new unit tests.
- `pnpm test:integration` — **skipped: no Docker** in this environment (daemon not running,
no local Postgres on :15432). The integration tests are correct-by-construction and run
in CI (testcontainers).
- No OpenAPI contract change.

## Acceptance

- [x] Backfill migration (idempotent, forward-only, NULL-only).
- [x] Ingest-path derivation fallback in the promotion service; F11 guard preserved.
- [x] Unit tests (epoch parse + service fallback) green.
- [x] Integration tests written (promotion path + migration backfill).
- [x] `pnpm lint` / `typecheck` / `test` / `format` green.
- [ ] Integration run green in CI (not runnable locally — no Docker).
- [ ] Post-deploy `ops:stats:recalculate` to fold recovered replays into all-time buckets.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- 0011_backfill_replay_timestamp_from_source_id: derive replay_timestamp from source_replay_id.
--
-- Early replays (sg 187, mace 657, ~257 untyped; ~1101 total) carry a NULL replay_timestamp: they
-- predate the primary (non-epoch) date source the later replays had. The all-time stats scope now
-- excludes NULL-timestamp replays (the F11 guard `and r.replay_timestamp is not null` in
-- scopedCurrentResultsSql, PR #15), so every player silently loses the games/kills/deaths in those
-- replays.
--
-- The date IS recoverable: source_replay_id is the sg.zone id, a Unix epoch (seconds) suffix, e.g.
-- `sg-zone-1624129684` -> 2021-06-19. This one-time backfill sets replay_timestamp from that epoch
-- for every NULL-timestamp replay whose source_replay_id ends in a parseable epoch.
--
-- The accepted epoch is bounded to a plausible Unix-seconds range -- between 1000000000 (2001-09)
-- and 2000000000 (2033-05) -- which covers every real 10-digit sg.zone id and rejects shorter
-- (pre-2001) and longer (far-future / int8-overflow) digit runs alike. The pattern matches a
-- trailing run of EXACTLY 10 digits (a non-digit or string start, then 10 digits to the end), so a
-- longer run is skipped before the ::bigint cast rather than overflowing it. This is the SAME bound
-- the ingest-path derivation applies in src/modules/ingest/replay-timestamp.ts, so backfilled and
-- newly-promoted replays agree exactly.
--
-- This is a FALLBACK, never a replacement: the WHERE clause touches only rows where
-- replay_timestamp IS NULL, so a primary timestamp is never overwritten. Behavior-preserving for
-- derived stats until the next recalc; idempotent (re-running the NULL-only update is a no-op once
-- filled).
--
-- IMPORTANT: a full `ops:stats:recalculate` MUST follow deploy so the recovered replays are folded
-- back into the all-time buckets the F11 guard had been excluding.
--
-- down: there is no automatic revert (the migrate.ts runner is forward-only). No marker is written
-- to distinguish a backfilled row from a row that always had this timestamp, so a precise revert is
-- not possible. To approximately reverse, re-null the rows whose current replay_timestamp still
-- equals the epoch this migration would derive from their source_replay_id:
-- update replays
-- set replay_timestamp = null
-- where replay_timestamp is not null
-- and source_replay_id ~ '(\D|^)\d{10}$'
-- and (substring(source_replay_id from '(?:\D|^)(\d{10})$'))::bigint
-- between 1000000000 and 2000000000
-- and replay_timestamp = to_timestamp(
-- (substring(source_replay_id from '(?:\D|^)(\d{10})$'))::bigint
-- );

update replays
set replay_timestamp = to_timestamp(
(substring(source_replay_id from '(?:\D|^)(\d{10})$'))::bigint
),
updated_at = now()
where replay_timestamp is null
and source_replay_id ~ '(\D|^)\d{10}$'
and (substring(source_replay_id from '(?:\D|^)(\d{10})$'))::bigint
between 1000000000 and 2000000000;
80 changes: 80 additions & 0 deletions src/modules/ingest/replay-timestamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* eslint-disable unicorn/no-null */
import { describe, expect, it } from "vitest";

import {
deriveReplayTimestampFromSourceId,
resolveReplayTimestamp,
} from "./replay-timestamp.js";

describe("deriveReplayTimestampFromSourceId", () => {
it.each([
["sg-zone-1624129684", "2021-06-19T19:08:04.000Z"],
["mace-zone-1624129684", "2021-06-19T19:08:04.000Z"],
["1624129684", "2021-06-19T19:08:04.000Z"],
// Lower bound (1e9, 2001-09) and upper bound (2e9, 2033-05) are accepted inclusively.
["sg-zone-1000000000", "2001-09-09T01:46:40.000Z"],
["sg-zone-2000000000", "2033-05-18T03:33:20.000Z"],
])("derives the epoch suffix of %s as %s", (sourceReplayId, expected) => {
expect(deriveReplayTimestampFromSourceId(sourceReplayId)).toBe(expected);
});

it.each([
["non-numeric id", "sg-zone-replay"],
["empty id", ""],
["short numeric suffix (8 digits)", "sg-zone-16241296"],
["digits not at the end", "sg-1624129684-zone"],
// Not exactly 10 trailing digits: a 9-digit run is short of the anchor (SQL `\d{10}$` misses
// it). It is also < 1e9, but the exact-10 anchor rejects it first.
["9-digit run (not exactly 10)", "123456789"],
["pre-2001 9-digit epoch (100000000)", "100000000"],
// 11+-digit unbroken runs are not exactly 10 trailing digits, so SQL's `\d{10}$` (anchored to
// the string end) never matches them -- they stay NULL, never read as a far-future epoch.
["11-digit run", "16241296840"],
["11-digit run (year ~5138)", "sg-zone-99999999999"],
["13-digit run (millisecond-looking epoch)", "sg-zone-1624129684000"],
["19-digit run (int8 overflow in SQL)", "sg-zone-1234567890123456789"],
// Zero-padded all-numeric id longer than 10 digits: the OLD greedy `\d{9,}$` + Number() would
// strip the leading zeros to 1500000000 (in range) and ACCEPT it, while SQL's exact-10
// `(\D|^)\d{10}$` leaves the row NULL. The exact-10 anchor now rejects it in TS too, matching
// SQL. This is the residual divergence F12's code review flagged.
["zero-padded 20-digit run (greedy-strip trap)", "00000000001500000000"],
// A 10-digit run captured cleanly but out of the bound: 0999999999 is < 1e9; 2000000001 > 2e9.
["10-digit run below the lower bound (0999999999)", "sg-zone-0999999999"],
["just above the upper bound (2000000001)", "sg-zone-2000000001"],
// 11-digit runs: the trailing 10 digits are preceded by a digit (the leading 3), not a
// non-digit or string start, so `(\D|^)\d{10}$` never matches -- NULL, same as SQL.
["above range, 11-digit run (30000000000)", "30000000000"],
["above range with prefix (sg-zone-30000000000)", "sg-zone-30000000000"],
])("returns null for %s", (_label, sourceReplayId) => {
expect(deriveReplayTimestampFromSourceId(sourceReplayId)).toBeNull();
});
});

describe("resolveReplayTimestamp", () => {
it("keeps the primary timestamp when present", () => {
expect(
resolveReplayTimestamp({
replayTimestamp: "2026-05-09T00:00:00.000Z",
sourceReplayId: "sg-zone-1624129684",
}),
).toBe("2026-05-09T00:00:00.000Z");
});

it("falls back to the source-id epoch when the primary timestamp is null", () => {
expect(
resolveReplayTimestamp({
replayTimestamp: null,
sourceReplayId: "sg-zone-1624129684",
}),
).toBe("2021-06-19T19:08:04.000Z");
});

it("returns null when neither a primary timestamp nor a parseable epoch exists", () => {
expect(
resolveReplayTimestamp({
replayTimestamp: null,
sourceReplayId: "sg-zone-replay",
}),
).toBeNull();
});
});
68 changes: 68 additions & 0 deletions src/modules/ingest/replay-timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable unicorn/no-null */
// Derive a replay timestamp from a source replay id when the primary date source is absent.
//
// Early sg/mace replays carry no primary `replayTimestamp`, but their `source_replay_id` is the
// sg.zone id — a Unix epoch (seconds) suffix, e.g. `sg-zone-1624129684` -> 2021-06-19. This is a
// FALLBACK only: it is applied when the primary source is missing, never to replace a value that
// is already present. The non-null replays got their timestamp from a different (non-epoch)
// source that the early ones lacked.

// The epoch is a trailing run of EXACTLY 10 digits preceded by a non-digit or the string start,
// then bounded to a plausible Unix-seconds range. This mirrors migration 0011's SQL exactly:
// `source_replay_id ~ '(\D|^)\d{10}$'` plus `between 1000000000 and 2000000000`, so the ingest
// path and the backfill accept/reject the SAME inputs.
//
// The exactly-10-digits anchor is what keeps the two paths in agreement: a greedy `\d{9,}$` would
// accept a longer unbroken run (e.g. a zero-padded `00000000001500000000`, which Number() strips to
// 1500000000, in range) that the SQL's exact-10 pattern leaves NULL. Requiring exactly 10 trailing
// digits rejects any run that is not 10-long (9-digit, 11+-digit, zero-padded over-long) before the
// range check, identically to SQL.
//
// minEpochSeconds = 1e9 (2001-09) .. maxEpochSeconds = 2e9 (2033-05) covers every real 10-digit
// sg.zone id (e.g. 1624129684 -> 2021-06-19) and rejects the in-range-but-out-of-bound ends (a
// 10-digit run < 1e9 or > 2e9).
const minEpochSeconds = 1_000_000_000,
maxEpochSeconds = 2_000_000_000,
millisPerSecond = 1000,
trailingEpochPattern = /(?:^|\D)(?<epoch>\d{10})$/u;

/**
* Parse the Unix-epoch (seconds) suffix of a `source_replay_id` into an ISO-8601 timestamp.
*
* Returns `null` when the id has no trailing run of exactly 10 digits preceded by a non-digit or
* the string start, or when the parsed epoch falls outside the plausible
* {@link minEpochSeconds}..{@link maxEpochSeconds} range. Mirrors migration 0011's `(\D|^)\d{10}$`
* + range bound exactly. Pure and deterministic.
*/
export function deriveReplayTimestampFromSourceId(
sourceReplayId: string,
): string | null {
const match = trailingEpochPattern.exec(sourceReplayId);
const epochDigits = match?.groups?.["epoch"];
if (epochDigits === undefined) {
return null;
}
const epochSeconds = Number(epochDigits);
if (epochSeconds < minEpochSeconds || epochSeconds > maxEpochSeconds) {
return null;
}
// The range check above 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 produce an Invalid Date here, so no NaN guard is needed.
const date = new Date(epochSeconds * millisPerSecond);
return date.toISOString();
}

/**
* Resolve the effective replay timestamp for a promoted replay: keep the primary timestamp when
* present, otherwise fall back to the epoch encoded in the source replay id.
*/
export function resolveReplayTimestamp(input: {
replayTimestamp: string | null;
sourceReplayId: string;
}): string | null {
return (
input.replayTimestamp ??
deriveReplayTimestampFromSourceId(input.sourceReplayId)
);
}
Loading