Chapter 3 of 13

Modeling the Database with Prisma 7

Once the workspace existed, the next commit was “feat: add Prisma integration and initial database schema.” This is where Gatify’s actual data model shows up, and where I hit the first real friction of the project: Prisma 7 had shipped some breaking changes since the last time I’d used it, and none of the tutorials I half-remembered still applied.

The schema

Four models cover everything: AdminUser for dashboard logins, ModelDeployment for registered Azure endpoints, VirtualKey for the app-scoped access tokens, and UsageLog for every request that goes through the gateway.

model ModelDeployment {
    id                          String            @id @default(uuid())
    alias                       String            @unique
    provider                    ProviderType
    endpoint                    String
    deploymentName              String            @map("deployment_name")
    apiKeyEncrypted             String            @map("api_key_encrypted")
    apiVersion                  String            @map("api_version")
    isActive                    Boolean           @default(true) @map("is_active")
    isFallback                  Boolean           @default(false) @map("is_fallback")
    fallbackForId               String?           @map("fallback_for_id")
    fallbackFor                 ModelDeployment?  @relation("DeploymentFallback", fields: [fallbackForId], references: [id])
    fallbacks                   ModelDeployment[] @relation("DeploymentFallback")
    inputPricePerMillionTokens  Decimal?          @map("input_price_per_million_tokens") @db.Decimal(12, 6)
    outputPricePerMillionTokens Decimal?          @map("output_price_per_million_tokens") @db.Decimal(12, 6)

    virtualKeys VirtualKeyModel[]
    usageLogs   UsageLog[]

    @@map("model_deployments")
}

The self-relation (fallbackForId pointing at another ModelDeployment) is there from the start, even though the fallback logic itself wasn’t wired up until the gateway chapter. I knew I wanted “if the primary Azure deployment throws a 429 or 500, retry against a designated backup deployment” as a feature, so I modeled it into the schema early rather than bolting it on with a migration later. apiKeyEncrypted is deliberately named with the Encrypted suffix in the column, not apiKey, so nobody (including future me skimming a query) mistakes it for plaintext.

VirtualKey doesn’t store the actual key anywhere, only a hash and a short visible prefix:

model VirtualKey {
    id           String           @id @default(uuid())
    keyHash      String           @unique @map("key_hash")
    keyPrefix    String           @map("key_prefix")
    label        String
    maxBudget    Decimal?         @map("max_budget") @db.Decimal(12, 4)
    budgetPeriod BudgetPeriod     @default(TOTAL) @map("budget_period")
    rateLimitRpm Int?             @map("rate_limit_rpm")
    rateLimitTpm Int?             @map("rate_limit_tpm")
    status       VirtualKeyStatus @default(ACTIVE)
    expiresAt    DateTime?        @map("expires_at")
    revokedAt    DateTime?        @map("revoked_at")

    allowedModels VirtualKeyModel[]
    usageLogs     UsageLog[]

    @@map("virtual_keys")
}

VirtualKeyModel is a plain join table between keys and deployments, which is how a key ends up scoped to only the models it’s allowed to call. UsageLog gets two composite indexes, [virtualKeyId, createdAt] and [modelDeploymentId, createdAt], because I knew from the start that the dashboard would need to query “usage for this key over time” and “usage for this model over time” and I didn’t want either of those to be a sequential scan once there’s real traffic.

Prisma 7 broke my muscle memory

I reached for the config I remembered from Prisma 5 and 6: a datasource db { url = env("DATABASE_URL") } block right in schema.prisma. Prisma 7 doesn’t allow that anymore. The URL now lives in a separate prisma.config.ts at the repo root:

import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: 'apps/api/prisma/schema.prisma',
  migrations: {
    path: 'apps/api/prisma/migrations',
    seed: 'tsx apps/api/prisma/seed.ts',
  },
  datasource: {
    url: env('DATABASE_URL'),
  },
});

That needed the dotenv package added just so env('DATABASE_URL') had something to read outside of a Prisma CLI context. Small thing, but not obvious if you’re going in expecting the old schema-level syntax.

The bigger change is that a driver adapter is now mandatory at runtime, not optional. The generator block also changed from the prisma-client-js I was used to:

generator client {
    provider               = "prisma-client"
    output                 = "../src/generated/prisma"
    moduleFormat           = "cjs"
    generatedFileExtension = "ts"
    importFileExtension    = ""
}

That importFileExtension = "" line is not decoration. I went digging because the generated client, when bundled through Nx’s webpack config, was hitting a known issue where Prisma 7’s generated imports don’t resolve cleanly under Webpack. Setting the extension to an empty string makes the generated file use bare imports instead of explicit .js extensions, which is the documented workaround. I found this by actually reading the open GitHub issue rather than guessing, and I want to be honest that I went back and forth on whether to fight this at the tooling level (switch bundlers) or just apply the workaround. I applied the workaround. It’s held up fine since.

With the adapter mandatory, PrismaService looks like this:

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  constructor(configService: ConfigService) {
    super({
      adapter: new PrismaPg({
        connectionString: configService.getOrThrow<string>('DATABASE_URL'),
      }),
    });
  }
}

And the seed script, which bootstraps the one admin user Gatify needs, pulls from the generated client directly rather than the @prisma/client package:

import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });

None of this is hard once you know it, but it cost me real time working out that the tutorial-shaped mental model I had for Prisma was two major versions stale. I added apps/api/src/generated/ to .gitignore immediately, since it’s build output, not something I want reviewed in a diff.

Next: the auth module, and why there’s deliberately no public “sign up” endpoint for the admin dashboard.