ADR-0060: API data write ownership enforced by an oxlint JS plugin
Status: Accepted
Date: 2026-10-19
Context
PrismaService is global, so any API module can write any table. The admin area writes
users, sessions, tracks, albums, releases, playlists, podcasts and moderation reports
directly, beside the modules that own those rules. A rule such as "what removing a track
means" then lives in two places and drifts. Nothing in the toolchain noticed: the API is
linted by Biome, which has no API for a rule that needs a module map and the file's path.
Decision
- Every Prisma model has an owning scope: a folder under
src/modules/, an admin area (admin/genres,admin/staff) or ansrc/infra/folder. The map lives inapps/api/oxlint/data-ownership.mjs. Writes (create*,update*,upsert,delete*) are restricted to the owner; reads stay open to every module. bitrate-api/prisma-write-ownership, an oxlint JS plugin inapps/api/oxlint/prisma-write-ownership.mjs, reports<client>.<model>.<write>(…)from any other scope. Matching is syntactic, sothis.prisma, a transactiontxand aliases are all covered. The model list is read from the Prisma schema (one file or a folder).- Existing violations are recorded in
apps/api/oxlint/data-ownership-baseline.json(per file and model). A file over its allowance fails; a file under it also fails until the entry is lowered, so the baseline only shrinks. It started at 27 writes in 11 files, all but one inadmin/*. - oxlint runs only this rule in the API (
apps/api/.oxlintrc.jsonturns every built-in category off). Biome remains the API's lint and format gate.pnpm --filter @bitrate/api lintruns both, and the API CI job runslint:data-ownershipafterbiome ci. apps/api/oxlint/data-ownership.test.mjs(node:test) fails when a schema model has no owner, an owner folder or baseline file disappears, or the rule's behaviour changes; it runs oxlint itself overoxlint/fixtures/.- Seeds (
src/infra/seeds/) and tests are exempt: fixtures legitimately write every table.
Consequences
- A new model needs an owner in the same change; a cross-module write needs the owner's service or an explicit, reviewed ownership change.
- Reducing the baseline is the migration path for admin: each admin area calls the domain service and its baseline entry is lowered or removed.
- Out of scope, because a syntactic rule cannot see them: nested relation writes inside
data(user.update({ data: { sessions: { create } } })),$executeRaw, and writes reached through a helper defined in the owner but called elsewhere. Review covers them. - The API now has two linters with disjoint jobs. oxlint adds one dev dependency and runs
in well under a second over
src/.
Alternatives considered
- ESLint with a local rule — works and has built-in bulk suppressions, but adds a second full linter stack to a Biome workspace; oxlint runs the same ESLint-style rule API with a much lighter footprint.
- Biome GritQL plugin — syntactic pattern only; it cannot read the ownership map or derive the module from the file path.
- Repository layer per module — the boundary would be a convention again, with no
check that other modules do not inject
PrismaServicedirectly.