6.0 KiB
Architecture
How STTracker is built today. Update in the same commit as any structural change.
Overview
Status 2026-10-30: only the workspace skeleton exists (root package.json, tsconfig.json).
The target design is in docs/specs/001-sttracker-v1-design.md §11. Replace each "planned" marker below as the code lands.
One Bun process serves the REST API, the React web app and plugin ingest endpoints, and runs a
database-backed job worker. Source plugins pull data from platforms and return normalised SourceRecords.
The core snapshots them, diffs against the previous run, matches them to works, and either applies them to the
user layer or files a Changes-inbox event.
Map
| Path | Responsibility | State |
|---|---|---|
apps/server |
main.ts boots (config → createDb → migrateToLatest → loadPlugins → worker + scheduler + daily backup → Bun.serve). app.ts createApp(deps) = /api/v1 route table + /ingest/* mount (404 in Phase 1), testable via app.fetch. http.ts = route(), auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. routes/<area>.ts one file per area; openapi.ts builds /api/v1/openapi.json from the table |
exists |
apps/web |
React app (HTML imports); index.html is a placeholder until Task 17 |
placeholder |
packages/core |
domain services, one module per area (import by path, no barrel): auth, crypto, works, matching, entries (entries, library listing, quick add, per-user moves, match suggestions), engagements, history (user_history, restore), lists, copies, custom-fields, attachments, export (per-user bundle, CSV, restore), backup (scheduled bundles + SQLite VACUUM INTO), sync/ (the change engine: snapshot hashing and versions, apply per-record diff and apply, run = runSync, changes = Changes inbox listChanges/resolveChange, listens = ingestListens), jobs (queue, worker, scheduler), registry (loadPlugins), connections (CRUD, plugin context, syncHandler) |
exists (partial) |
packages/db |
Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, forEachDialect test harness |
exists (schema pending) |
packages/plugin-sdk |
plugin contract, SourceRecord, http/rate-limit helpers, fixture test harness |
planned |
plugins/<id> |
one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
docs/design/mockups |
approved UI mockups (reference only) | exists |
Data flow
HTTP request (apps/server):
Bun.serveroutes/api/*and/ingest/*toapp.fetch; everything else gets the web app (HTML import).apiHandlermatches method + path in the route table, applies the per-IP rate limit (login/register), authenticates (sessioncookiestt_session, Bearer app token with a scope, or either), requiresX-Requested-With: sttrackeron every non-Bearer write (login included), reads the body (JSON ≤ 1 MB withContent-Type: application/json, multipart ≤ 50 MB) and validates it with zod.- The handler calls one core function with the caller's
userIdand actor (token:<name>for Bearer, else"user";field_sourcesstores both as"user"). Core enforces ownership (NotFoundError→ 404). Errors become{ error: { code, message } }; unknown errors are logged and return 500internal. POST sources/:id/run|importonly enqueue asyncjob (lockKeysync:<id>, 409 when held); the worker runs it.
Sync lifecycle (packages/core/src/sync/; steps 1–2 are jobs.ts and connections.ts):
- The scheduler enqueues a
syncjob (lockKeysync:<connectionId>) for each non-paused connection that is due. - The worker claims the job;
syncHandlerloads the connection and plugin and callsrunSync(deps, plugin, connectionId, mode), injectingmakeContext(http client, credentials, cursor).modepickspull,pullChangesorimportFile. runSyncopens asource_runsrow and iterates the plugin'sSourceRecords one at a time.listenrecords are batched intoingestListens(insert-or-ignore intolistens; no snapshot, no events).- Every other record goes through
applyRecord: hash its normalised form; same hash assource_records.current_hash→ onlylast_seenmoves. Otherwise a newsource_record_versionsrow (gzipped{normalized, raw}) is written, and:- new record:
matchWork(own transaction, result committed on the record row so retries reuse it), then in one apply transaction create/patch the entry, engagement (de-duplicating plays across sources) or list, and write anaddedauto_appliedevent; - changed record: fields that differ from the previous version are decided against
field_sources→ applied (changedauto_applied) or filed aspendingchanged/conflictevents. Mapped records are updated throughsource_records.target_*, never re-matched; deleted: true→pendingremoved; a newmergedIntoKey→pendingmerged.
- new record:
- After a
fullpull that completed and applied every record, records not seen are marked removed (source_records.deleted_at+pendingremoved). - The run's counts, log and error land in
source_runs; the connection getslast_run_atandstatus. - The user resolves
pendingevents withresolveChange(accept / ignore / keep_mine). - Every user-layer write appends to
user_historywith the plugin actorplugin:<pluginId>:<connectionId>.
External dependencies
- Database: SQLite file under
DATA_DIR(default) or Postgres viaDATABASE_URL. If unavailable, nothing works. - Platform APIs (Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm). If unavailable, only that source's sync fails; the run is logged and retried.
Invariants
- A sync never overwrites or hard-deletes user-entered data (spec §8.3).
- Plugins never touch the database.
- All domain behaviour is identical on SQLite and Postgres.
Known rough edges
None yet. See docs/TODO.md.