4.5 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 |
Bun.serve: /api/v1, /ingest/<plugin>, job worker, static web |
planned |
apps/web |
React app (HTML imports) | planned |
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), and 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
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), then in one apply transaction create/patch the entry or engagement (de-duplicating plays across sources) 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.