From b805e8734e49d8737a2113992a3bb1a1372cae54 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 11:30:48 +0100
Subject: [PATCH 01/23] docs: design achievement fingerprint discovery
---
CONTEXT.md | 25 ++
...chievement-fingerprint-discovery-design.md | 241 ++++++++++++++++++
2 files changed, 266 insertions(+)
create mode 100644 CONTEXT.md
create mode 100644 docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..00833ed
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,25 @@
+# SlashWho
+
+SlashWho publishes World of Warcraft character-relationship information derived from public upstream data.
+
+## Language
+
+**Privacy-hidden ownership**:
+The Raider.IO state indicating that a character's ownership link is intentionally not public. It is SlashWho's sole privacy signal for inferred relationships.
+_Avoid_: Hidden alt, upstream opt-out
+
+**Fingerprint-derived link**:
+A relationship between characters inferred from Blizzard achievement-completion data, rather than declared by Raider.IO.
+_Avoid_: Verified link, confirmed alt
+
+**Alt list**:
+The public list of characters linked to a root character. It intentionally does not distinguish Raider.IO-declared relationships from fingerprint-derived links.
+_Avoid_: Verified-alt list, inferred-alt list
+
+**Partial snapshot**:
+An immutable historical result known not to contain every discoverable relationship. It is public as partial while its limitation reason remains internal.
+_Avoid_: Failed snapshot, incomplete refresh
+
+**Ephemeral fingerprint**:
+Achievement-completion data held only while a single discovery sweep is running. It is discarded before snapshot publication and never becomes a stored signature.
+_Avoid_: Fingerprint cache, stored signature
diff --git a/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md b/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md
new file mode 100644
index 0000000..00481fe
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-achievement-fingerprint-discovery-design.md
@@ -0,0 +1,241 @@
+# Achievement-Fingerprint Discovery Design
+
+**Date:** 2026-08-10
+
+**Status:** Approved for implementation planning
+
+## Summary
+
+SlashWho will extend its existing durable `discover-character` job with Blizzard
+achievement-fingerprint discovery. The job keeps Raider.IO discovery as its first
+phase, then—when a root character is eligible—uses the root's current Blizzard
+guild roster as the initial candidate source. It compares each candidate's
+achievement completion timestamps with the root's in memory and folds accepted
+matches into the same immutable snapshot as Raider.IO relationships.
+
+This is one deployable feature. Public search, character, history, and snapshot
+API shapes do not change. Fingerprint-derived and Raider.IO-declared characters
+appear in one undifferentiated alt list. The implementation persists neither
+achievement fingerprints nor scores, timestamps, achievement IDs, raw Blizzard
+responses, access tokens, or credentials.
+
+The design records the decisions in the completed
+[achievement-fingerprint map](https://github.com/Erilla/SlashWho/issues/4).
+
+## Goals
+
+- Automatically discover same-account characters from Blizzard achievement
+ completion data during eligible searches.
+- Begin candidate enumeration with the root character's current Blizzard guild
+ roster.
+- Preserve the existing immutable-snapshot and durable-worker contracts.
+- Bound Blizzard use with a FIFO, shared rolling budget of 28,800 requests per
+ hour and a configured per-sweep request cap.
+- Reuse the existing public character and search API without exposing discovery
+ provenance, confidence, queue state, or budget information.
+- Exclude privacy-hidden ownership from fingerprint-derived linkage, using
+ Raider.IO as the sole privacy signal.
+- Keep all achievement material ephemeral to one in-memory sweep.
+
+## Non-goals
+
+- Candidate sources beyond the root's current guild roster, including guild
+ history, raid-log collection, and a global guild index.
+- Cross-region matching or China-region matching; Blizzard's relevant Profile
+ API does not support the latter, and fingerprints are not comparable across
+ regions.
+- Persisting achievement IDs, timestamps, signatures, match scores, raw bodies,
+ access tokens, or user-supplied Blizzard credentials.
+- A public match score, source badge, queue indicator, or operational budget
+ display.
+- Stable Blizzard character IDs. Rename/transfer continuity remains a deferred
+ standalone effort ([#27](https://github.com/Erilla/SlashWho/issues/27)).
+- Tracing `Ictinus` to `Mistakinus`; that is a separate investigation
+ ([#24](https://github.com/Erilla/SlashWho/issues/24)).
+
+## Terminology
+
+This design uses the project terms recorded in `CONTEXT.md`:
+
+- **privacy-hidden ownership** is Raider.IO's intentionally non-public ownership
+ state and the only privacy signal used for inferred links;
+- **fingerprint-derived link** is a relationship inferred from Blizzard
+ achievement completion data;
+- **alt list** is the public, provenance-free relationship list;
+- **partial snapshot** is an immutable result known not to contain every
+ discoverable relationship; and
+- **ephemeral fingerprint** is achievement data held only for one sweep and
+ discarded before publication.
+
+## Architecture
+
+`discover-character` remains the only durable job and only snapshot writer. Its
+phases become:
+
+1. Run the existing Raider.IO traversal and produce its transient relationship
+ observations.
+2. Determine whether the root is eligible for a fingerprint sweep. A root is
+ eligible when no successfully published fingerprint sweep has occurred in
+ the previous seven days.
+3. When eligible, enter the shared Blizzard admission queue. The job waits in
+ FIFO order for capacity; it does not fail a user search or occupy a worker
+ execution while waiting.
+4. At admission, reserve the full configured sweep cap against the shared,
+ rolling 28,800-request/hour budget. No Blizzard request begins before this
+ reservation succeeds.
+5. Fetch the root guild roster and root achievement data, then fetch and compare
+ candidates until the cap is reached or the roster is exhausted.
+6. Merge accepted fingerprint-derived characters with the Raider.IO observations
+ and atomically publish one snapshot.
+
+The job has no separate public API, snapshot type, or completion state for the
+fingerprint phase. A completed cap-bounded sweep is a successful partial
+snapshot with the internal limitation `fingerprint_sweep_capped`. A successful
+sweep with no guild or an empty roster is a measured result with no
+fingerprint-derived additions.
+
+## Blizzard integration and matching
+
+The worker obtains a client-credentials access token using the operator-managed
+`BLIZZARD_CLIENT_ID` and `BLIZZARD_CLIENT_SECRET`. It uses Blizzard's regional
+Profile API to resolve the root's current guild and roster, then fetches
+achievement completion data for the root and each roster candidate.
+
+An ephemeral fingerprint maps achievement IDs to their completion timestamps.
+For a same-region candidate, the matcher counts common achievement IDs and the
+subset whose timestamps are identical. It accepts a candidate only when both
+conditions hold:
+
+- at least 200 achievement IDs are common; and
+- at least 20% of those common IDs have identical completion timestamps.
+
+The accepted candidate contributes only its ordinary character fields and an
+internal `fingerprint` discovery source to the combined snapshot membership.
+Scores and the comparison inputs are discarded immediately after each candidate
+is evaluated. No fingerprint may cross a job boundary or survive publication.
+
+Before a fingerprint-derived character is admitted to snapshot membership, the
+worker applies the existing Raider.IO privacy-hidden ownership check. A
+privacy-hidden candidate is excluded from fingerprint discovery. Raider.IO
+relationships continue to follow Raider.IO visibility as they do today.
+
+## Freshness, queueing, and budget accounting
+
+Raider.IO retains its existing 24-hour refresh model. Fingerprint discovery is
+decoupled: a root may successfully run at most one sweep every seven days.
+
+When a root is due, the first search that creates or refreshes its discovery run
+causes the fingerprint phase to be queued. Later searches for that root reuse
+the same active run. While an eligible sweep waits or runs, the existing current
+snapshot remains visible. Public responses do not state that a fingerprint sweep
+is due, queued, admitted, or running.
+
+The system persists only operational state required to enforce this policy:
+
+- per-root successful fingerprint-sweep time and the active/queued run
+ reference;
+- an internal terminal reason for the sweep; and
+- a Blizzard-budget reservation ledger containing run identity, reserved count,
+ accounting window, and release/expiry state.
+
+The ledger is updated transactionally when capacity is reserved. Used requests
+remain charged to the rolling window. Unused reserved capacity is released when
+the sweep finishes or aborts. A retryable failure releases only unused capacity;
+it never erases the usage already consumed. Only successful snapshot publication
+advances the seven-day eligibility window.
+
+All values are validated worker configuration: client credentials, sweep cap,
+hourly budget (initially 28,800), identical-timestamp percentage (initially
+20), common-achievement floor (initially 200), and sweep cadence (initially
+seven days).
+
+## Snapshot and failure semantics
+
+A fingerprint sweep is atomic. Its roster, candidate list, fingerprints,
+comparison results, and progress cursor exist only in process memory.
+
+- Reaching the configured request cap publishes the allowed partial snapshot and
+ ends the sweep. A later eligible run starts again from the root; it does not
+ resume a cursor or reuse a candidate list.
+- A transport failure, 429, 5xx, malformed response, schema drift, process
+ abort, or deployment shutdown before publication discards all in-memory sweep
+ state and leaves the prior snapshot current.
+- Retryable failures use the existing bounded exponential-backoff path. A retry
+ restarts the whole atomic job from the root.
+- Graceful shutdown stops beginning new Blizzard requests, abandons an unfinished
+ sweep before the existing drain deadline, and relies on a later retry instead
+ of extending deployment draining.
+- A no-guild or empty-roster response is a successful measured sweep. Unsupported
+ region coverage is an explicitly recorded internal limitation rather than an
+ unmeasured failure.
+
+Snapshot membership stores no score or confidence. It may retain the existing
+internal discovery-source field for diagnostics, extended with `fingerprint`;
+the shared serializers continue to omit this field from every public response.
+
+## Observability and privacy
+
+Structured logs and internal metrics record only operational information:
+
+- FIFO queue depth and admission wait time;
+- per-caller admission;
+- per-sweep cap reservation versus actual request use;
+- rolling shared-budget commitment;
+- retry and failure accounting; and
+- sweep duration and final internal limitation class.
+
+The worker alerts a maintainer when admission has been blocked for 15 minutes,
+reserved capacity exceeds 90%, or Blizzard returns a 429 response. No log,
+metric, public response, or alert includes credentials, tokens, raw response
+bodies, achievement IDs, timestamps, scores, or per-character comparison data.
+
+The `/privacy` page documents that privacy-hidden Raider.IO ownership excludes
+fingerprint-derived links. There is no separate SlashWho opt-out mechanism.
+
+## Testing strategy
+
+Unit tests cover:
+
+- fingerprint extraction and comparison, including both threshold boundaries;
+- privacy-hidden exclusion;
+- root-guild-roster candidate ordering and no-guild/empty-roster outcomes;
+- request-cap and rolling-budget accounting;
+- seven-day eligibility and active-run reuse; and
+- every classification of success, partial result, failure, abort, and retry.
+
+PostgreSQL integration tests prove that concurrent sweeps cannot over-reserve
+the shared rolling budget, waiting sweeps are admitted FIFO, duplicate searches
+reuse one root run, only successful publication advances cadence, cap-bounded
+runs publish an allowed partial snapshot, and aborted/retried runs neither
+persist fingerprint material nor replace the prior snapshot.
+
+Worker integration tests use sanitized Blizzard fixtures for token acquisition,
+no guild, empty roster, successful matches, 429 responses, transport failures,
+and schema drift. Existing API and browser tests continue to prove that public
+payloads and character pages reveal neither provenance nor queue/budget state.
+
+Staging acceptance uses the operator-managed credentials to complete a small,
+known public eligible sweep within its configured cap and shared-budget
+reservation. It must find known eligible matches, create no sensitive retained
+fingerprint material, and leave the public API shape unchanged.
+
+## Acceptance criteria
+
+The feature is ready for staging validation when:
+
+1. An eligible search automatically queues one fingerprint sweep for its root;
+ later searches reuse it.
+2. The worker uses the current root guild roster, only compares same-region
+ candidates, and accepts only the approved 20%/200 threshold.
+3. Privacy-hidden ownership never produces a fingerprint-derived public link.
+4. The worker cannot begin a sweep without first reserving its whole configured
+ cap within the shared rolling budget.
+5. A cap-bounded sweep publishes one partial snapshot with an internal
+ `fingerprint_sweep_capped` reason; all other interruption paths preserve the
+ previous snapshot.
+6. A successfully published sweep suppresses another sweep for that root for
+ seven days, while daily Raider.IO refresh behavior remains intact.
+7. Database, worker, API, and browser tests prove that no raw or compact
+ fingerprint material, score, or public provenance is retained or exposed.
+8. Internal alerts fire for the agreed queue-blocked, 90%-reservation, and 429
+ conditions.
From 44670b7a59ece87a844a47b85d686faf8888a431 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 11:35:24 +0100
Subject: [PATCH 02/23] docs: plan achievement fingerprint discovery
---
...nt-fingerprint-discovery-implementation.md | 628 ++++++++++++++++++
1 file changed, 628 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
diff --git a/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md b/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
new file mode 100644
index 0000000..702e01c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
@@ -0,0 +1,628 @@
+# Achievement-Fingerprint Discovery Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add automatic, privacy-preserving Blizzard achievement-fingerprint discovery to SlashWho's existing durable search workflow.
+
+**Architecture:** A `discover-character` run still owns all public state and is the only snapshot writer. A private FIFO admission queue coordinates eligible runs against a PostgreSQL-backed Blizzard request-budget ledger, then re-dispatches the existing discovery run to perform one in-memory Blizzard guild-roster sweep and atomically merge matches into its normal snapshot.
+
+**Tech Stack:** TypeScript, pnpm workspaces, Zod, Vitest, PostgreSQL, Drizzle migrations, pg-boss, Node `fetch`, Railway worker configuration.
+
+## Global Constraints
+
+- Use `BLIZZARD_CLIENT_ID` and `BLIZZARD_CLIENT_SECRET` only in the private worker service; never serialize or log them.
+- Persist no achievement IDs, completion timestamps, fingerprint signatures, match scores, raw Blizzard bodies, access tokens, or candidate cursor/list.
+- Raider.IO privacy-hidden ownership is the sole privacy signal. It excludes fingerprint-derived linkage; there is no SlashWho opt-out.
+- Match only within one region; do not support CN fingerprints or cross-region comparisons.
+- Accept only at least 200 common achievements with at least 20% identical completion timestamps.
+- The initial shared limit is 28,800 Blizzard requests per rolling hour. Reserve a sweep's full configured cap before it begins; wait FIFO rather than reject.
+- A root may publish at most one fingerprint sweep every seven days. Only successful snapshot publication advances that time.
+- A cap-bounded sweep publishes a partial snapshot with internal `fingerprint_sweep_capped`; transport failures, 429s, 5xxs, schema failures, aborts, and shutdowns publish nothing and retain the previous snapshot.
+- Public API payloads, pages, and snapshot history remain provenance-free; fingerprint and budget details are internal only.
+- Follow `docs/contributing.md`: short-lived `feat/` branches, conventional commits, PR to `main`, squash merge.
+
+---
+
+## File structure
+
+| Path | Responsibility |
+| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `packages/blizzard/src/client.ts` | OAuth token acquisition, regional Profile API requests, response validation, safe failure conversion. |
+| `packages/blizzard/src/fingerprint.ts` | Ephemeral achievement extraction and pure threshold comparison. |
+| `packages/blizzard/src/types.ts` | Blizzard gateway and roster/fingerprint value types; no persistent representations. |
+| `packages/domain/src/fingerprint-discovery.ts` | Cap-aware root-guild sweep over a `BlizzardGateway`, producing candidate character observations and partial/failure outcomes. |
+| `packages/database/src/schema.ts` and `drizzle/0002_fingerprint_sweeps.sql` | Internal source enum extension, per-root sweep state, FIFO admission rows, and rolling reservation ledger. |
+| `packages/database/src/repositories.ts` / `postgres-repositories.ts` | Transactional sweep eligibility, FIFO admission, budget reservation/use/release, and snapshot completion bookkeeping. |
+| `packages/database/src/queue.ts` | Private `fingerprint-admission` pg-boss queue and dispatch contract. |
+| `packages/application/src/discovery-job-handler.ts` | Coordinates Raider.IO discovery, deferred admission, fingerprint sweep, merged atomic snapshot, and safe retry/abort behaviour. |
+| `apps/worker/src/config.ts` / `runtime.ts` | Validated Blizzard and sweep settings; creates the Blizzard client and registers admission workers/maintenance. |
+| Existing unit, integration, and runtime tests | Demonstrate privacy, budget, snapshot, retry, and public-contract invariants. |
+
+## Task 1: Create the Blizzard boundary and pure matcher
+
+**Files:**
+
+- Create: `packages/blizzard/src/types.ts`
+- Create: `packages/blizzard/src/fingerprint.ts`
+- Create: `packages/blizzard/src/fingerprint.test.ts`
+- Create: `packages/blizzard/src/client.ts`
+- Create: `packages/blizzard/src/client.test.ts`
+- Create: `packages/blizzard/src/index.ts`
+- Create: `packages/blizzard/package.json`
+
+**Interfaces:**
+
+- Consumes: `CharacterKey` from `@slashwho/domain` and an injected `fetch` implementation.
+- Produces:
+
+```ts
+export type AchievementFingerprint = ReadonlyMap;
+
+export type BlizzardRosterCharacter = Readonly<{
+ key: CharacterKey;
+ displayName: string;
+ className: string;
+ level: number;
+}>;
+
+export interface BlizzardGateway {
+ getGuildRoster(
+ root: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+ getAchievementFingerprint(
+ key: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+}
+
+export function compareFingerprints(
+ root: AchievementFingerprint,
+ candidate: AchievementFingerprint,
+ policy: { minimumCommon: number; minimumIdenticalPercent: number }
+): { common: number; identical: number; isMatch: boolean };
+```
+
+- [ ] **Step 1: Write failing matcher tests**
+
+```ts
+it("requires both the common-achievement floor and identical-timestamp floor", () => {
+ expect(compareFingerprints(root, tooSmall, policy).isMatch).toBe(false);
+ expect(compareFingerprints(root, belowPercent, policy).isMatch).toBe(false);
+ expect(compareFingerprints(root, exactBoundary, policy)).toMatchObject({
+ common: 200,
+ identical: 40,
+ isMatch: true
+ });
+});
+```
+
+- [ ] **Step 2: Run the matcher test to verify it fails**
+
+Run: `pnpm --filter @slashwho/blizzard test -- fingerprint.test.ts`
+
+Expected: FAIL because the workspace and matcher do not exist.
+
+- [ ] **Step 3: Implement the smallest pure matcher and ephemeral types**
+
+Extract only numeric achievement ID/timestamp pairs from a validated response. Compare maps without mutation, return counts only to the caller, and do not add serialization or storage helpers.
+
+- [ ] **Step 4: Write failing HTTP-boundary tests**
+
+```ts
+it("passes the abort signal and never includes an upstream body in its error", async () => {
+ const gateway = createBlizzardClient({
+ fetch,
+ clientId: "id",
+ clientSecret: "secret"
+ });
+ await expect(
+ gateway.getAchievementFingerprint(key, controller.signal)
+ ).rejects.toMatchObject({
+ kind: "transient",
+ retryAfterMs: 60_000
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ signal: controller.signal })
+ );
+});
+```
+
+- [ ] **Step 5: Implement the Blizzard client**
+
+Implement cached-in-process OAuth token acquisition, regional API URL construction, roster normalization to `BlizzardRosterCharacter`, achievement extraction, `Retry-After` parsing, and typed `not_found`, `transient`, and `schema_drift` failures. Keep token and raw payload values local to `client.ts`.
+
+- [ ] **Step 6: Run package tests and static checks**
+
+Run: `pnpm --filter @slashwho/blizzard test && pnpm --filter @slashwho/blizzard typecheck`
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/blizzard
+git commit -m "feat(blizzard): add ephemeral fingerprint client"
+```
+
+## Task 2: Build the cap-aware domain sweep
+
+**Files:**
+
+- Create: `packages/domain/src/fingerprint-discovery.ts`
+- Create: `packages/domain/src/fingerprint-discovery.test.ts`
+- Modify: `packages/domain/src/index.ts`
+- Modify: `packages/domain/package.json`
+
+**Interfaces:**
+
+- Consumes: `BlizzardGateway`, `BlizzardRosterCharacter`, and `compareFingerprints` from `@slashwho/blizzard`; `CharacterKey`, `DiscoveredCharacter`, and `toRaiderIoUrl` from existing domain modules.
+- Produces:
+
+```ts
+export type FingerprintSweepOutcome =
+ | {
+ kind: "matched";
+ characters: readonly DiscoveredCharacter[];
+ requestsUsed: number;
+ }
+ | {
+ kind: "capped";
+ characters: readonly DiscoveredCharacter[];
+ requestsUsed: number;
+ }
+ | {
+ kind: "failure";
+ code: "upstream_unavailable" | "upstream_schema_changed";
+ retryable: boolean;
+ retryAfterMs?: number;
+ };
+
+export function discoverFingerprintMatches(
+ root: CharacterKey,
+ gateway: BlizzardGateway,
+ options: {
+ requestCap: number;
+ minimumCommon: number;
+ minimumIdenticalPercent: number;
+ isSuppressed(key: CharacterKey): Promise;
+ isPrivacyHidden(key: CharacterKey): Promise;
+ signal?: AbortSignal;
+ }
+): Promise;
+```
+
+- [ ] **Step 1: Write failing domain tests**
+
+```ts
+it("fetches the root once, skips suppressed/privacy-hidden candidates, and stops at its cap", async () => {
+ await expect(
+ discoverFingerprintMatches(root, gateway, options)
+ ).resolves.toMatchObject({
+ kind: "capped",
+ requestsUsed: 3,
+ characters: [expect.objectContaining({ source: "fingerprint" })]
+ });
+});
+```
+
+- [ ] **Step 2: Run the domain test to verify it fails**
+
+Run: `pnpm --filter @slashwho/domain test -- fingerprint-discovery.test.ts`
+
+Expected: FAIL because the domain sweep is not exported.
+
+- [ ] **Step 3: Implement the in-memory sweep**
+
+Count every roster and achievement request against `requestCap`; request the root fingerprint once; evaluate roster candidates deterministically; check suppression/privacy before retaining a result; convert accepted matches to ordinary `DiscoveredCharacter` rows with source `fingerprint`; discard each candidate fingerprint after comparison. Return `capped` only after a measured cap stop.
+
+- [ ] **Step 4: Add failure and abort tests**
+
+```ts
+it("returns a retryable failure for a 429 and throws the abort reason without a partial result", async () => {
+ gateway.getAchievementFingerprint = async () => {
+ throw rateLimited;
+ };
+ await expect(
+ discoverFingerprintMatches(root, gateway, options)
+ ).resolves.toMatchObject({ kind: "failure", retryable: true });
+ await expect(
+ discoverFingerprintMatches(root, gateway, {
+ ...options,
+ signal: aborted.signal
+ })
+ ).rejects.toBe(aborted.signal.reason);
+});
+```
+
+- [ ] **Step 5: Run focused tests and commit**
+
+Run: `pnpm --filter @slashwho/domain test -- fingerprint-discovery.test.ts`
+
+Expected: PASS.
+
+```bash
+git add packages/domain
+git commit -m "feat(domain): add cap-aware fingerprint sweep"
+```
+
+## Task 3: Add durable sweep state and rolling budget admission
+
+**Files:**
+
+- Modify: `packages/database/src/schema.ts`
+- Create: `packages/database/drizzle/0002_fingerprint_sweeps.sql`
+- Create: `packages/database/drizzle/meta/0002_snapshot.json`
+- Modify: `packages/database/drizzle/meta/_journal.json`
+- Modify: `packages/database/src/repositories.ts`
+- Modify: `packages/database/src/postgres-repositories.ts`
+- Modify: `packages/database/src/postgres-repositories.test.ts`
+- Modify: `packages/database/src/public-api.typecheck.ts`
+
+**Interfaces:**
+
+- Consumes: canonical root keys and discovery-run IDs.
+- Produces:
+
+```ts
+export type FingerprintAdmission =
+ | { kind: "not_due" }
+ | { kind: "waiting"; retryAt: Date }
+ | { kind: "admitted"; reservationId: string; requestCap: number };
+
+export interface FingerprintSweepRepository {
+ requestAdmission(input: {
+ runId: string;
+ key: CharacterKey;
+ requestCap: number;
+ hourlyBudget: number;
+ cadenceCutoff: Date;
+ at: Date;
+ }): Promise;
+ recordRequest(reservationId: string, count: number, at: Date): Promise;
+ finish(
+ reservationId: string,
+ input: { published: boolean; at: Date; limitationCode: string | null }
+ ): Promise;
+ release(reservationId: string, at: Date): Promise;
+ listWaiting(limit: number): Promise;
+}
+```
+
+- [ ] **Step 1: Write failing PostgreSQL integration tests**
+
+```ts
+it("admits only the FIFO head when two caps would exceed the rolling budget", async () => {
+ await repository.requestAdmission(first);
+ await expect(repository.requestAdmission(second)).resolves.toMatchObject({
+ kind: "waiting"
+ });
+ await repository.finish(firstReservation, {
+ published: true,
+ at,
+ limitationCode: null
+ });
+ await expect(repository.requestAdmission(second)).resolves.toMatchObject({
+ kind: "admitted"
+ });
+});
+```
+
+- [ ] **Step 2: Run the integration test to verify it fails**
+
+Run: `pnpm test:integration -- postgres-repositories.test.ts`
+
+Expected: FAIL because no fingerprint tables or repository exist.
+
+- [ ] **Step 3: Add the migration and schema types**
+
+Create internal tables for per-root sweep state, FIFO admission rows, and reservation accounting. Add `fingerprint` to `discovery_source`. A reservation records cap, used count, admitted time, expiry time, and terminal release/completion metadata; it stores no upstream or matching data.
+
+- [ ] **Step 4: Implement transactional repository methods**
+
+Under one global PostgreSQL advisory lock, select the oldest waiting eligible row, calculate active commitment as used plus unreleased reservation capacity, and admit only when the full cap fits. On finish/release, retain used count until the reservation's one-hour expiry, release unused count immediately, and set the seven-day timestamp only when the snapshot was published.
+
+- [ ] **Step 5: Add atomicity and cadence tests**
+
+```ts
+it("does not advance cadence or retain unused capacity after an aborted sweep", async () => {
+ const admitted = await repository.requestAdmission(input);
+ await repository.recordRequest(admitted.reservationId, 3, at);
+ await repository.release(admitted.reservationId, at);
+ await expect(
+ repository.requestAdmission({ ...input, at: plusOneMinute })
+ ).resolves.toMatchObject({ kind: "admitted" });
+});
+```
+
+- [ ] **Step 6: Run migration and integration verification**
+
+Run: `pnpm test:integration -- postgres-repositories.test.ts && pnpm --filter @slashwho/database typecheck`
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/database
+git commit -m "feat(database): reserve fingerprint sweep budget"
+```
+
+## Task 4: Add private FIFO admission dispatch
+
+**Files:**
+
+- Modify: `packages/database/src/queue.ts`
+- Modify: `packages/database/src/queue.test.ts`
+- Modify: `packages/database/src/index.ts`
+- Modify: `apps/worker/src/runtime.ts`
+- Modify: `apps/worker/src/runtime.test.ts`
+
+**Interfaces:**
+
+- Consumes: waiting discovery-run IDs from `FingerprintSweepRepository`.
+- Produces:
+
+```ts
+export const fingerprintAdmissionQueueName = "fingerprint-admission";
+
+export interface DiscoveryQueue {
+ // Existing members remain unchanged.
+ enqueueFingerprintAdmission(runId: string): Promise;
+ workFingerprintAdmissions(
+ handler: (runId: string) => Promise
+ ): Promise;
+}
+```
+
+- [ ] **Step 1: Write failing queue/runtime tests**
+
+```ts
+it("registers the private admission worker and re-enqueues only admitted discovery runs", async () => {
+ await admissionHandler(waitingRunId);
+ expect(fakes.enqueued).toEqual([{ runId: waitingRunId, key }]);
+ expect(fakes.handler.execute).not.toHaveBeenCalled();
+});
+```
+
+- [ ] **Step 2: Run focused tests to verify they fail**
+
+Run: `pnpm --filter @slashwho/database test -- queue.test.ts && pnpm --filter @slashwho/worker test -- runtime.test.ts`
+
+Expected: FAIL because the private queue is not registered.
+
+- [ ] **Step 3: Implement the private pg-boss queue**
+
+Create and start `fingerprint-admission` with a singleton key per run. Its handler asks the repository to admit FIFO work, then enqueues an admitted run back onto `discover-character`. Waiting runs remain durable in the admission table/queue and do not consume a discovery worker execution or delivery retry.
+
+- [ ] **Step 4: Wire shutdown and recovery**
+
+Make runtime startup recover waiting admission rows before readiness, and make `stop()` cease new admission work before its existing graceful drain. Do not add a public queue or API route.
+
+- [ ] **Step 5: Run focused tests and commit**
+
+Run: `pnpm --filter @slashwho/database test -- queue.test.ts && pnpm --filter @slashwho/worker test -- runtime.test.ts`
+
+Expected: PASS.
+
+```bash
+git add packages/database apps/worker
+git commit -m "feat(worker): dispatch fingerprint admissions"
+```
+
+## Task 5: Orchestrate merged snapshots in the discovery handler
+
+**Files:**
+
+- Modify: `packages/application/src/discovery-job-handler.ts`
+- Modify: `packages/application/src/discovery-job-handler.test.ts`
+- Modify: `packages/application/src/index.ts`
+- Modify: `apps/worker/src/runtime.ts`
+
+**Interfaces:**
+
+- Consumes: existing `discoverCharacter`, `discoverFingerprintMatches`, `FingerprintSweepRepository`, `BlizzardGateway`, and `DiscoveryWorkContext`.
+- Produces an extended handler option:
+
+```ts
+export type DiscoveryJobHandlerOptions = {
+ repositories: Repositories;
+ gateway: RaiderIoGateway;
+ blizzardGateway: BlizzardGateway;
+ fingerprint: {
+ requestCap: number;
+ hourlyBudget: number;
+ cadenceMs: number;
+ minimumCommon: number;
+ minimumIdenticalPercent: number;
+ };
+ // Existing retry, clock, logger, and cache options remain.
+};
+```
+
+- [ ] **Step 1: Write failing handler tests for deferred admission**
+
+```ts
+it("defers an eligible run to private FIFO admission without consuming a delivery retry", async () => {
+ repositories.fingerprintSweeps.requestAdmission = async () => ({
+ kind: "waiting",
+ retryAt
+ });
+ await handler.execute(run.id, delivery());
+ expect(repositories.runs.find(run.id)).resolves.toMatchObject({
+ status: "queued"
+ });
+ expect(gateway.getCharacter).toHaveBeenCalled();
+ expect(blizzardGateway.getGuildRoster).not.toHaveBeenCalled();
+});
+```
+
+- [ ] **Step 2: Run the handler test to verify it fails**
+
+Run: `pnpm --filter @slashwho/application test -- discovery-job-handler.test.ts`
+
+Expected: FAIL because the handler has no fingerprint admission branch.
+
+- [ ] **Step 3: Implement admission, sweeping, and merge**
+
+Run existing Raider.IO discovery first. If the normal result cannot produce a trustworthy snapshot, preserve its existing behaviour and never start a fingerprint sweep. For a trustworthy result, request admission. On `waiting`, persist only the internal admission state and return without snapshot publication. On `admitted`, invoke the domain sweep, record each consumed Blizzard request, merge deduplicated fingerprint observations with Raider.IO observations, then call the existing atomic snapshot repository once.
+
+- [ ] **Step 4: Write failing failure/partial/abort tests**
+
+```ts
+it("publishes only a cap-bounded partial result and releases an aborted reservation", async () => {
+ fingerprintSweep.mockResolvedValue({
+ kind: "capped",
+ characters: [match],
+ requestsUsed: 300
+ });
+ await handler.execute(run.id, delivery());
+ expect(snapshot.limitationCode).toBe("fingerprint_sweep_capped");
+
+ controller.abort(abortReason);
+ await expect(
+ handler.execute(nextRun.id, { ...delivery(), signal: controller.signal })
+ ).rejects.toBe(abortReason);
+ expect(repositories.fingerprintSweeps.release).toHaveBeenCalled();
+});
+```
+
+- [ ] **Step 5: Extend allowlisted logs without sensitive data**
+
+Add only queue wait, reservation/use counts, duration, and final limitation class. Extend `apps/worker/src/logger.test.ts` with achievement IDs, timestamps, tokens, and scores as redaction markers, and prove none reaches output.
+
+- [ ] **Step 6: Run focused tests and commit**
+
+Run: `pnpm --filter @slashwho/application test -- discovery-job-handler.test.ts && pnpm --filter @slashwho/worker test -- logger.test.ts`
+
+Expected: PASS.
+
+```bash
+git add packages/application apps/worker
+git commit -m "feat(application): merge fingerprint discovery snapshots"
+```
+
+## Task 6: Validate worker configuration and public non-disclosure
+
+**Files:**
+
+- Modify: `apps/worker/src/config.ts`
+- Modify: `apps/worker/src/config.test.ts`
+- Modify: `apps/worker/src/runtime.ts`
+- Modify: `packages/contracts/src/contracts.test.ts`
+- Modify: `packages/application/src/serializers.test.ts`
+- Modify: `apps/web/src/app/privacy/page.tsx` (or create it if absent)
+- Modify: privacy-page test colocated with the route/component
+
+**Interfaces:**
+
+- Consumes: worker environment values `BLIZZARD_CLIENT_ID`, `BLIZZARD_CLIENT_SECRET`, `BLIZZARD_SWEEP_REQUEST_CAP`, `BLIZZARD_HOURLY_REQUEST_BUDGET`, `FINGERPRINT_MINIMUM_COMMON`, `FINGERPRINT_MINIMUM_IDENTICAL_PERCENT`, and `FINGERPRINT_SWEEP_CADENCE_HOURS`.
+- Produces a `WorkerConfig` whose fingerprint fields are positive validated numbers and whose cadence defaults to 168 hours.
+
+- [ ] **Step 1: Write failing configuration and serializer tests**
+
+```ts
+it("rejects missing Blizzard credentials and invalid sweep bounds", () => {
+ expect(() => loadWorkerConfig({ DATABASE_URL: url })).toThrow(
+ "blizzard_client_id_required"
+ );
+ expect(() =>
+ loadWorkerConfig({ ...env, BLIZZARD_SWEEP_REQUEST_CAP: "0" })
+ ).toThrow("invalid_blizzard_sweep_request_cap");
+});
+
+it("never exposes fingerprint source, score, queue, or reservation fields", () => {
+ expect(serializeCharacterResource(snapshot)).not.toHaveProperty(
+ "discoverySource"
+ );
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `pnpm --filter @slashwho/worker test -- config.test.ts && pnpm --filter @slashwho/application test -- serializers.test.ts`
+
+Expected: FAIL because the fingerprint configuration and privacy wording are absent.
+
+- [ ] **Step 3: Implement validated configuration and runtime construction**
+
+Load credentials only in worker configuration; pass them directly to `createBlizzardClient`; do not expose them to web configuration. Register defaults of 28,800/hour, 20%, 200 common achievements, and 168 hours. Keep all public schemas and serializers unchanged except for tests proving no internal field leaks.
+
+- [ ] **Step 4: Document the privacy boundary**
+
+Add concise `/privacy` copy stating that privacy-hidden Raider.IO ownership is excluded from fingerprint-derived links and that public lists do not disclose discovery method. Do not add a public opt-out flow.
+
+- [ ] **Step 5: Run focused tests and commit**
+
+Run: `pnpm --filter @slashwho/worker test -- config.test.ts && pnpm --filter @slashwho/application test -- serializers.test.ts && pnpm --filter @slashwho/web test -- privacy`
+
+Expected: PASS.
+
+```bash
+git add apps/worker packages/application packages/contracts apps/web
+git commit -m "feat(worker): configure private Blizzard sweeps"
+```
+
+## Task 7: Run end-to-end verification and staging smoke test
+
+**Files:**
+
+- Modify: deployment/environment documentation if Railway variable setup is not already recorded.
+- Modify: `README.md` only if it names discovery sources or privacy behaviour contradicted by this feature.
+
+**Interfaces:**
+
+- Consumes: all prior tasks and operator-managed Railway worker secrets.
+- Produces: a verified branch and a manually recorded staging smoke-test result without secret or raw upstream data.
+
+- [ ] **Step 1: Run the complete local gate**
+
+Run:
+
+```bash
+pnpm format:check
+pnpm lint
+pnpm typecheck
+pnpm test
+pnpm build
+```
+
+Expected: every command exits 0. If Docker is unavailable, start Docker Desktop hidden, verify `docker version`, then rerun the integration suite; do not alter tests to skip it.
+
+- [ ] **Step 2: Review public-contract and retention evidence**
+
+Run:
+
+```bash
+rg -n "fingerprint|achievement|blizzard" apps/web packages/contracts packages/application/src/serializers.ts
+git diff main...HEAD --check
+```
+
+Expected: only the approved `/privacy` wording and internal implementation references appear; contracts and serializers contain no score, source, queue, credential, ID, timestamp, or raw-payload field.
+
+- [ ] **Step 3: Stage and run the bounded staging smoke test**
+
+Deploy to Railway `test` with only the already-provisioned worker credentials. Submit one known eligible public root, confirm the worker reserves its cap, completes within it, and the public page/API shows a normal undifferentiated list. Record only root key, run outcome, request count, duration, snapshot state, and limitation class.
+
+- [ ] **Step 4: Commit documentation evidence and open the feature PR**
+
+```bash
+git add README.md docs apps packages
+git commit -m "docs: record fingerprint sweep validation"
+git push -u origin feat/achievement-fingerprint-discovery
+gh pr create --base main --title "feat: add achievement fingerprint discovery"
+```
+
+## Plan self-review
+
+- Spec coverage: Tasks 1–2 implement ephemeral Blizzard matching and the threshold; Tasks 3–4 implement seven-day eligibility, FIFO admission, and rolling budget; Task 5 implements merged atomic snapshots, cap handling, retries, and shutdown release; Task 6 implements config, privacy copy, and public non-disclosure; Task 7 verifies all acceptance criteria in CI and staging.
+- Placeholder scan: no deferred implementation steps, unnamed types, or generic testing directions remain; every task names its files, interfaces, commands, and expected result.
+- Type consistency: `BlizzardGateway`, `FingerprintSweepOutcome`, `FingerprintSweepRepository`, `FingerprintAdmission`, and the extended `DiscoveryJobHandlerOptions` are introduced before later tasks consume them.
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md`.
+
+1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task and review between tasks.
+2. **Inline Execution** — execute the tasks in this session with checkpoints.
From c9a4c7442ae805317a394a9a2b21e672aba9b7fa Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 11:47:16 +0100
Subject: [PATCH 03/23] feat(blizzard): add ephemeral fingerprint client
---
packages/blizzard/package.json | 13 +
packages/blizzard/src/client.test.ts | 175 +++++++++++++
packages/blizzard/src/client.ts | 292 ++++++++++++++++++++++
packages/blizzard/src/fingerprint.test.ts | 31 +++
packages/blizzard/src/fingerprint.ts | 27 ++
packages/blizzard/src/index.ts | 10 +
packages/blizzard/src/types.ts | 32 +++
packages/blizzard/tsconfig.json | 4 +
pnpm-lock.yaml | 6 +
9 files changed, 590 insertions(+)
create mode 100644 packages/blizzard/package.json
create mode 100644 packages/blizzard/src/client.test.ts
create mode 100644 packages/blizzard/src/client.ts
create mode 100644 packages/blizzard/src/fingerprint.test.ts
create mode 100644 packages/blizzard/src/fingerprint.ts
create mode 100644 packages/blizzard/src/index.ts
create mode 100644 packages/blizzard/src/types.ts
create mode 100644 packages/blizzard/tsconfig.json
diff --git a/packages/blizzard/package.json b/packages/blizzard/package.json
new file mode 100644
index 0000000..8676f31
--- /dev/null
+++ b/packages/blizzard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@slashwho/blizzard",
+ "private": true,
+ "exports": "./src/index.ts",
+ "scripts": {
+ "build": "tsc --noEmit",
+ "test": "vitest run --root ../.. packages/blizzard/src",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@slashwho/domain": "workspace:*"
+ }
+}
diff --git a/packages/blizzard/src/client.test.ts b/packages/blizzard/src/client.test.ts
new file mode 100644
index 0000000..bc993be
--- /dev/null
+++ b/packages/blizzard/src/client.test.ts
@@ -0,0 +1,175 @@
+import type { CharacterKey } from "@slashwho/domain";
+import { describe, expect, it, vi } from "vitest";
+
+import { createBlizzardClient } from "./index";
+
+const key: CharacterKey = {
+ region: "eu",
+ realm: "silvermoon",
+ name: "sentinel"
+};
+
+function clientFor(
+ responder: (url: URL, init?: RequestInit) => Response | Promise
+) {
+ const fetchSpy = vi.fn(
+ async (input: RequestInfo | URL, init?: RequestInit): Promise =>
+ responder(
+ new URL(
+ typeof input === "string" || input instanceof URL ? input : input.url
+ ),
+ init
+ )
+ );
+ return {
+ fetchSpy,
+ gateway: createBlizzardClient({
+ fetch: fetchSpy as unknown as typeof globalThis.fetch,
+ clientId: "id",
+ clientSecret: "secret"
+ })
+ };
+}
+
+function tokenResponse(): Response {
+ return Response.json({
+ access_token: "private-access-token",
+ expires_in: 3600
+ });
+}
+
+describe("Blizzard gateway", () => {
+ it("uses the root region profile API and normalizes the current guild roster", async () => {
+ // Break caught: roster requests could cross regions or leak upstream member
+ // shapes into discovery snapshots.
+ const { gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ if (url.pathname.endsWith("/character/silvermoon/sentinel")) {
+ return Response.json({
+ guild: { name: "A Guild", realm: { slug: "silvermoon" } }
+ });
+ }
+ if (url.pathname.endsWith("/guild/silvermoon/a-guild/roster")) {
+ return Response.json({
+ members: [
+ {
+ character: {
+ name: "Alt",
+ realm: { slug: "Silvermoon" },
+ playable_class: { name: "Mage" },
+ level: 80
+ }
+ }
+ ]
+ });
+ }
+ throw new Error(`unexpected endpoint: ${url.pathname}`);
+ });
+
+ await expect(gateway.getGuildRoster(key)).resolves.toEqual([
+ {
+ key: { region: "eu", realm: "silvermoon", name: "alt" },
+ displayName: "Alt",
+ className: "Mage",
+ level: 80
+ }
+ ]);
+ });
+
+ it("returns an empty roster when the root has no guild", async () => {
+ const { gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ return Response.json({});
+ });
+
+ await expect(gateway.getGuildRoster(key)).resolves.toEqual([]);
+ });
+
+ it("extracts only numeric achievement pairs and caches the process token", async () => {
+ // Break caught: malformed achievement entries could reach comparison, or a
+ // token request could be made per character.
+ const { fetchSpy, gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ return Response.json({
+ achievements: [
+ { id: 1, completed_timestamp: 100 },
+ { id: "2", completed_timestamp: 200 },
+ { id: 3, completed_timestamp: "300" }
+ ]
+ });
+ });
+
+ await expect(gateway.getAchievementFingerprint(key)).resolves.toEqual(
+ new Map([[1, 100]])
+ );
+ await expect(gateway.getAchievementFingerprint(key)).resolves.toEqual(
+ new Map([[1, 100]])
+ );
+ expect(
+ fetchSpy.mock.calls.filter(
+ ([input]) => new URL(String(input)).hostname === "oauth.battle.net"
+ )
+ ).toHaveLength(1);
+ });
+
+ it("passes the abort signal and never includes an upstream body in its error", async () => {
+ // Break caught: cancellation could be omitted, or an upstream error body
+ // could enter a typed failure and be logged later.
+ const controller = new AbortController();
+ const { fetchSpy, gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ return new Response("upstream-private-body-marker", {
+ status: 429,
+ headers: { "Retry-After": "60" }
+ });
+ });
+
+ const request = gateway.getAchievementFingerprint(key, controller.signal);
+ await expect(request).rejects.toMatchObject({
+ kind: "transient",
+ retryAfterMs: 60_000
+ });
+ await expect(request).rejects.not.toThrow(/upstream-private-body-marker/);
+ expect(
+ JSON.stringify(await request.catch((error: unknown) => error))
+ ).not.toContain("upstream-private-body-marker");
+ expect(fetchSpy).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ signal: controller.signal })
+ );
+ });
+
+ it("classifies unexpected success payloads as schema drift", async () => {
+ const { gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ return Response.json({ unexpected: true });
+ });
+
+ await expect(gateway.getAchievementFingerprint(key)).rejects.toMatchObject({
+ kind: "schema_drift"
+ });
+ });
+
+ it("classifies missing Blizzard resources without exposing their body", async () => {
+ const { gateway } = clientFor((url) => {
+ if (url.hostname === "oauth.battle.net") return tokenResponse();
+ return new Response("missing-private-body-marker", { status: 404 });
+ });
+
+ const request = gateway.getAchievementFingerprint(key);
+ await expect(request).rejects.toMatchObject({ kind: "not_found" });
+ await expect(request).rejects.not.toThrow(/missing-private-body-marker/);
+ });
+
+ it("rejects regions outside the supported same-region profile boundary", async () => {
+ // Break caught: a forged key could send fingerprint data to the unsupported
+ // China API rather than keeping every request in the domain's region set.
+ const { fetchSpy, gateway } = clientFor(() => tokenResponse());
+ const unsupportedKey = { ...key, region: "cn" } as unknown as CharacterKey;
+
+ await expect(
+ gateway.getAchievementFingerprint(unsupportedKey)
+ ).rejects.toThrow("invalid_character_key");
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/blizzard/src/client.ts b/packages/blizzard/src/client.ts
new file mode 100644
index 0000000..42f6f40
--- /dev/null
+++ b/packages/blizzard/src/client.ts
@@ -0,0 +1,292 @@
+import { supportedRegions, type CharacterKey } from "@slashwho/domain";
+
+import type {
+ AchievementFingerprint,
+ BlizzardError,
+ BlizzardFailure,
+ BlizzardGateway,
+ BlizzardRosterCharacter
+} from "./types";
+
+export type CreateBlizzardClientOptions = Readonly<{
+ fetch: typeof globalThis.fetch;
+ clientId: string;
+ clientSecret: string;
+}>;
+
+type AccessToken = Readonly<{
+ value: string;
+ expiresAt: number;
+}>;
+
+function createBlizzardError(failure: BlizzardFailure): BlizzardError {
+ return Object.assign(
+ new Error(`blizzard_${failure.kind}`),
+ failure
+ ) as BlizzardError;
+}
+
+function retryAfterMs(response: Response): number | undefined {
+ const value = response.headers.get("Retry-After")?.trim();
+ if (!value) return undefined;
+
+ if (/^\d+$/.test(value)) return Number(value) * 1_000;
+
+ const retryAt = Date.parse(value);
+ return Number.isFinite(retryAt)
+ ? Math.max(0, retryAt - Date.now())
+ : undefined;
+}
+
+function responseFailure(response: Response): BlizzardFailure {
+ if (response.status === 404) return { kind: "not_found" };
+
+ const retryAfter = retryAfterMs(response);
+ return {
+ kind: "transient",
+ status: response.status,
+ ...(retryAfter === undefined ? {} : { retryAfterMs: retryAfter })
+ };
+}
+
+function validCharacterKey(value: CharacterKey): CharacterKey {
+ const valid =
+ supportedRegions.includes(value.region) &&
+ /^[a-z0-9-]+$/.test(value.realm) &&
+ /^[\p{L}\p{M}'-]+$/u.test(value.name) &&
+ value.realm === value.realm.toLocaleLowerCase("en-US") &&
+ value.name === value.name.toLocaleLowerCase("en-US");
+ if (!valid) throw new Error("invalid_character_key");
+ return value;
+}
+
+function valueRecord(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function nonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function finiteNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function normalizedRosterCharacter(
+ value: unknown,
+ region: CharacterKey["region"]
+): BlizzardRosterCharacter | null {
+ const member = valueRecord(value);
+ const character = member && valueRecord(member.character);
+ const realm = character && valueRecord(character.realm);
+ const playableClass = character && valueRecord(character.playable_class);
+ const displayName = character && nonEmptyString(character.name);
+ const realmSlug = realm && nonEmptyString(realm.slug);
+ const className = playableClass && nonEmptyString(playableClass.name);
+ const level = character && finiteNumber(character.level);
+ if (
+ !displayName ||
+ !realmSlug ||
+ !className ||
+ level === null ||
+ !Number.isInteger(level) ||
+ level < 0
+ ) {
+ return null;
+ }
+
+ const key = {
+ region,
+ realm: realmSlug.toLocaleLowerCase("en-US"),
+ name: displayName.toLocaleLowerCase("en-US")
+ } as CharacterKey;
+ try {
+ validCharacterKey(key);
+ } catch {
+ return null;
+ }
+
+ return { key, displayName, className, level };
+}
+
+function fingerprintFromResponse(
+ value: unknown
+): AchievementFingerprint | null {
+ const response = valueRecord(value);
+ if (!response || !Array.isArray(response.achievements)) return null;
+
+ const fingerprint = new Map();
+ for (const achievement of response.achievements) {
+ const entry = valueRecord(achievement);
+ const id = entry && finiteNumber(entry.id);
+ const timestamp = entry && finiteNumber(entry.completed_timestamp);
+ if (id !== null && timestamp !== null) fingerprint.set(id, timestamp);
+ }
+ return fingerprint;
+}
+
+function blizzardSlug(value: string): string {
+ return value.trim().toLocaleLowerCase("en-US").replace(/\s+/g, "-");
+}
+
+export function createBlizzardClient(
+ options: CreateBlizzardClientOptions
+): BlizzardGateway {
+ let cachedToken: AccessToken | undefined;
+
+ async function accessToken(signal?: AbortSignal): Promise {
+ if (cachedToken && cachedToken.expiresAt > Date.now()) {
+ return cachedToken.value;
+ }
+
+ let response: Response;
+ try {
+ response = await options.fetch("https://oauth.battle.net/token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Basic ${Buffer.from(
+ `${options.clientId}:${options.clientSecret}`
+ ).toString("base64")}`
+ },
+ body: "grant_type=client_credentials",
+ signal
+ });
+ } catch {
+ if (signal?.aborted) throw signal.reason;
+ throw createBlizzardError({ kind: "transient" });
+ }
+
+ signal?.throwIfAborted();
+ if (!response.ok) throw createBlizzardError(responseFailure(response));
+
+ try {
+ const body = valueRecord(await response.json());
+ signal?.throwIfAborted();
+ const token = body && nonEmptyString(body.access_token);
+ const expiresIn = body && finiteNumber(body.expires_in);
+ if (!token || expiresIn === null || expiresIn <= 0) {
+ throw new Error("invalid_token_response");
+ }
+ cachedToken = {
+ value: token,
+ expiresAt: Date.now() + Math.max(0, expiresIn * 1_000 - 60_000)
+ };
+ return token;
+ } catch {
+ if (signal?.aborted) throw signal.reason;
+ throw createBlizzardError({ kind: "schema_drift" });
+ }
+ }
+
+ async function request(
+ url: URL,
+ normalize: (value: unknown) => T | null,
+ signal?: AbortSignal
+ ): Promise {
+ const token = await accessToken(signal);
+ let response: Response;
+ try {
+ response = await options.fetch(url.toString(), {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json"
+ },
+ signal
+ });
+ } catch {
+ if (signal?.aborted) throw signal.reason;
+ throw createBlizzardError({ kind: "transient" });
+ }
+
+ signal?.throwIfAborted();
+ if (!response.ok) throw createBlizzardError(responseFailure(response));
+
+ try {
+ const normalized = normalize(await response.json());
+ signal?.throwIfAborted();
+ if (normalized === null) throw new Error("invalid_response");
+ return normalized;
+ } catch {
+ if (signal?.aborted) throw signal.reason;
+ throw createBlizzardError({ kind: "schema_drift" });
+ }
+ }
+
+ function profileUrl(key: CharacterKey): URL {
+ const url = new URL(
+ `https://${key.region}.api.blizzard.com/profile/wow/character/${encodeURIComponent(key.realm)}/${encodeURIComponent(key.name)}`
+ );
+ url.searchParams.set("namespace", `profile-${key.region}`);
+ url.searchParams.set("locale", "en_GB");
+ return url;
+ }
+
+ function achievementsUrl(key: CharacterKey): URL {
+ const url = profileUrl(key);
+ url.pathname = `${url.pathname}/achievements`;
+ return url;
+ }
+
+ function rosterUrl(
+ region: CharacterKey["region"],
+ realm: string,
+ guildName: string
+ ): URL {
+ const url = new URL(
+ `https://${region}.api.blizzard.com/data/wow/guild/${encodeURIComponent(blizzardSlug(realm))}/${encodeURIComponent(blizzardSlug(guildName))}/roster`
+ );
+ url.searchParams.set("namespace", `profile-${region}`);
+ url.searchParams.set("locale", "en_GB");
+ return url;
+ }
+
+ async function getGuildRoster(
+ root: CharacterKey,
+ signal?: AbortSignal
+ ): Promise {
+ const key = validCharacterKey(root);
+ const profile = await request(
+ profileUrl(key),
+ (value) => valueRecord(value),
+ signal
+ );
+ if (!("guild" in profile) || profile.guild === null) return [];
+
+ const guild = valueRecord(profile.guild);
+ const name = guild && nonEmptyString(guild.name);
+ const realm = guild && valueRecord(guild.realm);
+ const realmSlug = realm && nonEmptyString(realm.slug);
+ if (!name || !realmSlug)
+ throw createBlizzardError({ kind: "schema_drift" });
+
+ return request(
+ rosterUrl(key.region, realmSlug, name),
+ (value) => {
+ const roster = valueRecord(value);
+ if (!roster || !Array.isArray(roster.members)) return null;
+ const members = roster.members.map((member) =>
+ normalizedRosterCharacter(member, key.region)
+ );
+ return members.every((member) => member !== null)
+ ? (members as BlizzardRosterCharacter[])
+ : null;
+ },
+ signal
+ );
+ }
+
+ async function getAchievementFingerprint(
+ key: CharacterKey,
+ signal?: AbortSignal
+ ): Promise {
+ const validKey = validCharacterKey(key);
+ return request(achievementsUrl(validKey), fingerprintFromResponse, signal);
+ }
+
+ return { getGuildRoster, getAchievementFingerprint };
+}
+
+export { createBlizzardError };
diff --git a/packages/blizzard/src/fingerprint.test.ts b/packages/blizzard/src/fingerprint.test.ts
new file mode 100644
index 0000000..53b15d3
--- /dev/null
+++ b/packages/blizzard/src/fingerprint.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { compareFingerprints } from "./fingerprint";
+
+function fingerprint(
+ common: number,
+ identical: number
+): ReadonlyMap {
+ return new Map(
+ Array.from({ length: common }, (_, id) => [id, id < identical ? id : -id])
+ );
+}
+
+describe("compareFingerprints", () => {
+ it("requires both the common-achievement floor and identical-timestamp floor", () => {
+ // Break caught: accepting a candidate when either threshold is not met.
+ const root = new Map(Array.from({ length: 200 }, (_, id) => [id, id]));
+ const tooSmall = fingerprint(199, 199);
+ const belowPercent = fingerprint(200, 39);
+ const exactBoundary = fingerprint(200, 40);
+ const policy = { minimumCommon: 200, minimumIdenticalPercent: 20 };
+
+ expect(compareFingerprints(root, tooSmall, policy).isMatch).toBe(false);
+ expect(compareFingerprints(root, belowPercent, policy).isMatch).toBe(false);
+ expect(compareFingerprints(root, exactBoundary, policy)).toMatchObject({
+ common: 200,
+ identical: 40,
+ isMatch: true
+ });
+ });
+});
diff --git a/packages/blizzard/src/fingerprint.ts b/packages/blizzard/src/fingerprint.ts
new file mode 100644
index 0000000..18e9727
--- /dev/null
+++ b/packages/blizzard/src/fingerprint.ts
@@ -0,0 +1,27 @@
+import type { AchievementFingerprint } from "./types";
+
+export function compareFingerprints(
+ root: AchievementFingerprint,
+ candidate: AchievementFingerprint,
+ policy: { minimumCommon: number; minimumIdenticalPercent: number }
+): { common: number; identical: number; isMatch: boolean } {
+ let common = 0;
+ let identical = 0;
+
+ for (const [achievementId, timestamp] of root) {
+ const candidateTimestamp = candidate.get(achievementId);
+ if (candidateTimestamp === undefined) continue;
+
+ common += 1;
+ if (candidateTimestamp === timestamp) identical += 1;
+ }
+
+ const identicalPercent = common === 0 ? 0 : (identical / common) * 100;
+ return {
+ common,
+ identical,
+ isMatch:
+ common >= policy.minimumCommon &&
+ identicalPercent >= policy.minimumIdenticalPercent
+ };
+}
diff --git a/packages/blizzard/src/index.ts b/packages/blizzard/src/index.ts
new file mode 100644
index 0000000..28b67b0
--- /dev/null
+++ b/packages/blizzard/src/index.ts
@@ -0,0 +1,10 @@
+export { createBlizzardClient } from "./client";
+export type { CreateBlizzardClientOptions } from "./client";
+export { compareFingerprints } from "./fingerprint";
+export type {
+ AchievementFingerprint,
+ BlizzardError,
+ BlizzardFailure,
+ BlizzardGateway,
+ BlizzardRosterCharacter
+} from "./types";
diff --git a/packages/blizzard/src/types.ts b/packages/blizzard/src/types.ts
new file mode 100644
index 0000000..2fa8031
--- /dev/null
+++ b/packages/blizzard/src/types.ts
@@ -0,0 +1,32 @@
+import type { CharacterKey } from "@slashwho/domain";
+
+export type AchievementFingerprint = ReadonlyMap;
+
+export type BlizzardRosterCharacter = Readonly<{
+ key: CharacterKey;
+ displayName: string;
+ className: string;
+ level: number;
+}>;
+
+export interface BlizzardGateway {
+ getGuildRoster(
+ root: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+ getAchievementFingerprint(
+ key: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+}
+
+export type BlizzardFailure =
+ | { kind: "not_found" }
+ | {
+ kind: "transient";
+ status?: number;
+ retryAfterMs?: number;
+ }
+ | { kind: "schema_drift" };
+
+export type BlizzardError = Error & BlizzardFailure;
diff --git a/packages/blizzard/tsconfig.json b/packages/blizzard/tsconfig.json
new file mode 100644
index 0000000..9e25e6e
--- /dev/null
+++ b/packages/blizzard/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4c0b34..671037d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -158,6 +158,12 @@ importers:
specifier: ^4.3.5
version: 4.4.3
+ packages/blizzard:
+ dependencies:
+ '@slashwho/domain':
+ specifier: workspace:*
+ version: link:../domain
+
packages/contracts:
dependencies:
zod:
From 687db4bcf8d91e2c03eb2d0b998735cd99e51893 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 11:50:35 +0100
Subject: [PATCH 04/23] fix(blizzard): enforce fingerprint match floors
---
packages/blizzard/src/fingerprint.test.ts | 23 +++++++++++++++++++++++
packages/blizzard/src/fingerprint.ts | 11 +++++++++--
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/packages/blizzard/src/fingerprint.test.ts b/packages/blizzard/src/fingerprint.test.ts
index 53b15d3..b861b15 100644
--- a/packages/blizzard/src/fingerprint.test.ts
+++ b/packages/blizzard/src/fingerprint.test.ts
@@ -28,4 +28,27 @@ describe("compareFingerprints", () => {
isMatch: true
});
});
+
+ it("does not allow caller policy to lower the mandatory match floors", () => {
+ // Break caught: worker configuration could turn a weak coincidence into a
+ // fingerprint-derived relationship by supplying lower thresholds.
+ const weakPolicy = { minimumCommon: 1, minimumIdenticalPercent: 0 };
+ const fewerThanMandatoryCommon = fingerprint(199, 199);
+ const belowMandatoryIdenticalPercent = fingerprint(200, 0);
+
+ expect(
+ compareFingerprints(
+ fewerThanMandatoryCommon,
+ fewerThanMandatoryCommon,
+ weakPolicy
+ ).isMatch
+ ).toBe(false);
+ expect(
+ compareFingerprints(
+ fingerprint(200, 200),
+ belowMandatoryIdenticalPercent,
+ weakPolicy
+ ).isMatch
+ ).toBe(false);
+ });
});
diff --git a/packages/blizzard/src/fingerprint.ts b/packages/blizzard/src/fingerprint.ts
index 18e9727..f8d4c28 100644
--- a/packages/blizzard/src/fingerprint.ts
+++ b/packages/blizzard/src/fingerprint.ts
@@ -1,5 +1,8 @@
import type { AchievementFingerprint } from "./types";
+const mandatoryMinimumCommon = 200;
+const mandatoryMinimumIdenticalPercent = 20;
+
export function compareFingerprints(
root: AchievementFingerprint,
candidate: AchievementFingerprint,
@@ -17,11 +20,15 @@ export function compareFingerprints(
}
const identicalPercent = common === 0 ? 0 : (identical / common) * 100;
+ const minimumCommon = Math.max(mandatoryMinimumCommon, policy.minimumCommon);
+ const minimumIdenticalPercent = Math.max(
+ mandatoryMinimumIdenticalPercent,
+ policy.minimumIdenticalPercent
+ );
return {
common,
identical,
isMatch:
- common >= policy.minimumCommon &&
- identicalPercent >= policy.minimumIdenticalPercent
+ common >= minimumCommon && identicalPercent >= minimumIdenticalPercent
};
}
From 37d4dd4c8ef368a0e7f0c50f3ac474385ba9a9a5 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 11:54:33 +0100
Subject: [PATCH 05/23] docs: keep fingerprint seam dependency-free
---
...nt-fingerprint-discovery-implementation.md | 28 +++++++++++++++++--
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md b/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
index 702e01c..3dbd842 100644
--- a/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
+++ b/docs/superpowers/plans/2026-08-10-achievement-fingerprint-discovery-implementation.md
@@ -38,6 +38,10 @@
| `apps/worker/src/config.ts` / `runtime.ts` | Validated Blizzard and sweep settings; creates the Blizzard client and registers admission workers/maintenance. |
| Existing unit, integration, and runtime tests | Demonstrate privacy, budget, snapshot, retry, and public-contract invariants. |
+### Dependency seam
+
+`@slashwho/blizzard` depends on `@slashwho/domain` for the existing canonical character key. To avoid a reverse workspace dependency, the domain module owns the small `FingerprintGateway` interface it needs. The application layer supplies an adapter around `BlizzardGateway`; domain tests use a fake. The domain module never imports `@slashwho/blizzard`.
+
## Task 1: Create the Blizzard boundary and pure matcher
**Files:**
@@ -157,10 +161,28 @@ git commit -m "feat(blizzard): add ephemeral fingerprint client"
**Interfaces:**
-- Consumes: `BlizzardGateway`, `BlizzardRosterCharacter`, and `compareFingerprints` from `@slashwho/blizzard`; `CharacterKey`, `DiscoveredCharacter`, and `toRaiderIoUrl` from existing domain modules.
+- Consumes: `CharacterKey`, `DiscoveredCharacter`, and `toRaiderIoUrl` from existing domain modules. The caller supplies a domain-owned adapter; the domain package does not import `@slashwho/blizzard`.
- Produces:
```ts
+export type FingerprintCandidate = Readonly<{
+ key: CharacterKey;
+ displayName: string;
+ className: string;
+ level: number;
+}>;
+
+export interface FingerprintGateway {
+ getGuildRoster(
+ root: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+ getAchievementFingerprint(
+ key: CharacterKey,
+ signal?: AbortSignal
+ ): Promise>;
+}
+
export type FingerprintSweepOutcome =
| {
kind: "matched";
@@ -181,7 +203,7 @@ export type FingerprintSweepOutcome =
export function discoverFingerprintMatches(
root: CharacterKey,
- gateway: BlizzardGateway,
+ gateway: FingerprintGateway,
options: {
requestCap: number;
minimumCommon: number;
@@ -422,7 +444,7 @@ git commit -m "feat(worker): dispatch fingerprint admissions"
**Interfaces:**
-- Consumes: existing `discoverCharacter`, `discoverFingerprintMatches`, `FingerprintSweepRepository`, `BlizzardGateway`, and `DiscoveryWorkContext`.
+- Consumes: existing `discoverCharacter`, `discoverFingerprintMatches`, `FingerprintSweepRepository`, `BlizzardGateway`, and `DiscoveryWorkContext`. `packages/application/src/blizzard-fingerprint-adapter.ts` adapts `BlizzardGateway` to the domain-owned `FingerprintGateway`; it does not duplicate matching or upstream logic.
- Produces an extended handler option:
```ts
From 16abc7a54bb58fc9e97ab446b226bdefba4994f7 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 12:01:21 +0100
Subject: [PATCH 06/23] feat(domain): add cap-aware fingerprint sweep
---
packages/domain/src/deduplicate.ts | 2 +-
.../domain/src/fingerprint-discovery.test.ts | 253 ++++++++++++++++
packages/domain/src/fingerprint-discovery.ts | 274 ++++++++++++++++++
packages/domain/src/index.ts | 7 +
4 files changed, 535 insertions(+), 1 deletion(-)
create mode 100644 packages/domain/src/fingerprint-discovery.test.ts
create mode 100644 packages/domain/src/fingerprint-discovery.ts
diff --git a/packages/domain/src/deduplicate.ts b/packages/domain/src/deduplicate.ts
index a81288c..128a11c 100644
--- a/packages/domain/src/deduplicate.ts
+++ b/packages/domain/src/deduplicate.ts
@@ -1,7 +1,7 @@
import type { CharacterKey } from "./character-key";
export type DiscoverySource =
- "input" | "claimed" | "declared_main" | "profile_guess";
+ "input" | "claimed" | "declared_main" | "profile_guess" | "fingerprint";
export interface DiscoveredCharacter {
readonly key: CharacterKey;
diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts
new file mode 100644
index 0000000..9d0b56d
--- /dev/null
+++ b/packages/domain/src/fingerprint-discovery.test.ts
@@ -0,0 +1,253 @@
+import { describe, expect, it } from "vitest";
+
+import type { CharacterKey } from "./character-key";
+import {
+ discoverFingerprintMatches,
+ type FingerprintCandidate,
+ type FingerprintGateway
+} from ".";
+
+const root: CharacterKey = {
+ region: "eu",
+ realm: "silvermoon",
+ name: "root"
+};
+
+const matchingKey: CharacterKey = {
+ region: "eu",
+ realm: "silvermoon",
+ name: "matching"
+};
+
+function fingerprint(
+ common: number,
+ identical: number = common
+): ReadonlyMap {
+ return new Map(
+ Array.from({ length: common }, (_, id) => [id, id < identical ? 1 : 2])
+ );
+}
+
+function candidate(key: CharacterKey): FingerprintCandidate {
+ return {
+ key,
+ displayName: key.name,
+ className: "Mage",
+ level: 80
+ };
+}
+
+function keyId(key: CharacterKey): string {
+ return `${key.region}/${key.realm}/${key.name}`;
+}
+
+function gatewayFor(
+ roster: readonly FingerprintCandidate[],
+ fingerprints: Readonly>>
+): FingerprintGateway {
+ return {
+ async getGuildRoster() {
+ return roster;
+ },
+ async getAchievementFingerprint(key) {
+ const value = fingerprints[keyId(key)];
+ if (!value)
+ throw Object.assign(new Error("missing"), { kind: "not_found" });
+ return value;
+ }
+ };
+}
+
+const options = {
+ requestCap: 3,
+ minimumCommon: 200,
+ minimumIdenticalPercent: 20,
+ isSuppressed: async (key: CharacterKey) => key.name === "a-suppressed",
+ isPrivacyHidden: async (key: CharacterKey) => key.name === "b-hidden"
+};
+
+describe("discoverFingerprintMatches", () => {
+ it("fetches the root once, skips suppressed, privacy-hidden, and cross-region candidates, and stops at its cap", async () => {
+ // Break caught: roster order or excluded candidates could consume the sweep
+ // budget, preventing an otherwise matching same-region character from being
+ // admitted before the cap.
+ const outcome = await discoverFingerprintMatches(
+ root,
+ gatewayFor(
+ [
+ candidate({ region: "eu", realm: "silvermoon", name: "z-last" }),
+ candidate(matchingKey),
+ candidate({ region: "eu", realm: "silvermoon", name: "b-hidden" }),
+ candidate({
+ region: "eu",
+ realm: "silvermoon",
+ name: "a-suppressed"
+ }),
+ candidate({ region: "us", realm: "area-52", name: "other-region" }),
+ candidate(root)
+ ],
+ {
+ [keyId(root)]: fingerprint(200),
+ [keyId(matchingKey)]: fingerprint(200),
+ "eu/silvermoon/z-last": fingerprint(200, 0)
+ }
+ ),
+ options
+ );
+
+ expect(outcome).toEqual({
+ kind: "capped",
+ requestsUsed: 3,
+ characters: [
+ {
+ key: matchingKey,
+ displayName: "matching",
+ className: "Mage",
+ level: 80,
+ raiderIoUrl: "https://raider.io/characters/eu/silvermoon/matching",
+ source: "fingerprint"
+ }
+ ]
+ });
+ });
+
+ it("enforces the non-configurable matching floors", async () => {
+ // Break caught: lower caller-provided thresholds could admit a weak
+ // fingerprint-derived relationship.
+ const outcome = await discoverFingerprintMatches(
+ root,
+ gatewayFor([candidate(matchingKey)], {
+ [keyId(root)]: fingerprint(199),
+ [keyId(matchingKey)]: fingerprint(199)
+ }),
+ {
+ ...options,
+ requestCap: 3,
+ minimumCommon: 1,
+ minimumIdenticalPercent: 0,
+ isSuppressed: async () => false,
+ isPrivacyHidden: async () => false
+ }
+ );
+
+ expect(outcome).toEqual({
+ kind: "matched",
+ requestsUsed: 3,
+ characters: []
+ });
+ });
+
+ it("does not report a cap when the roster is exhausted exactly at the budget", async () => {
+ // Break caught: consuming the final allowed request could be mistaken for a
+ // measured cap stop despite there being no further work to perform.
+ await expect(
+ discoverFingerprintMatches(
+ root,
+ gatewayFor(
+ [candidate({ region: "us", realm: "area-52", name: "other-region" })],
+ { [keyId(root)]: fingerprint(200) }
+ ),
+ { ...options, requestCap: 2 }
+ )
+ ).resolves.toEqual({
+ kind: "matched",
+ requestsUsed: 2,
+ characters: []
+ });
+ });
+
+ it("rechecks privacy immediately before admitting a matched candidate", async () => {
+ // Break caught: a privacy-hidden designation that lands while the candidate
+ // fingerprint is being fetched could still be retained in the result.
+ let privacyChecks = 0;
+ const outcome = await discoverFingerprintMatches(
+ root,
+ gatewayFor([candidate(matchingKey)], {
+ [keyId(root)]: fingerprint(200),
+ [keyId(matchingKey)]: fingerprint(200)
+ }),
+ {
+ ...options,
+ isSuppressed: async () => false,
+ isPrivacyHidden: async () => {
+ privacyChecks += 1;
+ return privacyChecks > 1;
+ }
+ }
+ );
+
+ expect(outcome).toEqual({
+ kind: "matched",
+ requestsUsed: 3,
+ characters: []
+ });
+ });
+
+ it("rechecks suppression immediately before admitting a matched candidate", async () => {
+ // Break caught: a removal that lands while the candidate fingerprint is
+ // being fetched could still be retained in the result.
+ let suppressionChecks = 0;
+ const outcome = await discoverFingerprintMatches(
+ root,
+ gatewayFor([candidate(matchingKey)], {
+ [keyId(root)]: fingerprint(200),
+ [keyId(matchingKey)]: fingerprint(200)
+ }),
+ {
+ ...options,
+ isSuppressed: async () => {
+ suppressionChecks += 1;
+ return suppressionChecks > 1;
+ },
+ isPrivacyHidden: async () => false
+ }
+ );
+
+ expect(outcome).toEqual({
+ kind: "matched",
+ requestsUsed: 3,
+ characters: []
+ });
+ });
+
+ it("returns a retryable failure for a 429", async () => {
+ // Break caught: rate limiting could publish a partial match set instead of
+ // restarting the atomic sweep through the worker retry path.
+ const rateLimited = Object.assign(new Error("rate limited"), {
+ kind: "transient",
+ status: 429,
+ retryAfterMs: 30_000
+ });
+ const gateway = gatewayFor([], { [keyId(root)]: fingerprint(200) });
+ gateway.getAchievementFingerprint = async () => {
+ throw rateLimited;
+ };
+
+ await expect(
+ discoverFingerprintMatches(root, gateway, options)
+ ).resolves.toEqual({
+ kind: "failure",
+ code: "upstream_unavailable",
+ retryable: true,
+ retryAfterMs: 30_000
+ });
+ });
+
+ it("throws the abort reason without returning a partial result", async () => {
+ // Break caught: cancellation after an upstream response could continue the
+ // sweep and expose observations from an abandoned atomic run.
+ const aborted = new AbortController();
+ const gateway = gatewayFor([], { [keyId(root)]: fingerprint(200) });
+ gateway.getGuildRoster = async () => {
+ aborted.abort(new DOMException("drain timeout", "AbortError"));
+ return [];
+ };
+
+ await expect(
+ discoverFingerprintMatches(root, gateway, {
+ ...options,
+ signal: aborted.signal
+ })
+ ).rejects.toBe(aborted.signal.reason);
+ });
+});
diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts
new file mode 100644
index 0000000..3f55c72
--- /dev/null
+++ b/packages/domain/src/fingerprint-discovery.ts
@@ -0,0 +1,274 @@
+import { toRaiderIoUrl, type CharacterKey } from "./character-key";
+import { canonicalCharacterId, type DiscoveredCharacter } from "./deduplicate";
+
+const mandatoryMinimumCommon = 200;
+const mandatoryMinimumIdenticalPercent = 20;
+const budgetExhausted = Symbol("budget_exhausted");
+
+export type FingerprintCandidate = Readonly<{
+ key: CharacterKey;
+ displayName: string;
+ className: string;
+ level: number;
+}>;
+
+export interface FingerprintGateway {
+ getGuildRoster(
+ root: CharacterKey,
+ signal?: AbortSignal
+ ): Promise;
+ getAchievementFingerprint(
+ key: CharacterKey,
+ signal?: AbortSignal
+ ): Promise>;
+}
+
+export type FingerprintSweepOutcome =
+ | {
+ kind: "matched";
+ characters: readonly DiscoveredCharacter[];
+ requestsUsed: number;
+ }
+ | {
+ kind: "capped";
+ characters: readonly DiscoveredCharacter[];
+ requestsUsed: number;
+ }
+ | {
+ kind: "failure";
+ code: "upstream_unavailable" | "upstream_schema_changed";
+ retryable: boolean;
+ retryAfterMs?: number;
+ };
+
+export type DiscoverFingerprintMatchesOptions = {
+ requestCap: number;
+ minimumCommon: number;
+ minimumIdenticalPercent: number;
+ isSuppressed(key: CharacterKey): Promise;
+ isPrivacyHidden(key: CharacterKey): Promise;
+ signal?: AbortSignal;
+};
+
+function isCharacterKey(value: unknown): value is CharacterKey {
+ if (typeof value !== "object" || value === null) return false;
+
+ return (
+ "region" in value &&
+ typeof value.region === "string" &&
+ "realm" in value &&
+ typeof value.realm === "string" &&
+ "name" in value &&
+ typeof value.name === "string"
+ );
+}
+
+function isCandidate(value: unknown): value is FingerprintCandidate {
+ if (typeof value !== "object" || value === null) return false;
+
+ return (
+ "key" in value &&
+ isCharacterKey(value.key) &&
+ "displayName" in value &&
+ typeof value.displayName === "string" &&
+ "className" in value &&
+ typeof value.className === "string" &&
+ "level" in value &&
+ typeof value.level === "number"
+ );
+}
+
+function isCandidateList(
+ value: unknown
+): value is readonly FingerprintCandidate[] {
+ return Array.isArray(value) && value.every(isCandidate);
+}
+
+function isFingerprint(value: unknown): value is ReadonlyMap {
+ return (
+ value instanceof Map &&
+ [...value].every(
+ ([achievementId, timestamp]) =>
+ typeof achievementId === "number" &&
+ Number.isFinite(achievementId) &&
+ typeof timestamp === "number" &&
+ Number.isFinite(timestamp)
+ )
+ );
+}
+
+function compareCandidates(
+ left: FingerprintCandidate,
+ right: FingerprintCandidate
+): number {
+ return canonicalCharacterId(left.key).localeCompare(
+ canonicalCharacterId(right.key)
+ );
+}
+
+function fingerprintMatches(
+ root: ReadonlyMap,
+ candidate: ReadonlyMap,
+ options: DiscoverFingerprintMatchesOptions
+): boolean {
+ let common = 0;
+ let identical = 0;
+
+ for (const [achievementId, timestamp] of root) {
+ const candidateTimestamp = candidate.get(achievementId);
+ if (candidateTimestamp === undefined) continue;
+
+ common += 1;
+ if (candidateTimestamp === timestamp) identical += 1;
+ }
+
+ const identicalPercent = common === 0 ? 0 : (identical / common) * 100;
+ return (
+ common >= Math.max(mandatoryMinimumCommon, options.minimumCommon) &&
+ identicalPercent >=
+ Math.max(
+ mandatoryMinimumIdenticalPercent,
+ options.minimumIdenticalPercent
+ )
+ );
+}
+
+function discoveredCharacter(
+ candidate: FingerprintCandidate
+): DiscoveredCharacter {
+ return {
+ key: candidate.key,
+ displayName: candidate.displayName,
+ className: candidate.className,
+ level: candidate.level,
+ raiderIoUrl: toRaiderIoUrl(candidate.key),
+ source: "fingerprint"
+ };
+}
+
+function failureOutcome(error: unknown): FingerprintSweepOutcome {
+ const kind =
+ typeof error === "object" && error !== null && "kind" in error
+ ? error.kind
+ : undefined;
+
+ if (kind === "schema_drift") {
+ return {
+ kind: "failure",
+ code: "upstream_schema_changed",
+ retryable: false
+ };
+ }
+
+ const retryAfterMs =
+ typeof error === "object" &&
+ error !== null &&
+ "retryAfterMs" in error &&
+ typeof error.retryAfterMs === "number" &&
+ Number.isFinite(error.retryAfterMs) &&
+ error.retryAfterMs >= 0
+ ? error.retryAfterMs
+ : undefined;
+
+ return {
+ kind: "failure",
+ code: "upstream_unavailable",
+ retryable: true,
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs })
+ };
+}
+
+export async function discoverFingerprintMatches(
+ root: CharacterKey,
+ gateway: FingerprintGateway,
+ options: DiscoverFingerprintMatchesOptions
+): Promise {
+ let remainingRequests = Number.isFinite(options.requestCap)
+ ? Math.max(0, Math.floor(options.requestCap))
+ : 0;
+ let requestsUsed = 0;
+ let capped = false;
+ const matches: DiscoveredCharacter[] = [];
+
+ function throwIfAborted(): void {
+ options.signal?.throwIfAborted();
+ }
+
+ async function request(
+ operation: () => Promise
+ ): Promise {
+ throwIfAborted();
+ if (remainingRequests === 0) {
+ capped = true;
+ return budgetExhausted;
+ }
+
+ remainingRequests -= 1;
+ requestsUsed += 1;
+ const result = await operation();
+ throwIfAborted();
+ return result;
+ }
+
+ try {
+ const roster = await request(() =>
+ gateway.getGuildRoster(root, options.signal)
+ );
+ if (roster === budgetExhausted) {
+ return { kind: "capped", characters: [], requestsUsed };
+ }
+ if (!isCandidateList(roster)) throw { kind: "schema_drift" };
+
+ const rootFingerprint = await request(() =>
+ gateway.getAchievementFingerprint(root, options.signal)
+ );
+ if (rootFingerprint === budgetExhausted) {
+ return { kind: "capped", characters: [], requestsUsed };
+ }
+ if (!isFingerprint(rootFingerprint)) throw { kind: "schema_drift" };
+
+ const rootId = canonicalCharacterId(root);
+ const candidates = [...roster].sort(compareCandidates);
+ const seen = new Set();
+ for (const candidate of candidates) {
+ throwIfAborted();
+ const candidateId = canonicalCharacterId(candidate.key);
+ if (
+ candidateId === rootId ||
+ seen.has(candidateId) ||
+ candidate.key.region !== root.region
+ ) {
+ continue;
+ }
+ seen.add(candidateId);
+
+ if (await options.isSuppressed(candidate.key)) continue;
+ throwIfAborted();
+ if (await options.isPrivacyHidden(candidate.key)) continue;
+ throwIfAborted();
+
+ const candidateFingerprint = await request(() =>
+ gateway.getAchievementFingerprint(candidate.key, options.signal)
+ );
+ if (candidateFingerprint === budgetExhausted) break;
+ if (!isFingerprint(candidateFingerprint)) throw { kind: "schema_drift" };
+
+ if (!fingerprintMatches(rootFingerprint, candidateFingerprint, options)) {
+ continue;
+ }
+ if (await options.isSuppressed(candidate.key)) continue;
+ throwIfAborted();
+ if (await options.isPrivacyHidden(candidate.key)) continue;
+ throwIfAborted();
+
+ matches.push(discoveredCharacter(candidate));
+ }
+ } catch (error) {
+ if (options.signal?.aborted) throw options.signal.reason;
+ return failureOutcome(error);
+ }
+
+ return capped
+ ? { kind: "capped", characters: matches, requestsUsed }
+ : { kind: "matched", characters: matches, requestsUsed };
+}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index d835918..c15aa31 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -15,3 +15,10 @@ export type {
RaiderIoGateway,
RaiderIoProfile
} from "./discovery";
+export { discoverFingerprintMatches } from "./fingerprint-discovery";
+export type {
+ DiscoverFingerprintMatchesOptions,
+ FingerprintCandidate,
+ FingerprintGateway,
+ FingerprintSweepOutcome
+} from "./fingerprint-discovery";
From e03bd4ecd1c85c3561aca6078800b92c15e7c486 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 13:16:05 +0100
Subject: [PATCH 07/23] fix(domain): honor aborts from policy checks
---
.../domain/src/fingerprint-discovery.test.ts | 28 +++++++++++++++++++
packages/domain/src/fingerprint-discovery.ts | 16 ++++++++---
2 files changed, 40 insertions(+), 4 deletions(-)
diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts
index 9d0b56d..bf22317 100644
--- a/packages/domain/src/fingerprint-discovery.test.ts
+++ b/packages/domain/src/fingerprint-discovery.test.ts
@@ -210,6 +210,34 @@ describe("discoverFingerprintMatches", () => {
});
});
+ it("throws the abort reason when a suppressing policy callback aborts", async () => {
+ // Break caught: a callback that excludes a candidate could bypass the next
+ // abort checkpoint and let an abandoned sweep return a result.
+ const aborted = new AbortController();
+ const abortReason = new DOMException("drain timeout", "AbortError");
+ const outcome = discoverFingerprintMatches(
+ root,
+ gatewayFor([candidate(matchingKey)], {
+ [keyId(root)]: fingerprint(200),
+ [keyId(matchingKey)]: fingerprint(200)
+ }),
+ {
+ ...options,
+ isSuppressed: async (key) => {
+ if (keyId(key) === keyId(matchingKey)) {
+ aborted.abort(abortReason);
+ return true;
+ }
+ return false;
+ },
+ isPrivacyHidden: async () => false,
+ signal: aborted.signal
+ }
+ );
+
+ await expect(outcome).rejects.toBe(abortReason);
+ });
+
it("returns a retryable failure for a 429", async () => {
// Break caught: rate limiting could publish a partial match set instead of
// restarting the atomic sweep through the worker retry path.
diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts
index 3f55c72..b397d6d 100644
--- a/packages/domain/src/fingerprint-discovery.ts
+++ b/packages/domain/src/fingerprint-discovery.ts
@@ -242,10 +242,12 @@ export async function discoverFingerprintMatches(
}
seen.add(candidateId);
- if (await options.isSuppressed(candidate.key)) continue;
+ const isSuppressed = await options.isSuppressed(candidate.key);
throwIfAborted();
- if (await options.isPrivacyHidden(candidate.key)) continue;
+ if (isSuppressed) continue;
+ const isPrivacyHidden = await options.isPrivacyHidden(candidate.key);
throwIfAborted();
+ if (isPrivacyHidden) continue;
const candidateFingerprint = await request(() =>
gateway.getAchievementFingerprint(candidate.key, options.signal)
@@ -256,10 +258,16 @@ export async function discoverFingerprintMatches(
if (!fingerprintMatches(rootFingerprint, candidateFingerprint, options)) {
continue;
}
- if (await options.isSuppressed(candidate.key)) continue;
+ const isSuppressedBeforeAdmission = await options.isSuppressed(
+ candidate.key
+ );
throwIfAborted();
- if (await options.isPrivacyHidden(candidate.key)) continue;
+ if (isSuppressedBeforeAdmission) continue;
+ const isPrivacyHiddenBeforeAdmission = await options.isPrivacyHidden(
+ candidate.key
+ );
throwIfAborted();
+ if (isPrivacyHiddenBeforeAdmission) continue;
matches.push(discoveredCharacter(candidate));
}
From c528946a6e24292c16204a19273332a95c1f977f Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 13:45:17 +0100
Subject: [PATCH 08/23] feat(database): reserve fingerprint sweep budget
---
.../drizzle/0002_fingerprint_sweeps.sql | 47 +
.../database/drizzle/meta/0002_snapshot.json | 1179 +++++++++++++++++
packages/database/drizzle/meta/_journal.json | 7 +
packages/database/src/index.ts | 2 +
.../database/src/postgres-repositories.ts | 324 +++++
packages/database/src/public-api.typecheck.ts | 4 +
packages/database/src/repositories.ts | 26 +-
packages/database/src/schema.ts | 101 +-
tests/integration/repositories.test.ts | 127 ++
9 files changed, 1815 insertions(+), 2 deletions(-)
create mode 100644 packages/database/drizzle/0002_fingerprint_sweeps.sql
create mode 100644 packages/database/drizzle/meta/0002_snapshot.json
diff --git a/packages/database/drizzle/0002_fingerprint_sweeps.sql b/packages/database/drizzle/0002_fingerprint_sweeps.sql
new file mode 100644
index 0000000..813ea38
--- /dev/null
+++ b/packages/database/drizzle/0002_fingerprint_sweeps.sql
@@ -0,0 +1,47 @@
+ALTER TYPE "public"."discovery_source" ADD VALUE 'fingerprint';--> statement-breakpoint
+CREATE TABLE "fingerprint_sweep_admissions" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "queue_order" bigserial NOT NULL,
+ "discovery_run_id" uuid NOT NULL,
+ "region" text NOT NULL,
+ "realm_slug" text NOT NULL,
+ "normalized_name" text NOT NULL,
+ "request_cap" integer NOT NULL,
+ "hourly_budget" integer NOT NULL,
+ "cadence_cutoff" timestamp with time zone NOT NULL,
+ "status" text DEFAULT 'waiting' NOT NULL,
+ "requested_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "fingerprint_sweep_admissions_request_cap_check" CHECK ("fingerprint_sweep_admissions"."request_cap" > 0),
+ CONSTRAINT "fingerprint_sweep_admissions_hourly_budget_check" CHECK ("fingerprint_sweep_admissions"."hourly_budget" > 0)
+);
+--> statement-breakpoint
+CREATE TABLE "fingerprint_sweep_reservations" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "admission_id" uuid NOT NULL,
+ "request_cap" integer NOT NULL,
+ "used_count" integer DEFAULT 0 NOT NULL,
+ "admitted_at" timestamp with time zone NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "released_at" timestamp with time zone,
+ "finished_at" timestamp with time zone,
+ "published" boolean,
+ "limitation_code" text,
+ CONSTRAINT "fingerprint_sweep_reservations_request_cap_check" CHECK ("fingerprint_sweep_reservations"."request_cap" > 0),
+ CONSTRAINT "fingerprint_sweep_reservations_used_count_check" CHECK ("fingerprint_sweep_reservations"."used_count" >= 0 AND "fingerprint_sweep_reservations"."used_count" <= "fingerprint_sweep_reservations"."request_cap"),
+ CONSTRAINT "fingerprint_sweep_reservations_expiry_check" CHECK ("fingerprint_sweep_reservations"."expires_at" > "fingerprint_sweep_reservations"."admitted_at")
+);
+--> statement-breakpoint
+CREATE TABLE "fingerprint_sweep_states" (
+ "region" text NOT NULL,
+ "realm_slug" text NOT NULL,
+ "normalized_name" text NOT NULL,
+ "last_published_at" timestamp with time zone,
+ CONSTRAINT "fingerprint_sweep_states_pkey" PRIMARY KEY("region","realm_slug","normalized_name")
+);
+--> statement-breakpoint
+ALTER TABLE "fingerprint_sweep_admissions" ADD CONSTRAINT "fingerprint_sweep_admissions_discovery_run_id_discovery_runs_id_fk" FOREIGN KEY ("discovery_run_id") REFERENCES "public"."discovery_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "fingerprint_sweep_reservations" ADD CONSTRAINT "fingerprint_sweep_reservations_admission_id_fingerprint_sweep_admissions_id_fk" FOREIGN KEY ("admission_id") REFERENCES "public"."fingerprint_sweep_admissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "fingerprint_sweep_admissions_waiting_idx" ON "fingerprint_sweep_admissions" USING btree ("status","requested_at","queue_order");--> statement-breakpoint
+CREATE INDEX "fingerprint_sweep_admissions_root_idx" ON "fingerprint_sweep_admissions" USING btree ("region","realm_slug","normalized_name");--> statement-breakpoint
+CREATE UNIQUE INDEX "fingerprint_sweep_reservations_admission_idx" ON "fingerprint_sweep_reservations" USING btree ("admission_id");--> statement-breakpoint
+CREATE INDEX "fingerprint_sweep_reservations_expiry_idx" ON "fingerprint_sweep_reservations" USING btree ("expires_at");
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0002_snapshot.json b/packages/database/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..3a983f5
--- /dev/null
+++ b/packages/database/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,1179 @@
+{
+ "id": "f5f059bd-f78a-47d5-bfaa-9874bdffacd5",
+ "prevId": "3b6dfffc-fdbb-46bb-b4f1-078a749ddd35",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.characters": {
+ "name": "characters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm_slug": {
+ "name": "realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "class_name": {
+ "name": "class_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raider_io_url": {
+ "name": "raider_io_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "characters_canonical_key_idx": {
+ "name": "characters_canonical_key_idx",
+ "columns": [
+ {
+ "expression": "region",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "normalized_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.discovery_runs": {
+ "name": "discovery_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "root_region": {
+ "name": "root_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "root_realm_slug": {
+ "name": "root_realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "root_normalized_name": {
+ "name": "root_normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "root_character_id": {
+ "name": "root_character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "queue_job_id": {
+ "name": "queue_job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "discovery_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "caller_class": {
+ "name": "caller_class",
+ "type": "caller_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "discovery_runs_one_active_root_idx": {
+ "name": "discovery_runs_one_active_root_idx",
+ "columns": [
+ {
+ "expression": "root_region",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "root_realm_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "root_normalized_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"discovery_runs\".\"status\" in ('queued', 'running', 'retrying')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "discovery_runs_root_character_id_characters_id_fk": {
+ "name": "discovery_runs_root_character_id_characters_id_fk",
+ "tableFrom": "discovery_runs",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "root_character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "discovery_runs_snapshot_id_snapshots_id_fk": {
+ "name": "discovery_runs_snapshot_id_snapshots_id_fk",
+ "tableFrom": "discovery_runs",
+ "tableTo": "snapshots",
+ "columnsFrom": [
+ "snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.fingerprint_sweep_admissions": {
+ "name": "fingerprint_sweep_admissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "queue_order": {
+ "name": "queue_order",
+ "type": "bigserial",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "discovery_run_id": {
+ "name": "discovery_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm_slug": {
+ "name": "realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_cap": {
+ "name": "request_cap",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hourly_budget": {
+ "name": "hourly_budget",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cadence_cutoff": {
+ "name": "cadence_cutoff",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'waiting'"
+ },
+ "requested_at": {
+ "name": "requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "fingerprint_sweep_admissions_waiting_idx": {
+ "name": "fingerprint_sweep_admissions_waiting_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "requested_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "queue_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "fingerprint_sweep_admissions_root_idx": {
+ "name": "fingerprint_sweep_admissions_root_idx",
+ "columns": [
+ {
+ "expression": "region",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "normalized_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "fingerprint_sweep_admissions_discovery_run_id_discovery_runs_id_fk": {
+ "name": "fingerprint_sweep_admissions_discovery_run_id_discovery_runs_id_fk",
+ "tableFrom": "fingerprint_sweep_admissions",
+ "tableTo": "discovery_runs",
+ "columnsFrom": [
+ "discovery_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "fingerprint_sweep_admissions_request_cap_check": {
+ "name": "fingerprint_sweep_admissions_request_cap_check",
+ "value": "\"fingerprint_sweep_admissions\".\"request_cap\" > 0"
+ },
+ "fingerprint_sweep_admissions_hourly_budget_check": {
+ "name": "fingerprint_sweep_admissions_hourly_budget_check",
+ "value": "\"fingerprint_sweep_admissions\".\"hourly_budget\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.fingerprint_sweep_reservations": {
+ "name": "fingerprint_sweep_reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "admission_id": {
+ "name": "admission_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_cap": {
+ "name": "request_cap",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_count": {
+ "name": "used_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "admitted_at": {
+ "name": "admitted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "released_at": {
+ "name": "released_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limitation_code": {
+ "name": "limitation_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "fingerprint_sweep_reservations_admission_idx": {
+ "name": "fingerprint_sweep_reservations_admission_idx",
+ "columns": [
+ {
+ "expression": "admission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "fingerprint_sweep_reservations_expiry_idx": {
+ "name": "fingerprint_sweep_reservations_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "fingerprint_sweep_reservations_admission_id_fingerprint_sweep_admissions_id_fk": {
+ "name": "fingerprint_sweep_reservations_admission_id_fingerprint_sweep_admissions_id_fk",
+ "tableFrom": "fingerprint_sweep_reservations",
+ "tableTo": "fingerprint_sweep_admissions",
+ "columnsFrom": [
+ "admission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "fingerprint_sweep_reservations_request_cap_check": {
+ "name": "fingerprint_sweep_reservations_request_cap_check",
+ "value": "\"fingerprint_sweep_reservations\".\"request_cap\" > 0"
+ },
+ "fingerprint_sweep_reservations_used_count_check": {
+ "name": "fingerprint_sweep_reservations_used_count_check",
+ "value": "\"fingerprint_sweep_reservations\".\"used_count\" >= 0 AND \"fingerprint_sweep_reservations\".\"used_count\" <= \"fingerprint_sweep_reservations\".\"request_cap\""
+ },
+ "fingerprint_sweep_reservations_expiry_check": {
+ "name": "fingerprint_sweep_reservations_expiry_check",
+ "value": "\"fingerprint_sweep_reservations\".\"expires_at\" > \"fingerprint_sweep_reservations\".\"admitted_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.fingerprint_sweep_states": {
+ "name": "fingerprint_sweep_states",
+ "schema": "",
+ "columns": {
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm_slug": {
+ "name": "realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_published_at": {
+ "name": "last_published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "fingerprint_sweep_states_pkey": {
+ "name": "fingerprint_sweep_states_pkey",
+ "columns": [
+ "region",
+ "realm_slug",
+ "normalized_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.negative_character_cache": {
+ "name": "negative_character_cache",
+ "schema": "",
+ "columns": {
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm_slug": {
+ "name": "realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "negative_character_cache_expiry_idx": {
+ "name": "negative_character_cache_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "negative_character_cache_pkey": {
+ "name": "negative_character_cache_pkey",
+ "columns": [
+ "region",
+ "realm_slug",
+ "normalized_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_limit_events": {
+ "name": "rate_limit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "caller_bucket_hash": {
+ "name": "caller_bucket_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "discovery_run_id": {
+ "name": "discovery_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "rate_limit_events_bucket_expiry_idx": {
+ "name": "rate_limit_events_bucket_expiry_idx",
+ "columns": [
+ {
+ "expression": "caller_bucket_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rate_limit_events_expiry_idx": {
+ "name": "rate_limit_events_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rate_limit_events_discovery_run_idx": {
+ "name": "rate_limit_events_discovery_run_idx",
+ "columns": [
+ {
+ "expression": "discovery_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"rate_limit_events\".\"discovery_run_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rate_limit_events_discovery_run_id_discovery_runs_id_fk": {
+ "name": "rate_limit_events_discovery_run_id_discovery_runs_id_fk",
+ "tableFrom": "rate_limit_events",
+ "tableTo": "discovery_runs",
+ "columnsFrom": [
+ "discovery_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.snapshot_characters": {
+ "name": "snapshot_characters",
+ "schema": "",
+ "columns": {
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "discovery_source": {
+ "name": "discovery_source",
+ "type": "discovery_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "class_name": {
+ "name": "class_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raider_io_url": {
+ "name": "raider_io_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "snapshot_characters_membership_idx": {
+ "name": "snapshot_characters_membership_idx",
+ "columns": [
+ {
+ "expression": "snapshot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "snapshot_characters_display_order_idx": {
+ "name": "snapshot_characters_display_order_idx",
+ "columns": [
+ {
+ "expression": "snapshot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "snapshot_characters_snapshot_id_snapshots_id_fk": {
+ "name": "snapshot_characters_snapshot_id_snapshots_id_fk",
+ "tableFrom": "snapshot_characters",
+ "tableTo": "snapshots",
+ "columnsFrom": [
+ "snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "snapshot_characters_character_id_characters_id_fk": {
+ "name": "snapshot_characters_character_id_characters_id_fk",
+ "tableFrom": "snapshot_characters",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.snapshots": {
+ "name": "snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "root_character_id": {
+ "name": "root_character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "discovery_run_id": {
+ "name": "discovery_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "snapshot_state",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limitation_code": {
+ "name": "limitation_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refreshed_at": {
+ "name": "refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_count": {
+ "name": "character_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "snapshots_discovery_run_idx": {
+ "name": "snapshots_discovery_run_idx",
+ "columns": [
+ {
+ "expression": "discovery_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "snapshots_root_refreshed_idx": {
+ "name": "snapshots_root_refreshed_idx",
+ "columns": [
+ {
+ "expression": "root_character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "refreshed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "snapshots_root_character_id_characters_id_fk": {
+ "name": "snapshots_root_character_id_characters_id_fk",
+ "tableFrom": "snapshots",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "root_character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "snapshots_discovery_run_id_discovery_runs_id_fk": {
+ "name": "snapshots_discovery_run_id_discovery_runs_id_fk",
+ "tableFrom": "snapshots",
+ "tableTo": "discovery_runs",
+ "columnsFrom": [
+ "discovery_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "snapshots_state_limitation_check": {
+ "name": "snapshots_state_limitation_check",
+ "value": "(\"snapshots\".\"state\" = 'complete' AND \"snapshots\".\"limitation_code\" IS NULL) OR (\"snapshots\".\"state\" = 'partial' AND \"snapshots\".\"limitation_code\" IS NOT NULL)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.suppressed_characters": {
+ "name": "suppressed_characters",
+ "schema": "",
+ "columns": {
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm_slug": {
+ "name": "realm_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suppressed_at": {
+ "name": "suppressed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "suppressed_characters_expiry_idx": {
+ "name": "suppressed_characters_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "suppressed_characters_pkey": {
+ "name": "suppressed_characters_pkey",
+ "columns": [
+ "region",
+ "realm_slug",
+ "normalized_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.caller_class": {
+ "name": "caller_class",
+ "schema": "public",
+ "values": [
+ "anonymous",
+ "bot"
+ ]
+ },
+ "public.discovery_run_status": {
+ "name": "discovery_run_status",
+ "schema": "public",
+ "values": [
+ "queued",
+ "running",
+ "retrying",
+ "complete",
+ "failed"
+ ]
+ },
+ "public.discovery_source": {
+ "name": "discovery_source",
+ "schema": "public",
+ "values": [
+ "input",
+ "claimed",
+ "declared_main",
+ "profile_guess",
+ "fingerprint"
+ ]
+ },
+ "public.snapshot_state": {
+ "name": "snapshot_state",
+ "schema": "public",
+ "values": [
+ "complete",
+ "partial"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json
index 1783dfb..82b86b4 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1785927934514,
"tag": "0001_search_reservations",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1786365831105,
+ "tag": "0002_fingerprint_sweeps",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index 9bfcc59..547cce3 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -17,6 +17,8 @@ export type {
CreateSnapshotInput,
DiscoveryRun,
DiscoverySource,
+ FingerprintAdmission,
+ FingerprintSweepRepository,
NegativeCacheEntry,
NegativeCacheRepository,
RateLimitRepository,
diff --git a/packages/database/src/postgres-repositories.ts b/packages/database/src/postgres-repositories.ts
index be8aba6..bf6bb35 100644
--- a/packages/database/src/postgres-repositories.ts
+++ b/packages/database/src/postgres-repositories.ts
@@ -4,6 +4,7 @@ import type { Pool, PoolClient } from "pg";
import type {
CallerClass,
DiscoveryRun,
+ FingerprintAdmission,
Repositories,
SnapshotHistoryItem,
SnapshotHistoryPage,
@@ -64,6 +65,46 @@ async function lockRoot(client: Queryable, key: CharacterKey): Promise {
]);
}
+async function lockFingerprintSweeps(client: Queryable): Promise {
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
+ "fingerprint-sweeps"
+ ]);
+}
+
+function assertFingerprintAdmissionInput(input: {
+ requestCap: number;
+ hourlyBudget: number;
+ cadenceCutoff: Date;
+ at: Date;
+}): void {
+ if (!Number.isInteger(input.requestCap) || input.requestCap < 1) {
+ throw new RangeError("fingerprint_request_cap_out_of_range");
+ }
+ if (!Number.isInteger(input.hourlyBudget) || input.hourlyBudget < 1) {
+ throw new RangeError("fingerprint_hourly_budget_out_of_range");
+ }
+ if (input.requestCap > input.hourlyBudget) {
+ throw new RangeError("fingerprint_request_cap_exceeds_hourly_budget");
+ }
+ if (
+ Number.isNaN(input.cadenceCutoff.valueOf()) ||
+ Number.isNaN(input.at.valueOf())
+ ) {
+ throw new RangeError("fingerprint_admission_time_invalid");
+ }
+}
+
+async function fingerprintRetryAt(client: Queryable, at: Date): Promise {
+ const result = await client.query<{ retry_at: Date | null }>(
+ `SELECT min(expires_at) AS retry_at
+ FROM fingerprint_sweep_reservations
+ WHERE expires_at > $1
+ AND (released_at IS NULL OR used_count > 0)`,
+ [at]
+ );
+ return result.rows[0]?.retry_at ?? at;
+}
+
function mapRun(row: RunRow): DiscoveryRun {
return {
id: row.id,
@@ -887,6 +928,289 @@ export function createPostgresRepositories(pool: Pool): Repositories {
}
},
+ fingerprintSweeps: {
+ async requestAdmission(input): Promise {
+ assertFingerprintAdmissionInput(input);
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ await lockFingerprintSweeps(client);
+
+ const state = await client.query<{ last_published_at: Date | null }>(
+ `SELECT last_published_at
+ FROM fingerprint_sweep_states
+ WHERE region = $1 AND realm_slug = $2 AND normalized_name = $3`,
+ [input.key.region, input.key.realm, input.key.name]
+ );
+ if (
+ state.rows[0]?.last_published_at &&
+ state.rows[0].last_published_at > input.cadenceCutoff
+ ) {
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'not_due'
+ WHERE discovery_run_id = $1 AND status = 'waiting'`,
+ [input.runId]
+ );
+ await client.query("COMMIT");
+ return { kind: "not_due" };
+ }
+
+ const waiting = await client.query<{ id: string }>(
+ `SELECT id
+ FROM fingerprint_sweep_admissions
+ WHERE discovery_run_id = $1 AND status = 'waiting'
+ ORDER BY requested_at, queue_order
+ LIMIT 1
+ FOR UPDATE`,
+ [input.runId]
+ );
+ let admissionId = waiting.rows[0]?.id;
+ if (admissionId) {
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET request_cap = $2, hourly_budget = $3, cadence_cutoff = $4
+ WHERE id = $1`,
+ [
+ admissionId,
+ input.requestCap,
+ input.hourlyBudget,
+ input.cadenceCutoff
+ ]
+ );
+ } else {
+ const admission = await client.query<{ id: string }>(
+ `INSERT INTO fingerprint_sweep_admissions
+ (discovery_run_id, region, realm_slug, normalized_name, request_cap,
+ hourly_budget, cadence_cutoff, requested_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id`,
+ [
+ input.runId,
+ input.key.region,
+ input.key.realm,
+ input.key.name,
+ input.requestCap,
+ input.hourlyBudget,
+ input.cadenceCutoff,
+ input.at
+ ]
+ );
+ admissionId = admission.rows[0]!.id;
+ }
+
+ const head = await client.query<{
+ id: string;
+ request_cap: number;
+ hourly_budget: number;
+ }>(
+ `SELECT admission.id, admission.request_cap, admission.hourly_budget
+ FROM fingerprint_sweep_admissions admission
+ LEFT JOIN fingerprint_sweep_states state
+ ON state.region = admission.region
+ AND state.realm_slug = admission.realm_slug
+ AND state.normalized_name = admission.normalized_name
+ WHERE admission.status = 'waiting'
+ AND (
+ state.last_published_at IS NULL
+ OR state.last_published_at <= admission.cadence_cutoff
+ )
+ ORDER BY admission.requested_at, admission.queue_order
+ LIMIT 1
+ FOR UPDATE OF admission`
+ );
+ const candidate = head.rows[0];
+ if (!candidate || candidate.id !== admissionId) {
+ const retryAt = await fingerprintRetryAt(client, input.at);
+ await client.query("COMMIT");
+ return { kind: "waiting", retryAt };
+ }
+
+ const usage = await client.query<{ commitment: string }>(
+ `SELECT coalesce(sum(
+ used_count + CASE
+ WHEN released_at IS NULL THEN request_cap - used_count
+ ELSE 0
+ END
+ ), 0)::text AS commitment
+ FROM fingerprint_sweep_reservations
+ WHERE expires_at > $1`,
+ [input.at]
+ );
+ if (
+ Number(usage.rows[0]!.commitment) + candidate.request_cap >
+ candidate.hourly_budget
+ ) {
+ const retryAt = await fingerprintRetryAt(client, input.at);
+ await client.query("COMMIT");
+ return { kind: "waiting", retryAt };
+ }
+
+ const reservation = await client.query<{ id: string }>(
+ `INSERT INTO fingerprint_sweep_reservations
+ (admission_id, request_cap, admitted_at, expires_at)
+ VALUES ($1, $2, $3::timestamptz, $3::timestamptz + interval '1 hour')
+ RETURNING id`,
+ [admissionId, candidate.request_cap, input.at]
+ );
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'admitted'
+ WHERE id = $1`,
+ [admissionId]
+ );
+ await client.query("COMMIT");
+ return {
+ kind: "admitted",
+ reservationId: reservation.rows[0]!.id,
+ requestCap: candidate.request_cap
+ };
+ } catch (error) {
+ await client.query("ROLLBACK").catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
+ },
+
+ async recordRequest(reservationId, count, at) {
+ if (!Number.isInteger(count) || count < 1) {
+ throw new RangeError("fingerprint_request_count_out_of_range");
+ }
+ if (Number.isNaN(at.valueOf())) {
+ throw new RangeError("fingerprint_request_time_invalid");
+ }
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ await lockFingerprintSweeps(client);
+ const result = await client.query(
+ `UPDATE fingerprint_sweep_reservations
+ SET used_count = used_count + $2
+ WHERE id = $1
+ AND released_at IS NULL
+ AND expires_at > $3
+ AND used_count + $2 <= request_cap
+ RETURNING id`,
+ [reservationId, count, at]
+ );
+ if (result.rowCount !== 1) {
+ throw new Error("fingerprint_reservation_not_active");
+ }
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK").catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
+ },
+
+ async finish(reservationId, input) {
+ if (Number.isNaN(input.at.valueOf())) {
+ throw new RangeError("fingerprint_finish_time_invalid");
+ }
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ await lockFingerprintSweeps(client);
+ const reservation = await client.query<{
+ admission_id: string;
+ region: CharacterKey["region"];
+ realm_slug: string;
+ normalized_name: string;
+ }>(
+ `UPDATE fingerprint_sweep_reservations reservation
+ SET released_at = $2,
+ finished_at = $2,
+ published = $3,
+ limitation_code = $4
+ FROM fingerprint_sweep_admissions admission
+ WHERE reservation.id = $1
+ AND reservation.admission_id = admission.id
+ AND reservation.released_at IS NULL
+ RETURNING reservation.admission_id, admission.region,
+ admission.realm_slug, admission.normalized_name`,
+ [reservationId, input.at, input.published, input.limitationCode]
+ );
+ const row = reservation.rows[0];
+ if (!row) throw new Error("fingerprint_reservation_not_active");
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'finished'
+ WHERE id = $1`,
+ [row.admission_id]
+ );
+ if (input.published) {
+ await client.query(
+ `INSERT INTO fingerprint_sweep_states
+ (region, realm_slug, normalized_name, last_published_at)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (region, realm_slug, normalized_name)
+ DO UPDATE SET last_published_at = greatest(
+ fingerprint_sweep_states.last_published_at,
+ EXCLUDED.last_published_at
+ )`,
+ [row.region, row.realm_slug, row.normalized_name, input.at]
+ );
+ }
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK").catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
+ },
+
+ async release(reservationId, at) {
+ if (Number.isNaN(at.valueOf())) {
+ throw new RangeError("fingerprint_release_time_invalid");
+ }
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ await lockFingerprintSweeps(client);
+ const result = await client.query<{ admission_id: string }>(
+ `UPDATE fingerprint_sweep_reservations
+ SET released_at = $2
+ WHERE id = $1 AND released_at IS NULL
+ RETURNING admission_id`,
+ [reservationId, at]
+ );
+ const row = result.rows[0];
+ if (!row) throw new Error("fingerprint_reservation_not_active");
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'released'
+ WHERE id = $1`,
+ [row.admission_id]
+ );
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK").catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
+ },
+
+ async listWaiting(limit) {
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1_000) {
+ throw new RangeError("fingerprint_waiting_limit_out_of_range");
+ }
+ const result = await pool.query<{ discovery_run_id: string }>(
+ `SELECT discovery_run_id
+ FROM fingerprint_sweep_admissions
+ WHERE status = 'waiting'
+ ORDER BY requested_at, queue_order
+ LIMIT $1`,
+ [limit]
+ );
+ return result.rows.map((row) => row.discovery_run_id);
+ }
+ },
+
negativeCache: {
async put(key, expiresAt) {
const client = await pool.connect();
diff --git a/packages/database/src/public-api.typecheck.ts b/packages/database/src/public-api.typecheck.ts
index 48a4135..e652edb 100644
--- a/packages/database/src/public-api.typecheck.ts
+++ b/packages/database/src/public-api.typecheck.ts
@@ -1,6 +1,8 @@
import {
createPostgresRepositories,
runMigrations,
+ type FingerprintAdmission,
+ type FingerprintSweepRepository,
type Repositories
} from ".";
@@ -12,5 +14,7 @@ import { schema } from ".";
void createPostgresRepositories;
void runMigrations;
void (undefined as Repositories | undefined);
+void (undefined as FingerprintAdmission | undefined);
+void (undefined as FingerprintSweepRepository | undefined);
void createDatabase;
void schema;
diff --git a/packages/database/src/repositories.ts b/packages/database/src/repositories.ts
index f02f630..4868447 100644
--- a/packages/database/src/repositories.ts
+++ b/packages/database/src/repositories.ts
@@ -7,7 +7,7 @@ import type { CharacterKey } from "@slashwho/domain";
export type CallerClass = "anonymous" | "bot";
export type DiscoverySource =
- "input" | "claimed" | "declared_main" | "profile_guess";
+ "input" | "claimed" | "declared_main" | "profile_guess" | "fingerprint";
export interface DiscoveryRun {
id: string;
@@ -123,6 +123,29 @@ export interface NegativeCacheRepository {
cleanupExpired(at?: Date): Promise;
}
+export type FingerprintAdmission =
+ | { kind: "not_due" }
+ | { kind: "waiting"; retryAt: Date }
+ | { kind: "admitted"; reservationId: string; requestCap: number };
+
+export interface FingerprintSweepRepository {
+ requestAdmission(input: {
+ runId: string;
+ key: CharacterKey;
+ requestCap: number;
+ hourlyBudget: number;
+ cadenceCutoff: Date;
+ at: Date;
+ }): Promise;
+ recordRequest(reservationId: string, count: number, at: Date): Promise;
+ finish(
+ reservationId: string,
+ input: { published: boolean; at: Date; limitationCode: string | null }
+ ): Promise;
+ release(reservationId: string, at: Date): Promise;
+ listWaiting(limit: number): Promise;
+}
+
export type SearchReservationResult =
| { kind: "active"; run: DiscoveryRun }
| { kind: "reserved"; run: DiscoveryRun }
@@ -167,4 +190,5 @@ export interface Repositories {
suppressions: SuppressionRepository;
rateLimits: RateLimitRepository;
negativeCache: NegativeCacheRepository;
+ fingerprintSweeps: FingerprintSweepRepository;
}
diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts
index 27015d4..6aa7da5 100644
--- a/packages/database/src/schema.ts
+++ b/packages/database/src/schema.ts
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import {
bigserial,
+ boolean,
check,
index,
integer,
@@ -39,7 +40,8 @@ export const discoverySource = pgEnum("discovery_source", [
"input",
"claimed",
"declared_main",
- "profile_guess"
+ "profile_guess",
+ "fingerprint"
]);
export const characters = pgTable(
@@ -214,3 +216,100 @@ export const negativeCharacterCache = pgTable(
index("negative_character_cache_expiry_idx").on(table.expiresAt)
]
);
+
+export const fingerprintSweepStates = pgTable(
+ "fingerprint_sweep_states",
+ {
+ region: text("region").notNull(),
+ realmSlug: text("realm_slug").notNull(),
+ normalizedName: text("normalized_name").notNull(),
+ lastPublishedAt: timestamp("last_published_at", {
+ withTimezone: true
+ })
+ },
+ (table) => [
+ primaryKey({
+ name: "fingerprint_sweep_states_pkey",
+ columns: [table.region, table.realmSlug, table.normalizedName]
+ })
+ ]
+);
+
+export const fingerprintSweepAdmissions = pgTable(
+ "fingerprint_sweep_admissions",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ queueOrder: bigserial("queue_order", { mode: "number" }).notNull(),
+ discoveryRunId: uuid("discovery_run_id")
+ .notNull()
+ .references(() => discoveryRuns.id, { onDelete: "cascade" }),
+ region: text("region").notNull(),
+ realmSlug: text("realm_slug").notNull(),
+ normalizedName: text("normalized_name").notNull(),
+ requestCap: integer("request_cap").notNull(),
+ hourlyBudget: integer("hourly_budget").notNull(),
+ cadenceCutoff: timestamp("cadence_cutoff", {
+ withTimezone: true
+ }).notNull(),
+ status: text("status").default("waiting").notNull(),
+ requestedAt: timestamp("requested_at", { withTimezone: true })
+ .defaultNow()
+ .notNull()
+ },
+ (table) => [
+ index("fingerprint_sweep_admissions_waiting_idx").on(
+ table.status,
+ table.requestedAt,
+ table.queueOrder
+ ),
+ index("fingerprint_sweep_admissions_root_idx").on(
+ table.region,
+ table.realmSlug,
+ table.normalizedName
+ ),
+ check(
+ "fingerprint_sweep_admissions_request_cap_check",
+ sql`${table.requestCap} > 0`
+ ),
+ check(
+ "fingerprint_sweep_admissions_hourly_budget_check",
+ sql`${table.hourlyBudget} > 0`
+ )
+ ]
+);
+
+export const fingerprintSweepReservations = pgTable(
+ "fingerprint_sweep_reservations",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ admissionId: uuid("admission_id")
+ .notNull()
+ .references(() => fingerprintSweepAdmissions.id, { onDelete: "cascade" }),
+ requestCap: integer("request_cap").notNull(),
+ usedCount: integer("used_count").default(0).notNull(),
+ admittedAt: timestamp("admitted_at", { withTimezone: true }).notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ releasedAt: timestamp("released_at", { withTimezone: true }),
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
+ published: boolean("published"),
+ limitationCode: text("limitation_code")
+ },
+ (table) => [
+ uniqueIndex("fingerprint_sweep_reservations_admission_idx").on(
+ table.admissionId
+ ),
+ index("fingerprint_sweep_reservations_expiry_idx").on(table.expiresAt),
+ check(
+ "fingerprint_sweep_reservations_request_cap_check",
+ sql`${table.requestCap} > 0`
+ ),
+ check(
+ "fingerprint_sweep_reservations_used_count_check",
+ sql`${table.usedCount} >= 0 AND ${table.usedCount} <= ${table.requestCap}`
+ ),
+ check(
+ "fingerprint_sweep_reservations_expiry_check",
+ sql`${table.expiresAt} > ${table.admittedAt}`
+ )
+ ]
+);
diff --git a/tests/integration/repositories.test.ts b/tests/integration/repositories.test.ts
index cae9862..a0e6d49 100644
--- a/tests/integration/repositories.test.ts
+++ b/tests/integration/repositories.test.ts
@@ -492,4 +492,131 @@ describe("PostgreSQL repositories", () => {
await repositories.rateLimits.countActive("sha256:active", now)
).toBe(1);
});
+
+ it("admits only the FIFO head when two caps would exceed the rolling budget", async () => {
+ // Break caught: later sweeps could jump the queue or oversubscribe the global hourly budget.
+ await pool.query(`TRUNCATE TABLE
+ fingerprint_sweep_reservations,
+ fingerprint_sweep_admissions,
+ fingerprint_sweep_states
+ CASCADE`);
+ const at = new Date("2026-08-10T12:00:00.000Z");
+ const firstKey = rootKey;
+ const secondKey = altKey;
+ const firstRun = await repositories.runs.createOrReuse(
+ firstKey,
+ "anonymous"
+ );
+ const secondRun = await repositories.runs.createOrReuse(
+ secondKey,
+ "anonymous"
+ );
+ const first = {
+ runId: firstRun.id,
+ key: firstKey,
+ requestCap: 3,
+ hourlyBudget: 5,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at
+ };
+ const second = { ...first, runId: secondRun.id, key: secondKey };
+
+ const admitted =
+ await repositories.fingerprintSweeps.requestAdmission(first);
+ expect(admitted).toMatchObject({ kind: "admitted", requestCap: 3 });
+ if (admitted.kind !== "admitted")
+ throw new Error("first_sweep_not_admitted");
+
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission(second)
+ ).resolves.toMatchObject({ kind: "waiting" });
+ await expect(
+ repositories.fingerprintSweeps.listWaiting(10)
+ ).resolves.toEqual([secondRun.id]);
+
+ await repositories.fingerprintSweeps.finish(admitted.reservationId, {
+ published: true,
+ at,
+ limitationCode: null
+ });
+
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission(second)
+ ).resolves.toMatchObject({ kind: "admitted", requestCap: 3 });
+ });
+
+ it("does not advance cadence or retain unused capacity after an aborted sweep", async () => {
+ // Break caught: aborts could consume future cadence or the entire unused reservation.
+ await pool.query(`TRUNCATE TABLE
+ fingerprint_sweep_reservations,
+ fingerprint_sweep_admissions,
+ fingerprint_sweep_states
+ CASCADE`);
+ const at = new Date("2026-08-10T12:00:00.000Z");
+ const run = await repositories.runs.createOrReuse(rootKey, "anonymous");
+ const input = {
+ runId: run.id,
+ key: rootKey,
+ requestCap: 5,
+ hourlyBudget: 8,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at
+ };
+ const admitted =
+ await repositories.fingerprintSweeps.requestAdmission(input);
+ expect(admitted).toMatchObject({ kind: "admitted" });
+ if (admitted.kind !== "admitted") throw new Error("sweep_not_admitted");
+
+ await repositories.fingerprintSweeps.recordRequest(
+ admitted.reservationId,
+ 3,
+ at
+ );
+ await repositories.fingerprintSweeps.release(admitted.reservationId, at);
+
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission({
+ ...input,
+ at: new Date("2026-08-10T12:01:00.000Z")
+ })
+ ).resolves.toMatchObject({ kind: "admitted", requestCap: 5 });
+ });
+
+ it("returns not due only after a published sweep within its cadence", async () => {
+ // Break caught: a partial, unpublished, or aborted sweep could suppress a later sweep.
+ await pool.query(`TRUNCATE TABLE
+ fingerprint_sweep_reservations,
+ fingerprint_sweep_admissions,
+ fingerprint_sweep_states
+ CASCADE`);
+ const at = new Date("2026-08-10T12:00:00.000Z");
+ const run = await repositories.runs.createOrReuse(rootKey, "anonymous");
+ const admitted = await repositories.fingerprintSweeps.requestAdmission({
+ runId: run.id,
+ key: rootKey,
+ requestCap: 1,
+ hourlyBudget: 2,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at
+ });
+ if (admitted.kind !== "admitted") throw new Error("sweep_not_admitted");
+ await repositories.fingerprintSweeps.finish(admitted.reservationId, {
+ published: true,
+ at,
+ limitationCode: null
+ });
+ await repositories.runs.fail(run.id, "upstream_unavailable");
+
+ const nextRun = await repositories.runs.createOrReuse(rootKey, "anonymous");
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission({
+ runId: nextRun.id,
+ key: rootKey,
+ requestCap: 1,
+ hourlyBudget: 2,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at: new Date("2026-08-10T12:01:00.000Z")
+ })
+ ).resolves.toEqual({ kind: "not_due" });
+ });
});
From c2da1bca029ea478195527686e9e9550f25c91fc Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 14:05:32 +0100
Subject: [PATCH 09/23] feat(worker): dispatch fingerprint admissions
---
apps/worker/src/runtime.test.ts | 84 ++++++-
apps/worker/src/runtime.ts | 29 +++
packages/database/src/index.ts | 2 +
.../database/src/postgres-repositories.ts | 228 +++++++++++++-----
packages/database/src/queue.test.ts | 76 +++++-
packages/database/src/queue.ts | 91 ++++++-
packages/database/src/repositories.ts | 7 +
tests/integration/repositories.test.ts | 49 ++++
8 files changed, 495 insertions(+), 71 deletions(-)
diff --git a/apps/worker/src/runtime.test.ts b/apps/worker/src/runtime.test.ts
index 73f833d..4020545 100644
--- a/apps/worker/src/runtime.test.ts
+++ b/apps/worker/src/runtime.test.ts
@@ -39,9 +39,13 @@ function runtimeFakes() {
) => Promise)
| undefined;
let maintenanceHandler: (() => Promise) | undefined;
+ let admissionHandler: ((runId: string) => Promise) | undefined;
const pendingDispatches: DiscoverCharacterJob[] = [];
const recoveredDispatches: string[] = [];
const enqueued: DiscoverCharacterJob[] = [];
+ const fingerprintAdmissions: string[] = [];
+ const waitingFingerprintRuns: string[] = [];
+ const admittedFingerprintRuns = new Set();
const queue: DiscoveryQueue = {
async start() {
queueReady = true;
@@ -50,12 +54,19 @@ function runtimeFakes() {
enqueued.push(payload);
return payload.runId;
},
+ async enqueueFingerprintAdmission(runId) {
+ fingerprintAdmissions.push(runId);
+ return runId;
+ },
async work(handler) {
workHandler = handler;
},
async scheduleMaintenanceCleanup(handler) {
maintenanceHandler = handler;
},
+ async workFingerprintAdmissions(handler) {
+ admissionHandler = handler;
+ },
async stop() {
queueReady = false;
},
@@ -91,7 +102,17 @@ function runtimeFakes() {
},
rateLimits: { cleanupExpired: cleanup.rateLimits },
negativeCache: { cleanupExpired: cleanup.negativeCache },
- suppressions: { cleanupExpired: cleanup.suppressions }
+ suppressions: { cleanupExpired: cleanup.suppressions },
+ fingerprintSweeps: {
+ async admitWaiting(runId: string) {
+ return admittedFingerprintRuns.has(runId)
+ ? { kind: "admitted" as const }
+ : { kind: "waiting" as const, retryAt: new Date() };
+ },
+ async listWaiting() {
+ return [...waitingFingerprintRuns];
+ }
+ }
} as unknown as Repositories;
const sleeps: number[] = [];
@@ -113,9 +134,13 @@ function runtimeFakes() {
handler,
migrations,
cleanup,
+ repositories,
pendingDispatches,
recoveredDispatches,
enqueued,
+ fingerprintAdmissions,
+ waitingFingerprintRuns,
+ admittedFingerprintRuns,
queue,
get connectionAttempts() {
return connectionAttempts;
@@ -129,6 +154,9 @@ function runtimeFakes() {
get maintenanceHandler() {
return maintenanceHandler;
},
+ get admissionHandler() {
+ return admissionHandler;
+ },
sleeps
};
}
@@ -227,6 +255,60 @@ describe("worker runtime", () => {
await runtime.stop();
});
+ it("registers the private admission worker and re-enqueues only admitted discovery runs", async () => {
+ // Break caught: waiting fingerprint sweeps could consume discovery delivery attempts before budget admission.
+ const fakes = runtimeFakes();
+ const waitingRunId = "00000000-0000-4000-8000-000000000012";
+ const key = { region: "eu" as const, realm: "silvermoon", name: "waiting" };
+ fakes.waitingFingerprintRuns.push(waitingRunId);
+ fakes.admittedFingerprintRuns.add(waitingRunId);
+ const existingRun = {
+ id: waitingRunId,
+ rootKey: key,
+ rootCharacterId: null,
+ queueJobId: null,
+ status: "queued" as const,
+ callerClass: "anonymous" as const,
+ attempt: 0,
+ nextRetryAt: null,
+ errorCode: null,
+ createdAt: new Date(),
+ startedAt: null,
+ completedAt: null,
+ snapshotId: null
+ };
+ fakes.repositories.runs = {
+ async find(runId: string) {
+ return runId === waitingRunId ? existingRun : null;
+ }
+ } as Repositories["runs"];
+
+ const runtime = await createWorkerRuntime(config, fakes.dependencies);
+
+ expect(fakes.fingerprintAdmissions).toEqual([waitingRunId]);
+ expect(fakes.admissionHandler).toBeTypeOf("function");
+ await fakes.admissionHandler?.(waitingRunId);
+ expect(fakes.enqueued).toEqual([{ runId: waitingRunId, key }]);
+ expect(fakes.handler.execute).not.toHaveBeenCalled();
+ await runtime.stop();
+ });
+
+ it("keeps a budget-blocked fingerprint run out of discovery work", async () => {
+ // Break caught: a waiting admission could be redispatched into a discovery worker before capacity exists.
+ const fakes = runtimeFakes();
+ const waitingRunId = "00000000-0000-4000-8000-000000000013";
+ fakes.waitingFingerprintRuns.push(waitingRunId);
+
+ const runtime = await createWorkerRuntime(config, fakes.dependencies);
+
+ await expect(fakes.admissionHandler?.(waitingRunId)).rejects.toMatchObject({
+ retryable: true
+ });
+ expect(fakes.enqueued).toEqual([]);
+ expect(fakes.handler.execute).not.toHaveBeenCalled();
+ await runtime.stop();
+ });
+
it("drops readiness before gracefully draining and closing PostgreSQL", async () => {
// Break caught: shutdown could close storage under an in-flight job.
const fakes = runtimeFakes();
diff --git a/apps/worker/src/runtime.ts b/apps/worker/src/runtime.ts
index d9eafe5..f1cb50b 100644
--- a/apps/worker/src/runtime.ts
+++ b/apps/worker/src/runtime.ts
@@ -57,6 +57,16 @@ const defaultDependencies: WorkerRuntimeDependencies = {
new Promise((resolve) => setTimeout(resolve, milliseconds))
};
+function fingerprintAdmissionRetry(retryAt: Date): Error & {
+ retryable: true;
+ retryAfterMs: number;
+} {
+ return Object.assign(new Error("fingerprint_admission_waiting"), {
+ retryable: true as const,
+ retryAfterMs: Math.max(1_000, retryAt.getTime() - Date.now())
+ });
+}
+
export async function createWorkerRuntime(
config: WorkerConfig,
dependencies: WorkerRuntimeDependencies = defaultDependencies,
@@ -94,6 +104,25 @@ export async function createWorkerRuntime(
});
await initializedQueue.start();
await recoverPendingSearches(repositories, initializedQueue);
+ await initializedQueue.workFingerprintAdmissions(async (runId) => {
+ const admission = await repositories.fingerprintSweeps.admitWaiting(
+ runId,
+ new Date()
+ );
+ if (admission.kind === "waiting") {
+ throw fingerprintAdmissionRetry(admission.retryAt);
+ }
+ if (admission.kind !== "admitted") return;
+
+ const run = await repositories.runs.find(runId);
+ if (!run) return;
+ await initializedQueue.enqueue({ runId, key: run.rootKey });
+ });
+ const waitingFingerprintRuns =
+ await repositories.fingerprintSweeps.listWaiting(100);
+ for (const runId of waitingFingerprintRuns) {
+ await initializedQueue.enqueueFingerprintAdmission(runId);
+ }
await initializedQueue.scheduleMaintenanceCleanup(async () => {
await cleanupExpired(repositories);
await recoverPendingSearches(repositories, initializedQueue);
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index 547cce3..2d3d714 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -4,6 +4,7 @@ export {
createDiscoveryQueue,
DiscoveryQueueStopTimeoutError,
discoverCharacterQueueName,
+ fingerprintAdmissionQueueName,
maintenanceCleanupQueueName
} from "./queue";
export type {
@@ -18,6 +19,7 @@ export type {
DiscoveryRun,
DiscoverySource,
FingerprintAdmission,
+ FingerprintAdmissionDispatch,
FingerprintSweepRepository,
NegativeCacheEntry,
NegativeCacheRepository,
diff --git a/packages/database/src/postgres-repositories.ts b/packages/database/src/postgres-repositories.ts
index bf6bb35..6e89939 100644
--- a/packages/database/src/postgres-repositories.ts
+++ b/packages/database/src/postgres-repositories.ts
@@ -5,6 +5,7 @@ import type {
CallerClass,
DiscoveryRun,
FingerprintAdmission,
+ FingerprintAdmissionDispatch,
Repositories,
SnapshotHistoryItem,
SnapshotHistoryPage,
@@ -105,6 +106,74 @@ async function fingerprintRetryAt(client: Queryable, at: Date): Promise {
return result.rows[0]?.retry_at ?? at;
}
+async function admitFingerprintWaitingRun(
+ client: Queryable,
+ admissionId: string,
+ at: Date
+): Promise> {
+ const head = await client.query<{
+ id: string;
+ request_cap: number;
+ hourly_budget: number;
+ }>(
+ `SELECT admission.id, admission.request_cap, admission.hourly_budget
+ FROM fingerprint_sweep_admissions admission
+ LEFT JOIN fingerprint_sweep_states state
+ ON state.region = admission.region
+ AND state.realm_slug = admission.realm_slug
+ AND state.normalized_name = admission.normalized_name
+ WHERE admission.status = 'waiting'
+ AND (
+ state.last_published_at IS NULL
+ OR state.last_published_at <= admission.cadence_cutoff
+ )
+ ORDER BY admission.requested_at, admission.queue_order
+ LIMIT 1
+ FOR UPDATE OF admission`
+ );
+ const candidate = head.rows[0];
+ if (!candidate || candidate.id !== admissionId) {
+ return { kind: "waiting", retryAt: await fingerprintRetryAt(client, at) };
+ }
+
+ const usage = await client.query<{ commitment: string }>(
+ `SELECT coalesce(sum(
+ used_count + CASE
+ WHEN released_at IS NULL THEN request_cap - used_count
+ ELSE 0
+ END
+ ), 0)::text AS commitment
+ FROM fingerprint_sweep_reservations
+ WHERE expires_at > $1`,
+ [at]
+ );
+ if (
+ Number(usage.rows[0]!.commitment) + candidate.request_cap >
+ candidate.hourly_budget
+ ) {
+ return { kind: "waiting", retryAt: await fingerprintRetryAt(client, at) };
+ }
+
+ const reservation = await client.query<{ id: string }>(
+ `INSERT INTO fingerprint_sweep_reservations
+ (admission_id, request_cap, admitted_at, expires_at)
+ VALUES ($1, $2, $3::timestamptz, $3::timestamptz + interval '1 hour')
+ RETURNING id`,
+ [admissionId, candidate.request_cap, at]
+ );
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'admitted'
+ WHERE id = $1`,
+ [admissionId]
+ );
+ return {
+ kind: "admitted",
+ reservationId: reservation.rows[0]!.id,
+ requestCap: candidate.request_cap
+ };
+}
+
function mapRun(row: RunRow): DiscoveryRun {
return {
id: row.id,
@@ -936,6 +1005,32 @@ export function createPostgresRepositories(pool: Pool): Repositories {
await client.query("BEGIN");
await lockFingerprintSweeps(client);
+ const existingAdmission = await client.query<{
+ reservation_id: string;
+ request_cap: number;
+ }>(
+ `SELECT reservation.id AS reservation_id, reservation.request_cap
+ FROM fingerprint_sweep_admissions admission
+ JOIN fingerprint_sweep_reservations reservation
+ ON reservation.admission_id = admission.id
+ WHERE admission.discovery_run_id = $1
+ AND admission.status = 'admitted'
+ AND reservation.released_at IS NULL
+ ORDER BY admission.requested_at DESC
+ LIMIT 1
+ FOR UPDATE OF admission, reservation`,
+ [input.runId]
+ );
+ const existing = existingAdmission.rows[0];
+ if (existing) {
+ await client.query("COMMIT");
+ return {
+ kind: "admitted",
+ reservationId: existing.reservation_id,
+ requestCap: existing.request_cap
+ };
+ }
+
const state = await client.query<{ last_published_at: Date | null }>(
`SELECT last_published_at
FROM fingerprint_sweep_states
@@ -999,72 +1094,13 @@ export function createPostgresRepositories(pool: Pool): Repositories {
admissionId = admission.rows[0]!.id;
}
- const head = await client.query<{
- id: string;
- request_cap: number;
- hourly_budget: number;
- }>(
- `SELECT admission.id, admission.request_cap, admission.hourly_budget
- FROM fingerprint_sweep_admissions admission
- LEFT JOIN fingerprint_sweep_states state
- ON state.region = admission.region
- AND state.realm_slug = admission.realm_slug
- AND state.normalized_name = admission.normalized_name
- WHERE admission.status = 'waiting'
- AND (
- state.last_published_at IS NULL
- OR state.last_published_at <= admission.cadence_cutoff
- )
- ORDER BY admission.requested_at, admission.queue_order
- LIMIT 1
- FOR UPDATE OF admission`
- );
- const candidate = head.rows[0];
- if (!candidate || candidate.id !== admissionId) {
- const retryAt = await fingerprintRetryAt(client, input.at);
- await client.query("COMMIT");
- return { kind: "waiting", retryAt };
- }
-
- const usage = await client.query<{ commitment: string }>(
- `SELECT coalesce(sum(
- used_count + CASE
- WHEN released_at IS NULL THEN request_cap - used_count
- ELSE 0
- END
- ), 0)::text AS commitment
- FROM fingerprint_sweep_reservations
- WHERE expires_at > $1`,
- [input.at]
- );
- if (
- Number(usage.rows[0]!.commitment) + candidate.request_cap >
- candidate.hourly_budget
- ) {
- const retryAt = await fingerprintRetryAt(client, input.at);
- await client.query("COMMIT");
- return { kind: "waiting", retryAt };
- }
-
- const reservation = await client.query<{ id: string }>(
- `INSERT INTO fingerprint_sweep_reservations
- (admission_id, request_cap, admitted_at, expires_at)
- VALUES ($1, $2, $3::timestamptz, $3::timestamptz + interval '1 hour')
- RETURNING id`,
- [admissionId, candidate.request_cap, input.at]
- );
- await client.query(
- `UPDATE fingerprint_sweep_admissions
- SET status = 'admitted'
- WHERE id = $1`,
- [admissionId]
+ const result = await admitFingerprintWaitingRun(
+ client,
+ admissionId,
+ input.at
);
await client.query("COMMIT");
- return {
- kind: "admitted",
- reservationId: reservation.rows[0]!.id,
- requestCap: candidate.request_cap
- };
+ return result;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
@@ -1208,6 +1244,70 @@ export function createPostgresRepositories(pool: Pool): Repositories {
[limit]
);
return result.rows.map((row) => row.discovery_run_id);
+ },
+
+ async admitWaiting(runId, at) {
+ if (Number.isNaN(at.valueOf())) {
+ throw new RangeError("fingerprint_admission_time_invalid");
+ }
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ await lockFingerprintSweeps(client);
+ const waiting = await client.query<{
+ id: string;
+ region: CharacterKey["region"];
+ realm_slug: string;
+ normalized_name: string;
+ cadence_cutoff: Date;
+ }>(
+ `SELECT id, region, realm_slug, normalized_name, cadence_cutoff
+ FROM fingerprint_sweep_admissions
+ WHERE discovery_run_id = $1 AND status = 'waiting'
+ ORDER BY requested_at, queue_order
+ LIMIT 1
+ FOR UPDATE`,
+ [runId]
+ );
+ const admission = waiting.rows[0];
+ if (!admission) {
+ await client.query("COMMIT");
+ return { kind: "settled" };
+ }
+
+ const state = await client.query<{ last_published_at: Date | null }>(
+ `SELECT last_published_at
+ FROM fingerprint_sweep_states
+ WHERE region = $1 AND realm_slug = $2 AND normalized_name = $3`,
+ [admission.region, admission.realm_slug, admission.normalized_name]
+ );
+ if (
+ state.rows[0]?.last_published_at &&
+ state.rows[0].last_published_at > admission.cadence_cutoff
+ ) {
+ await client.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET status = 'not_due'
+ WHERE id = $1`,
+ [admission.id]
+ );
+ await client.query("COMMIT");
+ return { kind: "not_due" };
+ }
+
+ const result = await admitFingerprintWaitingRun(
+ client,
+ admission.id,
+ at
+ );
+ await client.query("COMMIT");
+ return result.kind === "admitted" ? { kind: "admitted" } : result;
+ } catch (error) {
+ await client.query("ROLLBACK").catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
}
},
diff --git a/packages/database/src/queue.test.ts b/packages/database/src/queue.test.ts
index a18bbb4..8f33be3 100644
--- a/packages/database/src/queue.test.ts
+++ b/packages/database/src/queue.test.ts
@@ -1,6 +1,41 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
-import { updateActiveRetryDelay } from "./queue";
+const queueFakes = vi.hoisted(() => {
+ const workers: Array<{
+ name: string;
+ handler: (jobs: Array<{ data: { runId: string } }>) => Promise;
+ }> = [];
+ return {
+ createQueue: vi.fn(async () => {}),
+ updateQueue: vi.fn(async () => {}),
+ send: vi.fn(async () => "job-id"),
+ start: vi.fn(async () => {}),
+ stop: vi.fn(async () => {}),
+ work: vi.fn(async (name, _options, handler) => {
+ workers.push({ name, handler });
+ }),
+ getDb: vi.fn(),
+ workers
+ };
+});
+
+vi.mock("pg-boss", () => ({
+ PgBoss: class {
+ start = queueFakes.start;
+ stop = queueFakes.stop;
+ createQueue = queueFakes.createQueue;
+ updateQueue = queueFakes.updateQueue;
+ send = queueFakes.send;
+ work = queueFakes.work;
+ getDb = queueFakes.getDb;
+ }
+}));
+
+import {
+ createDiscoveryQueue,
+ fingerprintAdmissionQueueName,
+ updateActiveRetryDelay
+} from "./queue";
describe("pg-boss retry delay update", () => {
it("fails safely when the active pg-boss row is not updated", async () => {
@@ -16,3 +51,40 @@ describe("pg-boss retry delay update", () => {
).rejects.toThrow("retry_delay_update_failed");
});
});
+
+describe("fingerprint admission queue", () => {
+ it("uses a per-run singleton job and delivers only its run id", async () => {
+ // Break caught: admission work could be duplicated or leak a discovery payload into the private queue.
+ const queue = createDiscoveryQueue({
+ connectionString: "postgres://worker:secret@database/slashwho"
+ });
+ const runId = "00000000-0000-4000-8000-000000000004";
+ const delivered: string[] = [];
+
+ await queue.start();
+ await queue.enqueueFingerprintAdmission(runId);
+ await queue.workFingerprintAdmissions(async (deliveredRunId) => {
+ delivered.push(deliveredRunId);
+ });
+
+ const worker = queueFakes.workers.find(
+ ({ name }) => name === fingerprintAdmissionQueueName
+ );
+ await worker?.handler([{ data: { runId } }]);
+
+ expect(queueFakes.createQueue).toHaveBeenCalledWith(
+ fingerprintAdmissionQueueName,
+ expect.any(Object)
+ );
+ expect(queueFakes.send).toHaveBeenCalledWith(
+ fingerprintAdmissionQueueName,
+ { runId },
+ { id: runId, singletonKey: runId }
+ );
+ expect(delivered).toEqual([runId]);
+
+ await queue.stop({ graceful: true, timeoutMs: 1 });
+ await worker?.handler([{ data: { runId } }]);
+ expect(delivered).toEqual([runId]);
+ });
+});
diff --git a/packages/database/src/queue.ts b/packages/database/src/queue.ts
index 8469a95..15bd892 100644
--- a/packages/database/src/queue.ts
+++ b/packages/database/src/queue.ts
@@ -3,12 +3,17 @@ import { PgBoss } from "pg-boss";
export const discoverCharacterQueueName = "discover-character";
export const maintenanceCleanupQueueName = "maintenance-cleanup";
+export const fingerprintAdmissionQueueName = "fingerprint-admission";
export type DiscoverCharacterJob = {
runId: string;
key: CharacterKey;
};
+type FingerprintAdmissionJob = {
+ runId: string;
+};
+
export type DiscoveryWorkContext = {
attempt: number;
maxAttempts: number;
@@ -27,12 +32,16 @@ export class DiscoveryQueueStopTimeoutError extends Error {
export interface DiscoveryQueue {
start(): Promise;
enqueue(payload: DiscoverCharacterJob): Promise;
+ enqueueFingerprintAdmission(runId: string): Promise;
work(
handler: (
payload: DiscoverCharacterJob,
context: DiscoveryWorkContext
) => Promise
): Promise;
+ workFingerprintAdmissions(
+ handler: (runId: string) => Promise
+ ): Promise;
scheduleMaintenanceCleanup(handler: () => Promise): Promise;
stop(options: { graceful: boolean; timeoutMs: number }): Promise;
isReady(): boolean;
@@ -50,7 +59,10 @@ const queueOptions = {
expireInSeconds: 1_800
} as const;
-function requestedRetryDelaySeconds(error: unknown): number | null {
+function requestedRetryDelaySeconds(
+ error: unknown,
+ maximumDelaySeconds: number = queueOptions.retryDelayMax
+): number | null {
if (
typeof error !== "object" ||
error === null ||
@@ -65,7 +77,7 @@ function requestedRetryDelaySeconds(error: unknown): number | null {
const retryDelaySeconds = error.retryAfterMs / 1_000;
return Number.isInteger(retryDelaySeconds) &&
retryDelaySeconds >= 1 &&
- retryDelaySeconds <= queueOptions.retryDelayMax
+ retryDelaySeconds <= maximumDelaySeconds
? retryDelaySeconds
: null;
}
@@ -80,7 +92,8 @@ type SqlExecutor = {
export async function updateActiveRetryDelay(
db: SqlExecutor,
jobId: string,
- retryDelaySeconds: number
+ retryDelaySeconds: number,
+ queueName = discoverCharacterQueueName
): Promise {
const result = await db.executeSql(
`UPDATE pgboss.job
@@ -90,7 +103,7 @@ export async function updateActiveRetryDelay(
AND name = $3
AND state = 'active'
RETURNING id`,
- [jobId, retryDelaySeconds, discoverCharacterQueueName]
+ [jobId, retryDelaySeconds, queueName]
);
if (result.rows.length !== 1) throw new Error("retry_delay_update_failed");
}
@@ -102,6 +115,8 @@ export function createDiscoveryQueue(
const inFlight = new Set>();
let ready = false;
let maintenanceRegistered = false;
+ let fingerprintAdmissionsRegistered = false;
+ let acceptingFingerprintAdmissions = false;
async function settleInFlight(timeoutMs: number): Promise {
const executions = [...inFlight];
@@ -127,6 +142,17 @@ export function createDiscoveryQueue(
await boss.start();
await boss.createQueue(discoverCharacterQueueName, queueOptions);
await boss.updateQueue(discoverCharacterQueueName, queueOptions);
+ await boss.createQueue(fingerprintAdmissionQueueName, {
+ retryLimit: 2_147_483_647,
+ retryDelay: 60,
+ expireInSeconds: 300
+ });
+ await boss.updateQueue(fingerprintAdmissionQueueName, {
+ retryLimit: 2_147_483_647,
+ retryDelay: 60,
+ expireInSeconds: 300
+ });
+ acceptingFingerprintAdmissions = true;
ready = true;
},
@@ -139,6 +165,19 @@ export function createDiscoveryQueue(
return id ?? payload.runId;
},
+ async enqueueFingerprintAdmission(runId) {
+ if (!ready) throw new Error("discovery_queue_not_ready");
+ const id = await boss.send(
+ fingerprintAdmissionQueueName,
+ { runId },
+ {
+ id: runId,
+ singletonKey: runId
+ }
+ );
+ return id ?? runId;
+ },
+
async work(handler) {
if (!ready) throw new Error("discovery_queue_not_ready");
await boss.work<
@@ -179,6 +218,48 @@ export function createDiscoveryQueue(
);
},
+ async workFingerprintAdmissions(handler) {
+ if (!ready) throw new Error("discovery_queue_not_ready");
+ if (fingerprintAdmissionsRegistered) return;
+ await boss.work<
+ FingerprintAdmissionJob,
+ void,
+ { pollingIntervalSeconds: number; includeMetadata: true }
+ >(
+ fingerprintAdmissionQueueName,
+ { pollingIntervalSeconds: 0.5, includeMetadata: true },
+ async ([job]) => {
+ if (!job || !acceptingFingerprintAdmissions) return;
+ const execution = (async () => {
+ try {
+ await handler(job.data.runId);
+ } catch (error) {
+ const retryDelaySeconds = requestedRetryDelaySeconds(
+ error,
+ 86_400
+ );
+ if (retryDelaySeconds !== null) {
+ await updateActiveRetryDelay(
+ boss.getDb(),
+ job.id,
+ retryDelaySeconds,
+ fingerprintAdmissionQueueName
+ );
+ }
+ throw error;
+ }
+ })();
+ inFlight.add(execution);
+ try {
+ await execution;
+ } finally {
+ inFlight.delete(execution);
+ }
+ }
+ );
+ fingerprintAdmissionsRegistered = true;
+ },
+
async scheduleMaintenanceCleanup(handler) {
if (!ready) throw new Error("discovery_queue_not_ready");
if (maintenanceRegistered) return;
@@ -217,6 +298,8 @@ export function createDiscoveryQueue(
async stop({ graceful, timeoutMs }) {
ready = false;
maintenanceRegistered = false;
+ fingerprintAdmissionsRegistered = false;
+ acceptingFingerprintAdmissions = false;
let stopError: unknown;
try {
await boss.stop({ graceful, timeout: timeoutMs });
diff --git a/packages/database/src/repositories.ts b/packages/database/src/repositories.ts
index 4868447..57a5811 100644
--- a/packages/database/src/repositories.ts
+++ b/packages/database/src/repositories.ts
@@ -128,6 +128,12 @@ export type FingerprintAdmission =
| { kind: "waiting"; retryAt: Date }
| { kind: "admitted"; reservationId: string; requestCap: number };
+export type FingerprintAdmissionDispatch =
+ | { kind: "admitted" }
+ | { kind: "waiting"; retryAt: Date }
+ | { kind: "not_due" }
+ | { kind: "settled" };
+
export interface FingerprintSweepRepository {
requestAdmission(input: {
runId: string;
@@ -144,6 +150,7 @@ export interface FingerprintSweepRepository {
): Promise;
release(reservationId: string, at: Date): Promise;
listWaiting(limit: number): Promise;
+ admitWaiting(runId: string, at: Date): Promise;
}
export type SearchReservationResult =
diff --git a/tests/integration/repositories.test.ts b/tests/integration/repositories.test.ts
index a0e6d49..90ee801 100644
--- a/tests/integration/repositories.test.ts
+++ b/tests/integration/repositories.test.ts
@@ -545,6 +545,55 @@ describe("PostgreSQL repositories", () => {
).resolves.toMatchObject({ kind: "admitted", requestCap: 3 });
});
+ it("admits a durable waiting run through private admission dispatch after budget frees", async () => {
+ // Break caught: waiting sweeps could need another discovery delivery instead of being admitted privately.
+ await pool.query(`TRUNCATE TABLE
+ fingerprint_sweep_reservations,
+ fingerprint_sweep_admissions,
+ fingerprint_sweep_states
+ CASCADE`);
+ const at = new Date("2026-08-10T12:00:00.000Z");
+ const firstRun = await repositories.runs.createOrReuse(
+ rootKey,
+ "anonymous"
+ );
+ const waitingRun = await repositories.runs.createOrReuse(
+ altKey,
+ "anonymous"
+ );
+ const first = {
+ runId: firstRun.id,
+ key: rootKey,
+ requestCap: 3,
+ hourlyBudget: 5,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at
+ };
+ const waiting = { ...first, runId: waitingRun.id, key: altKey };
+ const admitted =
+ await repositories.fingerprintSweeps.requestAdmission(first);
+ if (admitted.kind !== "admitted")
+ throw new Error("first_sweep_not_admitted");
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission(waiting)
+ ).resolves.toMatchObject({ kind: "waiting" });
+
+ await repositories.fingerprintSweeps.release(admitted.reservationId, at);
+
+ await expect(
+ repositories.fingerprintSweeps.admitWaiting(
+ waitingRun.id,
+ new Date("2026-08-10T12:01:00.000Z")
+ )
+ ).resolves.toEqual({ kind: "admitted" });
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission({
+ ...waiting,
+ at: new Date("2026-08-10T12:01:00.000Z")
+ })
+ ).resolves.toMatchObject({ kind: "admitted", requestCap: 3 });
+ });
+
it("does not advance cadence or retain unused capacity after an aborted sweep", async () => {
// Break caught: aborts could consume future cadence or the entire unused reservation.
await pool.query(`TRUNCATE TABLE
From 0e5353da95a55b311688854eaf9e92c11ab14eb6 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 14:13:03 +0100
Subject: [PATCH 10/23] fix(worker): recover fingerprint admission dispatch
---
apps/worker/src/runtime.test.ts | 72 ++++++++++++++++++-
apps/worker/src/runtime.ts | 33 ++++++---
.../0003_fingerprint_admission_dispatch.sql | 3 +
packages/database/drizzle/meta/_journal.json | 9 ++-
.../database/src/postgres-repositories.ts | 43 ++++++++++-
packages/database/src/repositories.ts | 4 +-
packages/database/src/schema.ts | 1 +
tests/integration/repositories.test.ts | 29 ++++++++
8 files changed, 178 insertions(+), 16 deletions(-)
create mode 100644 packages/database/drizzle/0003_fingerprint_admission_dispatch.sql
diff --git a/apps/worker/src/runtime.test.ts b/apps/worker/src/runtime.test.ts
index 4020545..65eb51f 100644
--- a/apps/worker/src/runtime.test.ts
+++ b/apps/worker/src/runtime.test.ts
@@ -46,6 +46,8 @@ function runtimeFakes() {
const fingerprintAdmissions: string[] = [];
const waitingFingerprintRuns: string[] = [];
const admittedFingerprintRuns = new Set();
+ const admittedUndispatchedFingerprintRuns: string[] = [];
+ const dispatchedFingerprintRuns: string[] = [];
const queue: DiscoveryQueue = {
async start() {
queueReady = true;
@@ -109,8 +111,16 @@ function runtimeFakes() {
? { kind: "admitted" as const }
: { kind: "waiting" as const, retryAt: new Date() };
},
- async listWaiting() {
- return [...waitingFingerprintRuns];
+ async listWaiting(limit: number, offset = 0) {
+ return waitingFingerprintRuns.slice(offset, offset + limit);
+ },
+ async listAdmittedUndispatched() {
+ return [...admittedUndispatchedFingerprintRuns];
+ },
+ async markDispatched(runId: string) {
+ dispatchedFingerprintRuns.push(runId);
+ const index = admittedUndispatchedFingerprintRuns.indexOf(runId);
+ if (index >= 0) admittedUndispatchedFingerprintRuns.splice(index, 1);
}
}
} as unknown as Repositories;
@@ -141,6 +151,8 @@ function runtimeFakes() {
fingerprintAdmissions,
waitingFingerprintRuns,
admittedFingerprintRuns,
+ admittedUndispatchedFingerprintRuns,
+ dispatchedFingerprintRuns,
queue,
get connectionAttempts() {
return connectionAttempts;
@@ -309,6 +321,62 @@ describe("worker runtime", () => {
await runtime.stop();
});
+ it("recovers an admitted fingerprint run that was not durably dispatched", async () => {
+ // Break caught: a process failure between admission and enqueue could strand a reserved sweep forever.
+ const fakes = runtimeFakes();
+ const runId = "00000000-0000-4000-8000-000000000014";
+ const key = {
+ region: "eu" as const,
+ realm: "silvermoon",
+ name: "admitted"
+ };
+ fakes.admittedUndispatchedFingerprintRuns.push(runId);
+ fakes.repositories.runs = {
+ async find(id: string) {
+ return id === runId
+ ? {
+ id: runId,
+ rootKey: key,
+ rootCharacterId: null,
+ queueJobId: null,
+ status: "queued" as const,
+ callerClass: "anonymous" as const,
+ attempt: 0,
+ nextRetryAt: null,
+ errorCode: null,
+ createdAt: new Date(),
+ startedAt: null,
+ completedAt: null,
+ snapshotId: null
+ }
+ : null;
+ }
+ } as Repositories["runs"];
+
+ const runtime = await createWorkerRuntime(config, fakes.dependencies);
+
+ expect(fakes.enqueued).toEqual([{ runId, key }]);
+ expect(fakes.dispatchedFingerprintRuns).toEqual([runId]);
+ await runtime.stop();
+ });
+
+ it("recovers every waiting fingerprint admission before readiness", async () => {
+ // Break caught: a fixed recovery batch could strand the 101st durable admission after a restart.
+ const fakes = runtimeFakes();
+ fakes.waitingFingerprintRuns.push(
+ ...Array.from(
+ { length: 101 },
+ (_unused, index) =>
+ `00000000-0000-4000-8000-${String(index + 100).padStart(12, "0")}`
+ )
+ );
+
+ const runtime = await createWorkerRuntime(config, fakes.dependencies);
+
+ expect(fakes.fingerprintAdmissions).toEqual(fakes.waitingFingerprintRuns);
+ await runtime.stop();
+ });
+
it("drops readiness before gracefully draining and closing PostgreSQL", async () => {
// Break caught: shutdown could close storage under an in-flight job.
const fakes = runtimeFakes();
diff --git a/apps/worker/src/runtime.ts b/apps/worker/src/runtime.ts
index f1cb50b..45311a3 100644
--- a/apps/worker/src/runtime.ts
+++ b/apps/worker/src/runtime.ts
@@ -104,6 +104,29 @@ export async function createWorkerRuntime(
});
await initializedQueue.start();
await recoverPendingSearches(repositories, initializedQueue);
+ const dispatchAdmittedFingerprintRun = async (runId: string) => {
+ const run = await repositories.runs.find(runId);
+ if (!run) return;
+ await initializedQueue.enqueue({ runId, key: run.rootKey });
+ await repositories.fingerprintSweeps.markDispatched(runId, new Date());
+ };
+ for (let offset = 0; ;) {
+ const waitingFingerprintRuns =
+ await repositories.fingerprintSweeps.listWaiting(100, offset);
+ for (const runId of waitingFingerprintRuns) {
+ await initializedQueue.enqueueFingerprintAdmission(runId);
+ }
+ if (waitingFingerprintRuns.length < 100) break;
+ offset += waitingFingerprintRuns.length;
+ }
+ for (;;) {
+ const admittedFingerprintRuns =
+ await repositories.fingerprintSweeps.listAdmittedUndispatched(100);
+ if (admittedFingerprintRuns.length === 0) break;
+ for (const runId of admittedFingerprintRuns) {
+ await dispatchAdmittedFingerprintRun(runId);
+ }
+ }
await initializedQueue.workFingerprintAdmissions(async (runId) => {
const admission = await repositories.fingerprintSweeps.admitWaiting(
runId,
@@ -113,16 +136,8 @@ export async function createWorkerRuntime(
throw fingerprintAdmissionRetry(admission.retryAt);
}
if (admission.kind !== "admitted") return;
-
- const run = await repositories.runs.find(runId);
- if (!run) return;
- await initializedQueue.enqueue({ runId, key: run.rootKey });
+ await dispatchAdmittedFingerprintRun(runId);
});
- const waitingFingerprintRuns =
- await repositories.fingerprintSweeps.listWaiting(100);
- for (const runId of waitingFingerprintRuns) {
- await initializedQueue.enqueueFingerprintAdmission(runId);
- }
await initializedQueue.scheduleMaintenanceCleanup(async () => {
await cleanupExpired(repositories);
await recoverPendingSearches(repositories, initializedQueue);
diff --git a/packages/database/drizzle/0003_fingerprint_admission_dispatch.sql b/packages/database/drizzle/0003_fingerprint_admission_dispatch.sql
new file mode 100644
index 0000000..943276b
--- /dev/null
+++ b/packages/database/drizzle/0003_fingerprint_admission_dispatch.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "fingerprint_sweep_admissions" ADD COLUMN "dispatched_at" timestamp with time zone;
+--> statement-breakpoint
+CREATE INDEX "fingerprint_sweep_admissions_dispatch_idx" ON "fingerprint_sweep_admissions" USING btree ("status","dispatched_at","requested_at","queue_order");
diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json
index 82b86b4..55eb1e8 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1786365831105,
"tag": "0002_fingerprint_sweeps",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1786371100000,
+ "tag": "0003_fingerprint_admission_dispatch",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/database/src/postgres-repositories.ts b/packages/database/src/postgres-repositories.ts
index 6e89939..153f8fa 100644
--- a/packages/database/src/postgres-repositories.ts
+++ b/packages/database/src/postgres-repositories.ts
@@ -163,7 +163,7 @@ async function admitFingerprintWaitingRun(
);
await client.query(
`UPDATE fingerprint_sweep_admissions
- SET status = 'admitted'
+ SET status = 'admitted', dispatched_at = NULL
WHERE id = $1`,
[admissionId]
);
@@ -1231,8 +1231,14 @@ export function createPostgresRepositories(pool: Pool): Repositories {
}
},
- async listWaiting(limit) {
- if (!Number.isInteger(limit) || limit < 1 || limit > 1_000) {
+ async listWaiting(limit, offset = 0) {
+ if (
+ !Number.isInteger(limit) ||
+ limit < 1 ||
+ limit > 1_000 ||
+ !Number.isInteger(offset) ||
+ offset < 0
+ ) {
throw new RangeError("fingerprint_waiting_limit_out_of_range");
}
const result = await pool.query<{ discovery_run_id: string }>(
@@ -1240,12 +1246,43 @@ export function createPostgresRepositories(pool: Pool): Repositories {
FROM fingerprint_sweep_admissions
WHERE status = 'waiting'
ORDER BY requested_at, queue_order
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+ return result.rows.map((row) => row.discovery_run_id);
+ },
+
+ async listAdmittedUndispatched(limit) {
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1_000) {
+ throw new RangeError(
+ "fingerprint_admission_dispatch_limit_out_of_range"
+ );
+ }
+ const result = await pool.query<{ discovery_run_id: string }>(
+ `SELECT discovery_run_id
+ FROM fingerprint_sweep_admissions
+ WHERE status = 'admitted' AND dispatched_at IS NULL
+ ORDER BY requested_at, queue_order
LIMIT $1`,
[limit]
);
return result.rows.map((row) => row.discovery_run_id);
},
+ async markDispatched(runId, at) {
+ if (Number.isNaN(at.valueOf())) {
+ throw new RangeError("fingerprint_admission_time_invalid");
+ }
+ await pool.query(
+ `UPDATE fingerprint_sweep_admissions
+ SET dispatched_at = $2
+ WHERE discovery_run_id = $1
+ AND status = 'admitted'
+ AND dispatched_at IS NULL`,
+ [runId, at]
+ );
+ },
+
async admitWaiting(runId, at) {
if (Number.isNaN(at.valueOf())) {
throw new RangeError("fingerprint_admission_time_invalid");
diff --git a/packages/database/src/repositories.ts b/packages/database/src/repositories.ts
index 57a5811..d2bd690 100644
--- a/packages/database/src/repositories.ts
+++ b/packages/database/src/repositories.ts
@@ -149,7 +149,9 @@ export interface FingerprintSweepRepository {
input: { published: boolean; at: Date; limitationCode: string | null }
): Promise;
release(reservationId: string, at: Date): Promise;
- listWaiting(limit: number): Promise;
+ listWaiting(limit: number, offset?: number): Promise;
+ listAdmittedUndispatched(limit: number): Promise;
+ markDispatched(runId: string, at: Date): Promise;
admitWaiting(runId: string, at: Date): Promise;
}
diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts
index 6aa7da5..ccc0546 100644
--- a/packages/database/src/schema.ts
+++ b/packages/database/src/schema.ts
@@ -252,6 +252,7 @@ export const fingerprintSweepAdmissions = pgTable(
withTimezone: true
}).notNull(),
status: text("status").default("waiting").notNull(),
+ dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
requestedAt: timestamp("requested_at", { withTimezone: true })
.defaultNow()
.notNull()
diff --git a/tests/integration/repositories.test.ts b/tests/integration/repositories.test.ts
index 90ee801..c9775b2 100644
--- a/tests/integration/repositories.test.ts
+++ b/tests/integration/repositories.test.ts
@@ -594,6 +594,35 @@ describe("PostgreSQL repositories", () => {
).resolves.toMatchObject({ kind: "admitted", requestCap: 3 });
});
+ it("keeps an admitted sweep dispatch-pending until its discovery job is durably enqueued", async () => {
+ // Break caught: a crash after budget reservation could lose a run before discovery is re-enqueued.
+ await pool.query(`TRUNCATE TABLE
+ fingerprint_sweep_reservations,
+ fingerprint_sweep_admissions,
+ fingerprint_sweep_states
+ CASCADE`);
+ const at = new Date("2026-08-10T12:00:00.000Z");
+ const run = await repositories.runs.createOrReuse(rootKey, "anonymous");
+ await expect(
+ repositories.fingerprintSweeps.requestAdmission({
+ runId: run.id,
+ key: rootKey,
+ requestCap: 3,
+ hourlyBudget: 5,
+ cadenceCutoff: new Date("2026-08-03T12:00:00.000Z"),
+ at
+ })
+ ).resolves.toMatchObject({ kind: "admitted" });
+
+ await expect(
+ repositories.fingerprintSweeps.listAdmittedUndispatched(10)
+ ).resolves.toEqual([run.id]);
+ await repositories.fingerprintSweeps.markDispatched(run.id, at);
+ await expect(
+ repositories.fingerprintSweeps.listAdmittedUndispatched(10)
+ ).resolves.toEqual([]);
+ });
+
it("does not advance cadence or retain unused capacity after an aborted sweep", async () => {
// Break caught: aborts could consume future cadence or the entire unused reservation.
await pool.query(`TRUNCATE TABLE
From 0b66dc0f9133da4e75f0b13187a0ba0c88dc8d09 Mon Sep 17 00:00:00 2001
From: Ryan Wong
Date: Mon, 10 Aug 2026 14:31:04 +0100
Subject: [PATCH 11/23] feat(application): merge fingerprint discovery
snapshots
---
apps/worker/src/logger.test.ts | 38 +++
apps/worker/src/logger.ts | 16 +-
apps/worker/src/runtime.test.ts | 40 +++
apps/worker/src/runtime.ts | 6 +
packages/application/package.json | 1 +
.../src/blizzard-fingerprint-adapter.ts | 22 ++
.../src/discovery-job-handler.test.ts | 323 +++++++++++++++++-
.../application/src/discovery-job-handler.ts | 228 +++++++++++--
packages/application/src/index.ts | 1 +
.../application/src/search-service.test.ts | 18 +
.../database/src/postgres-repositories.ts | 13 +-
pnpm-lock.yaml | 3 +
tests/integration/repositories.test.ts | 45 +++
13 files changed, 722 insertions(+), 32 deletions(-)
create mode 100644 packages/application/src/blizzard-fingerprint-adapter.ts
diff --git a/apps/worker/src/logger.test.ts b/apps/worker/src/logger.test.ts
index 3075a91..083dc02 100644
--- a/apps/worker/src/logger.test.ts
+++ b/apps/worker/src/logger.test.ts
@@ -67,4 +67,42 @@ describe("worker logger", () => {
expect(captured).toContain("[Circular]");
expect(captured).not.toContain(marker);
});
+
+ it("redacts every ephemeral fingerprint and credential marker", async () => {
+ // Break caught: diagnostic objects could serialize achievement material,
+ // access tokens, or comparison scores outside the handler allowlist.
+ const marker = "UNIQUE_FINGERPRINT_MARKER_414f8b";
+ const output = new PassThrough();
+ let captured = "";
+ output.on("data", (chunk) => {
+ captured += chunk.toString();
+ });
+ const logger = createWorkerLogger(output);
+
+ logger.info(
+ {
+ achievementId: marker,
+ achievementIds: marker,
+ achievementTimestamp: marker,
+ completionTimestamp: marker,
+ accessToken: marker,
+ refreshToken: marker,
+ fingerprint: marker,
+ fingerprintScore: marker,
+ matchScore: marker,
+ identicalPercent: marker,
+ nested: {
+ achievements: marker,
+ timestamps: marker,
+ token: marker,
+ score: marker
+ }
+ },
+ "fingerprint_event"
+ );
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(captured).toContain("fingerprint_event");
+ expect(captured).not.toContain(marker);
+ });
});
diff --git a/apps/worker/src/logger.ts b/apps/worker/src/logger.ts
index 3f4a96e..2ecf187 100644
--- a/apps/worker/src/logger.ts
+++ b/apps/worker/src/logger.ts
@@ -34,7 +34,21 @@ const sensitiveKeys = new Set([
"validationname",
"rawurl",
"rawpayload",
- "rawupstreampayload"
+ "rawupstreampayload",
+ "achievementid",
+ "achievementids",
+ "achievements",
+ "achievementtimestamp",
+ "completiontimestamp",
+ "timestamps",
+ "accesstoken",
+ "refreshtoken",
+ "token",
+ "fingerprint",
+ "fingerprintscore",
+ "matchscore",
+ "identicalpercent",
+ "score"
]);
function sanitize(value: unknown, visited = new WeakSet
+
Fingerprint-derived links
+
+ Privacy-hidden Raider.IO ownership is excluded from fingerprint-derived
+ links. Public alt lists do not disclose the discovery method for any
+ character relationship.
+