- 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)
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.dbcome 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.jsstay 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:
- Bootstrap (empty DB) →
backfillUserLogs($user)paginates full history. - 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 missingdata.depositedordata.withdrawn(defensive — shouldn't occur given we filter tolog=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 firstRun → backfillUserLogs and getLog → syncUserLogs. 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:
- Load
backup/api-sample-new.json. - Mock
executeApiCallto return the fixture ([api-sample-new.json content]). - Mock
processLogEntries's PDO statements (countexecute()calls and capture params). - Assert: every entry produces exactly one
INSERTwith the expectedid,user,timestamp,description,amount; that the loop terminates whenlinks.nextis null; and that a fixture withlinks.nextset 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: firstRun→backfillUserLogs, getLog→syncUserLogs. |
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
getLog/syncUserLogsandfirstRun/backfillUserLogsboth callhttps://api.torn.com/v2/userwithAuthorization: ApiKey …header.processLogEntriesreadsid,timestamp,details.title, anddata.deposited/data.withdrawnfrom each entry; ignores any other shape.- Pagination follows
_metadata.links.nextuntil null. - Re-running on the same data produces no duplicate rows.
tests/process_log_entries_test.phppasses againstbackup/api-sample-new.json.index.php,style.css,script.js,includes/exceptions.php(existing classes),config.phpbehave identically to before for end users — except that the data they fetch is now the v2 shape.