ADR-0030 β Worker Database Access (Prisma Direct) β
Status: Accepted Date: 2026-08-09
Context β
Phase 3.2 builds the first real job in apps/worker (until now an empty shell): scryfall-sync. It composes the Phase 3.1 bricks β getBulkDataInfo β fetchBulkStream β streamNormalizedCards β and must persist the resulting Card / CardPrint / CardFace rows: tens of thousands of cards from Scryfall's default_cards bulk dump, ingested by streaming (never buffered whole).
Before writing persistence code, one architectural question must be settled: how does the worker write to the database? Two options:
- A β via the API: the worker sends normalized cards to
apps/apiover HTTP; the API performs the writes. This mirrors theapps/webboundary (ADR-0016: web never touchespackages/db, always goes through the API). - B β Prisma direct: the worker imports
packages/dband writes to Postgres itself.
The tension is with the standing rule "Prisma models never exposed outside the API" and ADR-0016 ("never import packages/db in apps/web"). Does that rule bind the worker the same way it binds the web app?
Decision β
The worker accesses the database directly via packages/db (Prisma) β option B.
The scryfall-sync job imports packages/db, normalizes cards with packages/scryfall (pure), and performs the batch upsert itself. The worker never goes through apps/api to persist reference data.
Two disciplines make this safe, and are non-negotiable for any worker code:
- The worker writes only reference tables β
Card,CardPrint,CardFace(and, later, sync-state / pricing reference tables). It never writes user-owned tables (users,collections,decks, β¦). Those remain the API's exclusive domain. - The worker orchestrates, it does not implement business rules. It composes pure bricks (
packages/scryfallnormalization) + Prisma writes. Any card domain logic lives inpackages/scryfallorpackages/domain, tested and pure β never inlined in the worker.
Rationale β
The rule "Prisma never exposed outside the API" bundles two distinct concerns, and neither applies to the worker:
- Prisma is server-only β it must never reach a client bundle. This is the entire reason
apps/webcannot importpackages/db. The worker is a server-side backend process; there is no client bundle to protect. - A stable DTO contract for external/HTTP consumers β anyone talking to the API over HTTP receives DTOs, not Prisma models. The worker is not an external HTTP consumer; it is a backend peer of the API, in the same trust domain.
packages/dbis explicitly a shared server-side package (ADR structure lists it as such), not one reserved toapps/api.
The mental model: the API and the worker are two coworkers inside the same secure office, both holding keys to the filing cabinet (the DB). apps/web is a customer at the front desk who must ask through the window (HTTP + DTOs). The rule protects the cabinet from the customer, not from a coworker standing next to it.
Routing the worker through the API would actively harm the design:
- It destroys the streaming architecture. Phase 3.1's whole point is to never buffer the dump β fetch β parse β normalize as a stream. Re-serializing ~30k normalized cards over HTTP to our own API throws that away, doubling memory and adding a pointless network hop.
- It creates a phantom endpoint. The API would need a bulk-ingest route called only by the worker β leaking a worker concern into the HTTP surface for no external consumer.
- Direct DB access is the canonical shape for ETL / batch ingestion (
createMany, transactions). This is exactly what a data-sync worker is for.
On the "two writers" concern: the anti-pattern to avoid is two services racing to write the same rows. That is not this case. Writes are partitioned by data domain: the API owns user data, the worker owns reference data (a global card catalogue, identical for every user, read-only from the API's side β the GET /cards read path arrives in Phase 3.3). This "dedicated ingestion pipeline feeding reference data, separate from the transactional API" is the standard, recommended architecture for catalogue ingestion β not a smell.
Trade-offs β
Benefits:
- Preserves the streaming design end-to-end (Scryfall β Postgres, nothing buffered whole).
- No phantom bulk-ingest endpoint on the API.
- Uses the right tool for batch ETL (direct Prisma:
createMany, transactions). - Clean domain partition: API = user data, worker = reference data.
Costs:
- A second database write-point exists in the system. Accepted because writes are partitioned by disjoint table sets β no shared-row contention.
Risks:
- Boundary erosion: a future worker job could be tempted to write user tables, or to inline card business rules. Mitigated by the two disciplines above; enforce them in review (a
worker-revieweror the existingdb-revieweron any workerβDB change). - Schema-change coupling: the worker now depends on the Prisma schema directly. Acceptable β so does the API; both are backend peers versioned in the same monorepo.
Consequences / Follow-ups β
apps/workergains a dependency onpackages/dbandpackages/scryfall.- The Prisma client must be generated before the worker runs (
packages/dbpostinstall already does this monorepo-wide). - Batch-upsert strategy (chunk size, upsert keys, transaction boundaries, idempotence, handling of cards that disappear from the dump) is a separate design step in Phase 3.2.
- BullMQ + Redis adoption and sync-state persistence (
SyncState) are settled in their own decisions (BullMQ/Redis β ADR-0031).
References β
- ADR-0016 β TanStack Start adoption (
apps/webnever importspackages/db; the boundary this ADR contrasts against) - ADR-0024 β Service-layer architecture (business rules live in
packages/services/ pure packages, not in orchestrators) - ADR-0029 β Multi-face card modeling (
Card/CardPrint/CardFaceschema the worker writes) - Phase 3.1 β
packages/scryfallbulk download client (the bricks the worker composes)