Files
Torn-Vault-Tracker/docs/superpowers/specs/2026-08-03-torn-api-v2-migration-design.md
Keith Solomon c2b138d687 Add spec: Torn API v1 to v2 migration design
- Compares old (api-sample.json) and new (api-sample-new.json) shapes
- Specifies Authorization: ApiKey header instead of ?key= query param
- Specifies following _metadata.links.next for pagination
- Specifies entry.id (string) as PRIMARY KEY with INSERT ... ON CONFLICT DO NOTHING
- Adds backfillUserLogs (replaces firstRun) and syncUserLogs (renamed from getLog)
- Adds shared fetchAndStoreLogPage helper
- Adds golden-file unit test (no live API)
2026-08-03 12:42:19 -05:00

12 KiB

Torn API v1 → v2 Migration Design

Date: 2026-08-03 Project: Torn Vault Tracker Status: Approved — pending implementation

Problem

The Torn City API has moved from v1 (api.torn.com/user/) to v2 (api.torn.com/v2/user). The two protocols return different JSON shapes and use different authentication:

Concern v1 (old) v2 (new)
Endpoint https://api.torn.com/user/?selections=log&…&key=APIKEY https://api.torn.com/v2/user?selections=log&… + Authorization: ApiKey KEY
log shape Object keyed by entry id Flat array of entry objects
Entry id $entry['log'] (numeric event type — duplicated across entries) $entry['id'] (string, unique per entry)
title location $entry['title'] (root) $entry['details']['title']
timestamp location $entry['timestamp'] (root) $entry['timestamp'] (root, unchanged)
Pagination &to=$ts, &from=$ts _metadata.links.next URL with from/to/sort/limit params

The project was already partially migrated (firstRun() in functions.php:72 calls the v2 endpoint) but still reads the v1 JSON shape and v1 authentication, so firstRun() does not work. getLog() still uses the v1 endpoint entirely. Both code paths need to be brought onto v2 — a single coherent rewrite — and the helpers in includes/utilities.php (which both rely on) need to match.

Sample files live at backup/api-sample.json (v1, 2 entries) and backup/api-sample-new.json (v2, 100 entries with _metadata).

Goals

  • A single v2 path that handles both bootstrap (full history) and steady-state (recent sync).
  • Idempotent re-runs: re-fetching the same data does not produce duplicate rows.
  • Existing users with a populated vault.db come through the migration without manual intervention — only an empty DB triggers the full bootstrap.
  • New code is covered by a unit-style golden-file test that doesn't touch the network.

Non-Goals

  • Adding new API endpoints beyond user/selections=log&log=5850,5851.
  • Changing the local DB schema (table shape is already v2-compatible thanks to id TEXT PRIMARY KEY).
  • UI changes — index.php, style.css, script.js stay identical.
  • README updates — content already describes current behavior accurately.

Architecture

index.php
├── config.php
├── functions.php        (high-level: backfillUserLogs, syncUserLogs, DB, UI helpers)
└── includes/
    ├── utilities.php    (low-level: API + DB helpers; all v2-aware)
    └── exceptions.php   (unchanged)

The two-phase flow on each request:

  1. Bootstrap (empty DB) → backfillUserLogs($user) paginates full history.
  2. Steady state (DB populated) → syncUserLogs($user) fetches only newer entries.

Both call one shared fetchAndStoreLogPage() helper that performs a single page pull, stores rows idempotently, and returns either the next _metadata.links.next URL or null.

Components and Responsibilities

includes/exceptions.php — unchanged

Existing ApiKeyMissingException, CurlErrorException, JsonDataException, ApiValidationException cover all the failure modes we anticipate. Add one optional exception:

  • LogEntryIncompleteException — raised when a fetched entry is missing data.deposited or data.withdrawn (defensive — shouldn't occur given we filter to log=5850,5851, but we shouldn't silently insert garbage if the API ever changes).

includes/utilities.php — rewritten

Function Change
executeApiCall(string $url, string $apiKey): array Rewrite. Drop Content-Type: application/json; add Authorization: ApiKey <apiKey>. Accepts a complete URL (so callers can pass through _metadata.links.next). If $apiKey is empty, throws ApiKeyMissingException. The apiKey is supplied by the caller (fetchAndStoreLogPage looks it up from USER_KEYS[$user]); this keeps executeApiCall agnostic about which user is being fetched.
validateApiResponse(array) Tighten: log must exist and be an array (not an object-keyed map).
processLogEntries(array $logEntries, string $user, …) Rewrite for v2 shape. Read $entry['id'] for the PK, $entry['timestamp'], $entry['details']['title'], $entry['data']['deposited'] (deposit) or $entry['data']['withdrawn'] (withdraw). Use INSERT … ON CONFLICT(id) DO NOTHING so the operation is idempotent. Skip (and log) any entry whose data block lacks both fields rather than inserting bad data.
fetchAndStoreLogPage(PDO $pdo, string $user, string $url): ?string New. Composes executeApiCall + validateApiResponse + processLogEntries for a single page. Looks the API key up via USER_KEYS[$user]. Returns $data['_metadata']['links']['next'] ?? null.
dbNew(), ensureUserHasApiKey(), fetchVaultRecords(), vaultLoop(), getSign(), consoleLog() Unchanged.

functions.php — rewritten entry points

Function Change
getDatabaseConnection() Unchanged.
backfillUserLogs(string $user): void New (replaces firstRun). Starts at INITIAL_URL = "https://api.torn.com/v2/user?selections=log&log=5850,5851", paginates with _metadata.links.next until exhausted.
syncUserLogs(string $user): void Renamed from getLog. Uses the v2 endpoint with &from=<last_seen_ts+1> query param (only if the user has rows in the DB — otherwise delegates to backfillUserLogs). Falls back to following _metadata.links.next if more than one page comes back.
generateBalance(), getSpace(), buildTable() Unchanged — they read from the DB, schema is unchanged.

index.php — one-line swap

Replace firstRunbackfillUserLogs and getLogsyncUserLogs. Logic for "is DB empty?" already lives in dbNew(), so the control flow is identical.

config.php — unchanged

USER_KEYS map keyed by user already provides both API keys; the migration uses them both.

Data Flow

Bootstrap (empty DB)

backfillUserLogs(user) {
    url = "https://api.torn.com/v2/user?selections=log&log=5850,5851"
    do {
        next = fetchAndStoreLogPage($pdo, $user, $url)
        url  = next
    } while (next !== null)
}

fetchAndStoreLogPage(pdo, user, url): ?string {
    headers = ["Authorization: ApiKey " . USER_KEYS[user]]
    data    = executeApiCall(url, USER_KEYS[user])   // throws on curl/JSON failure
    validateApiResponse(data)                     // throws on shape error

    processLogEntries(data['log'], user, …)       // INSERT ... ON CONFLICT DO NOTHING
    return data['_metadata']['links']['next'] ?? null
}

Steady state (DB populated)

syncUserLogs(user) {
    if (dbNew()) return backfillUserLogs(user)         // safety net

    last = $pdo->query("SELECT MAX(timestamp) FROM vault WHERE user = ?")
    from = (int)last + 1
    url  = "https://api.torn.com/v2/user?selections=log&log=5850,5851&from=$from"

    do {
        next = fetchAndStoreLogPage($pdo, $user, $url)
        url  = next
    } while (next !== null)
}

Data Model

The vault table (defined in functions.php:32-38) already matches our needs:

CREATE TABLE IF NOT EXISTS vault (
    id          TEXT PRIMARY KEY,    -- v2 entry id, e.g. "j8EzaeOu2lpWPcloqGYJ"
    user        TEXT NOT NULL,
    timestamp   INTEGER NOT NULL,
    description TEXT NOT NULL,
    amount      REAL NOT NULL
);

Migration concern: existing rows were inserted with $key from the v1 object-keyed log, which means the current PK is the v1 alphanumeric object key (e.g. "CTczoijHAKhnfAeYmagC"). The v2 PK is the v2 unique id (e.g. "j8EzaeOu2lpWPcloqGYJ"). Format-wise both are strings, so the schema is compatible — but the values do not match and v2 inserts will collide on the PK only if the same id repeats, which it won't across the two schemes. Wiping data/vault.db on the first page load after deploy gives the cleanest result (recommended in install note). For users with an existing DB, the bootstrap path will simply keep fetching — v2 ids that already exist (by coincidence — vanishingly unlikely between v1 object keys and v2 entry ids) will be skipped via ON CONFLICT(id) DO NOTHING.

Error Handling

Failure Behavior
Missing API key for a user ensureUserHasApiKey() throws ApiKeyMissingException before any fetch.
curl_exec returns false executeApiCall throws CurlErrorException. The bootstrap loop stops; index page renders whatever was stored so far.
json_decode fails executeApiCall throws JsonDataException. Same as above.
Response missing log or wrong shape validateApiResponse throws ApiValidationException. Loop stops.
Entry lacks data.deposited or data.withdrawn processLogEntries raises LogEntryIncompleteException for that entry, skips it, continues with the rest. (Logged via the existing consoleLog and debug.log patterns.)
DB error Handled by PDO exception mode; propagates.

All exceptions are caught at the top of index.php's bootstrap path so a single bad page doesn't take down the whole UI. The user sees whatever data is in the DB plus a non-fatal warning.

Testing Strategy

A pure-PHP "golden file" test that does not hit the network:

  1. Load backup/api-sample-new.json.
  2. Mock executeApiCall to return the fixture ([api-sample-new.json content]).
  3. Mock processLogEntries's PDO statements (count execute() calls and capture params).
  4. Assert: every entry produces exactly one INSERT with the expected id, user, timestamp, description, amount; that the loop terminates when links.next is null; and that a fixture with links.next set to a sentinel URL causes exactly one extra fetch.

This lives next to phpcs.xml (no test framework added; we just add tests/process_log_entries_test.php and document how to run it via php tests/process_log_entries_test.php). The test is run manually before each deploy.

We do not add live API smoke tests — they require real keys and quota. The existing dbNew() path on index.php is the only "live" verification, and it runs only on the user's first page-load.

Files Touched

File Action
functions.php Rewrite the two log-fetch entry points; keep DB + UI helpers.
includes/utilities.php Rewrite executeApiCall, validateApiResponse, processLogEntries; add fetchAndStoreLogPage.
includes/exceptions.php Add LogEntryIncompleteException.
index.php Rename calls: firstRunbackfillUserLogs, getLogsyncUserLogs.
tests/process_log_entries_test.php New. Golden-file unit test for v2 parsing.
README.md Optional: note that data/vault.db will be re-populated on first page-load post-deploy.
config.php, style.css, script.js, backup/ Untouched.

Open Questions

None at design time. All clarifications resolved during brainstorming.

Acceptance Criteria

  1. getLog/syncUserLogs and firstRun/backfillUserLogs both call https://api.torn.com/v2/user with Authorization: ApiKey … header.
  2. processLogEntries reads id, timestamp, details.title, and data.deposited/data.withdrawn from each entry; ignores any other shape.
  3. Pagination follows _metadata.links.next until null.
  4. Re-running on the same data produces no duplicate rows.
  5. tests/process_log_entries_test.php passes against backup/api-sample-new.json.
  6. index.php, style.css, script.js, includes/exceptions.php (existing classes), config.php behave identically to before for end users — except that the data they fetch is now the v2 shape.