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
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: test

# Fast, hermetic checks only. The maintenance-cycle harness
# (`npm run e2e:maintenance`) is deliberately excluded: it spawns real
# crawler and scanner processes against a mock Bungie server and runs for
# minutes, which is not what a per-push gate is for.
#
# NOTE ON ARCHITECTURE: this runs on x86_64, while production is ARM64
# (Oracle A1 Flex). better-sqlite3 is a native module, so CI compiles and
# tests a different binary than production runs. Native-level differences
# in better-sqlite3 will not be caught here.

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

# `ci`, not `install`: the lockfile is the source of truth for
# deployments, and a CI run that silently resolved different versions
# would be testing something production never sees.
- run: npm ci

- run: npm run lint

# Nothing else typechecks the repo — `npm test` transpiles without
# checking, and a full `next build` is too slow for this gate.
- run: npx tsc --noEmit

- run: npm test
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,7 @@ localhost.key
# Sentry Config File
.env.sentry-build-plugin

certificates
certificates

#handoff docs
docs/handoffs/
56 changes: 56 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Destiny Farm Finder

Tracks Destiny 2 raid activity in near real time: which players are raiding right now, and who
has completed what. A set of background crawlers observes the Bungie API and writes to SQLite;
the web app only reads.

## Language

**Fireteam**:
A group of players playing a Destiny activity together. The unit users care about — every card on
the active-sessions page is one fireteam.
_Avoid_: party, group, team, squad, lobby

**Active Session**:
A fireteam observed to be inside a raid right now. Ceases to be active when the crawler confirms
the raid ended, or when the observation goes stale.
_Avoid_: live session, current activity, in-progress raid

**Roster**:
The players making up a fireteam, as reported by Bungie. May be incomplete — Bungie does not
always disclose every member — so a roster of one usually means limited visibility rather than a
genuine solo run.
_Avoid_: party members, participants, players in session

**Tracked Player**:
A player the system knows about and will poll for activity. Identified by `Name#Code`; a player
becomes tracked by being discovered in a raid alongside someone already tracked.
_Avoid_: user, account, member

**Raid**:
Destiny's six-player endgame activity, and the only activity type the leaderboards and the
active-sessions list cover. Other activities are observed but never displayed.
_Avoid_: activity (too broad), instance

**Full Clear**:
A raid played from the first encounter through the final boss, as opposed to joining at a
checkpoint. Only Bungie's own report that the activity began at the start establishes this — no
other signal is authoritative, and one that looks like it is has been wrong since Bungie stopped
publishing it.
_Avoid_: complete run, fresh run

**Checkpoint Run**:
A raid entered partway through, at a saved encounter. Observed and stored like any other run, but
never counted toward a leaderboard. The majority of raids we see.
_Avoid_: partial run, CP run

**Completion**:
One full clear finished by a particular player, counted once per raid instance however many
characters they brought to it. The unit every leaderboard ranks by. A player being present for a
cleared raid is not enough — they must have finished it themselves.
_Avoid_: clear, kill, run

**Farm**:
Repeatedly replaying a single raid encounter or checkpoint for rewards, rather than progressing
through the raid. The activity the site is named for.
_Avoid_: grind, rerun
24 changes: 24 additions & 0 deletions docs/adr/0001-fireteam-denominated-display-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Active-session limits are denominated in fireteams, not rows

`active_sessions` is keyed by `membership_id`, so a single fireteam produces up to six rows — one
per tracked player in it. The read path originally capped those raw rows (`ORDER BY started_at
DESC LIMIT 200`) and deduped into fireteams afterwards, which meant the limit was spent on
duplicates and, because it sorted by start time, evicted the longest-running raids first. In
practice ~1000 live rows rendered ~110 cards and nothing older than about five minutes was ever
visible. We now scan a generous bound of raw rows, dedupe into fireteams, and only then apply the
user-facing cap — which is counted in fireteams.

## Consequences

- Two separate limits exist and must not be collapsed into one: `ACTIVE_SESSION_ROW_SCAN_LIMIT`
(raw rows, default 3000) and `ACTIVE_SESSION_DISPLAY_LIMIT` (fireteams, default 600).
Re-introducing a single `LIMIT` in SQL restores the bug.
- The row scan is ordered by `checked_at DESC`, not `started_at DESC`. It is served by
`idx_active_sessions_checked_at`, and if the bound is ever hit it sheds the *stalest* rows —
the ones closest to ageing out — instead of the longest-running raids.
- Dedupe happens before name enrichment, so the display-name lookup covers only the fireteams
actually rendered rather than every row scanned.
- The row bound has roughly 2x headroom over what the crawler can produce: at
`CRAWLER_SESSION_POLLING_LIMIT` rows per cycle across the 900s freshness window, at most ~1500
rows can be fresh simultaneously. Raising the polling limit materially should prompt a review
of this bound.
22 changes: 22 additions & 0 deletions docs/adr/0002-session-count-reports-true-total.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The active-session count reports the true total, not the number of cards shown

`countActiveRaidSessions` feeds the nav StatsBar and the OG share cards, while
`/api/active-sessions` feeds the page. These deliberately no longer agree: the count reports every
live fireteam, whereas the list is capped at `ACTIVE_SESSION_DISPLAY_LIMIT`. The headline number
answers "how busy is Destiny right now", which is the question a share card and a stats bar are
actually asking; capping it to whatever happened to fit on screen would understate real activity
and undersell the site.

This is a deliberate exception to the invariant that `dedupe.ts` was written to protect — that the
count and the list collapse duplicate rows identically. That invariant still holds: both go through
`getDedupedActiveSessions`, so they can never disagree about *what a fireteam is*. Only the cap
differs.

## Consequences

- `/api/active-sessions` returns `total` (all live fireteams) alongside `shown` (those under the
cap), so the page can disclose the difference rather than hiding sessions silently.
- A discrepancy between the StatsBar number and the visible card count is expected when the cap
bites, and is not a bug to be "fixed" by capping the count.
- The server logs a warning whenever the cap bites, since the default (600) sits close to observed
prod volume and the gap would otherwise be invisible.
45 changes: 45 additions & 0 deletions docs/adr/0003-tests-run-against-a-real-sqlite-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Tests run against a real SQLite file, not `:memory:`

The test suite gives each test file its own throwaway database in a `mkdtemp`
directory, pointed at by `RAID_TRACKER_DB_PATH`, rather than using SQLite's
`:memory:` database. `:memory:` looks like the obvious choice — faster, no
cleanup — so the reason for not using it needs recording.

## Why

SQLite cannot put an in-memory database into WAL mode. `PRAGMA journal_mode = WAL`
returns `memory` for `:memory:` and `wal` for a file, silently:

```
:memory: journal_mode = WAL -> 'memory'
file journal_mode = WAL -> 'wal'
```

Production runs WAL. The entire justification for testing against a real database
rather than a mock is that it validates the real SQL under real semantics, so
running the suite under a different journal mode gives up most of what the
approach was bought for.

A temp *directory* rather than just a temp file, because `DATA_DIR` in
`src/lib/maintenance/state.ts` derives from `dirname(RAID_TRACKER_DB_PATH)`.
Relocating the database therefore relocates `maintenance-state.json` for free.
That is not incidental: `getDb()` calls `isDbQuiesceActive()` on *every*
invocation, which reads that file from disk — so a suite pointed at the real data
directory would throw `DatabaseMaintenanceError` from every test if it happened
to run while a maintenance vacuum was in progress.

The path is set in a Vitest `setupFile` rather than inside a helper, because
`DB_PATH` is a module-level constant resolved at import time. Setting it before
the test file's own imports run is what lets test files use ordinary static
imports instead of `await import()` throughout.

## Consequences

- Test databases cost a `mkdtemp` plus `initializeSchema()` per test file —
roughly 5–15 ms on tmpfs. At this suite's size that is a few milliseconds
overall, well below the value of matching production semantics.
- Temp directories leak into the system temp dir if a test process is killed
before `afterAll` runs. Harmless, and the OS clears them.
- The schema under test is the production schema by construction: `getDb()` runs
`initializeSchema()`, including the `ended_at` migration guard and the Phase 3
indexes. There is no second schema definition that can drift.
41 changes: 41 additions & 0 deletions docs/adr/0004-mock-only-at-the-network-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Mock only at the network boundary

Tests stub `fetch` and nothing else. There is no `vi.mock()` of any module in
`src/`, and the database is real rather than faked. This is a deliberate
constraint, not an oversight — mocking our own modules is the default habit in
most test suites, so the absence needs explaining before someone helpfully adds it.

## Why

Leaderboard integrity is the product. The failure that matters here is not a
crash but a silently wrong row set, and that failure lives in exactly the places
mocking would erase: the SQL, and the shape of what Bungie actually returns.

- **Mocking `@/lib/db/queries` would test the mock.** A test asserting that
`getLeaderboard` returns what the mock was told to return proves nothing about
whether the SQL selects the right runs. `better-sqlite3` opens a database in
about a millisecond, so a real one is both faster than the mock scaffolding and
actually load-bearing.
- **Seeding goes through `insertFullPGCR`, not raw INSERTs.** All four production
ingestion sources funnel through that function, and it is where `ended_at` is
derived and `players.last_seen_at` advanced. Raw inserts would let tests build
rows that production could never produce, so the tests would pass against
impossible data.
- **`fetch` is the one boundary worth faking.** It is genuinely external, genuinely
slow, rate-limited, and returns different data every day. Everything on our side
of it is ours to verify.

A setup file (`tests/setup/no-network.ts`) replaces `fetch` with a thrower before
every test, so a test that reaches the real internet fails loudly instead of
quietly burning Bungie API quota and going flaky against live data.

## Consequences

- Test databases are real; see ADR 0003 for why they are files rather than
`:memory:`.
- Tests that need a specific Bungie response stub `fetch` explicitly. The guard
records itself as the original, so the block is restored automatically for the
next test with no per-file cleanup.
- Fixtures are captured from the live API rather than hand-authored, so they
encode Bungie's real quirks instead of our beliefs about them. Builders in
`tests/helpers/` cover permutations where only one field needs to vary.
Loading
Loading