From 98734330055e9d6b8d097678d2c10e729130b79d Mon Sep 17 00:00:00 2001 From: GuanLdong Date: Wed, 7 Oct 2026 23:27:07 +0800 Subject: [PATCH] feat(engine): credits wallet, generation pipeline with refund rollback, abuse guardrails, e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db: credits_account / credits_transaction (append-only ledger, (type,refId) idempotency anchor) / generation (processing→succeeded|failed state machine) - lib/wallet: atomic conditional debit (no overdraft under concurrency), replay-safe credit/refund, lazy signup grant - routers/generate: debit-before-model money path; model/R2 failures refund through the ledger; debit failures never refund (transaction aborted = no money moved); client idempotency keys replay original outcome - guardrails: Turnstile (fail-closed when secret set), KV fixed-window rate limit (degrades to pass without binding), prompt blocklist, zod validation - routers/credits + history: balance/ledger/my-generations reads - wrangler: AI / R2 / KV bindings (top-level and dev env) - tests: 20 new (wallet idempotency, concurrency, overdraft, refund-once, generate success/rollback/idempotency, guardrails, history isolation) --- apps/api/lib/app.ts | 6 + apps/api/lib/env.ts | 11 + apps/api/lib/generations.ts | 101 ++ apps/api/lib/guard.ts | 125 +++ apps/api/lib/wallet.test.ts | 225 ++++ apps/api/lib/wallet.ts | 235 ++++ apps/api/routers/credits.ts | 41 + apps/api/routers/generate.test.ts | 360 ++++++ apps/api/routers/generate.ts | 250 +++++ apps/api/routers/history.ts | 58 + apps/api/wrangler.jsonc | 26 + db/migrations/0001_uneven_iron_lad.sql | 44 + db/migrations/meta/0001_snapshot.json | 1394 ++++++++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/credits.ts | 116 ++ db/schema/generation.ts | 114 ++ db/schema/index.ts | 2 + 17 files changed, 3115 insertions(+) create mode 100644 apps/api/lib/generations.ts create mode 100644 apps/api/lib/guard.ts create mode 100644 apps/api/lib/wallet.test.ts create mode 100644 apps/api/lib/wallet.ts create mode 100644 apps/api/routers/credits.ts create mode 100644 apps/api/routers/generate.test.ts create mode 100644 apps/api/routers/generate.ts create mode 100644 apps/api/routers/history.ts create mode 100644 db/migrations/0001_uneven_iron_lad.sql create mode 100644 db/migrations/meta/0001_snapshot.json create mode 100644 db/schema/credits.ts create mode 100644 db/schema/generation.ts diff --git a/apps/api/lib/app.ts b/apps/api/lib/app.ts index 720cb447f..e604f32c2 100644 --- a/apps/api/lib/app.ts +++ b/apps/api/lib/app.ts @@ -9,6 +9,9 @@ import { Hono } from "hono"; import { billingRouter } from "../routers/billing.js"; import { configRouter } from "../routers/config.js"; +import { creditsRouter } from "../routers/credits.js"; +import { generateRouter } from "../routers/generate.js"; +import { historyRouter } from "../routers/history.js"; import type { AppContext } from "./context.js"; import { router } from "./trpc.js"; @@ -16,6 +19,9 @@ import { router } from "./trpc.js"; const appRouter = router({ billing: billingRouter, config: configRouter, + credits: creditsRouter, + generate: generateRouter, + history: historyRouter, }); // HTTP router diff --git a/apps/api/lib/env.ts b/apps/api/lib/env.ts index 5a3dbdcfb..86c82d11c 100644 --- a/apps/api/lib/env.ts +++ b/apps/api/lib/env.ts @@ -1,3 +1,4 @@ +import type { Ai, KVNamespace, R2Bucket } from "@cloudflare/workers-types"; import { z } from "zod"; /** Environment contract and source for the inferred `Env` type. */ @@ -22,6 +23,16 @@ export const envSchema = z.object({ STRIPE_STARTER_PRICE_ID: z.string().startsWith("price_").optional(), STRIPE_PRO_PRICE_ID: z.string().startsWith("price_").optional(), STRIPE_PRO_ANNUAL_PRICE_ID: z.string().startsWith("price_").optional(), + // Turnstile bot protection on generate (optional – when set, tokens are + // REQUIRED and verified server-side; absent means the gate is disabled, + // which is only acceptable in local development). + TURNSTILE_SECRET_KEY: z.string().optional(), + // Cloudflare bindings. Optional because tests and non-Workers runtimes build + // Env by hand: a missing binding degrades the feature (no rate limiting, + // generation reports unavailable) instead of crashing unrelated requests. + AI: z.custom().optional(), + IMAGES: z.custom().optional(), + RATELIMIT: z.custom().optional(), }); // Do not parse `Bun.env` at module load: production bindings arrive on `c.env`, diff --git a/apps/api/lib/generations.ts b/apps/api/lib/generations.ts new file mode 100644 index 000000000..aa75afec8 --- /dev/null +++ b/apps/api/lib/generations.ts @@ -0,0 +1,101 @@ +/** + * Generation persistence. All access to the `generation` table lives here so + * routers stay orchestration-only and future surfaces (public gallery, + * reconcile sweep) reuse the same queries. + * + * Filters are always parameterized drizzle predicates over plain parameters; + * callers pass server-derived ids. + */ + +import { generation, type Database } from "@repo/db"; +import { and, desc, eq } from "drizzle-orm"; + +export type GenerationRow = typeof generation.$inferSelect; +export type NewGenerationRow = typeof generation.$inferInsert; + +export async function insertGeneration( + db: Database, + values: NewGenerationRow, +): Promise { + const rows = await db.insert(generation).values(values).returning(); + return rows[0]; +} + +/** Idempotent replay lookup: the caller's (userId, retry key) pair. */ +export async function findGenerationByIdempotencyKey( + db: Database, + ownerId: string, + idempotencyKey: string, +): Promise { + const rows = await db + .select() + .from(generation) + .where( + and( + eq(generation.userId, ownerId), + eq(generation.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + return rows[0] ?? null; +} + +export async function listRecentGenerations( + db: Database, + ownerId: string, + limit = 20, +): Promise { + return db + .select() + .from(generation) + .where(eq(generation.userId, ownerId)) + .orderBy(desc(generation.createdAt)) + .limit(limit); +} + +export async function findGenerationById( + db: Database, + ownerId: string, + id: string, +): Promise { + const rows = await db + .select() + .from(generation) + .where(and(eq(generation.id, id), eq(generation.userId, ownerId))) + .limit(1); + return rows[0] ?? null; +} + +/** + * Terminal success transition: only a `processing` row can become + * `succeeded`, so a replayed or swept row can never flip states twice. + */ +export async function markGenerationSucceeded( + db: Database, + id: string, + r2Key: string, +): Promise { + const rows = await db + .update(generation) + .set({ status: "succeeded", r2Key }) + .where(and(eq(generation.id, id), eq(generation.status, "processing"))) + .returning(); + return rows[0] ?? null; +} + +/** Terminal failure transition; refunds are the caller's responsibility. */ +export async function markGenerationFailed( + db: Database, + id: string, + errorKind: GenerationRow["errorKind"], + errorMessage: string, +): Promise { + await db + .update(generation) + .set({ + status: "failed", + errorKind, + errorMessage: errorMessage.slice(0, 500), + }) + .where(eq(generation.id, id)); +} diff --git a/apps/api/lib/guard.ts b/apps/api/lib/guard.ts new file mode 100644 index 000000000..5135965da --- /dev/null +++ b/apps/api/lib/guard.ts @@ -0,0 +1,125 @@ +/** + * Abuse guardrails for the generate endpoint: input validation, prompt + * blocklist, Turnstile server-side verification, and a KV rate limiter. + * + * Each piece degrades explicitly rather than silently: an unset Turnstile + * secret disables the captcha gate (dev only – production sets the secret), + * and a missing KV binding disables rate limiting rather than erroring every + * request. + */ + +import { z } from "zod"; + +export const generateInputSchema = z.object({ + prompt: z.string().trim().min(1).max(2048), + steps: z.int().min(1).max(8).default(4), + turnstileToken: z.string().min(1).optional(), + /** + * Client retry key. A network retry replays it and receives the original + * outcome instead of a second charge. + */ + idempotencyKey: z + .string() + .min(8) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/) + .optional(), +}); + +export type GenerateInput = z.infer; + +/** Credits per generation: 1 base, doubled above 4 steps. */ +export function creditCost(steps: number): number { + return steps > 4 ? 2 : 1; +} + +/** + * Coarse prompt blocklist for the highest-harm categories. This is a + * first-pass filter, not moderation: it exists so a brand-new site is not a + * free CSAM/gore printer on day one. Match is case-insensitive on word + * fragments; expand per-site in the site layer later. + */ +const BLOCKED_PATTERNS: readonly RegExp[] = [ + /\bchild(?:ren)?\s+(?:porn|nude|naked|sex)/i, + /\bcsam\b/i, + /\bloli(?:con)?\s+(?:porn|nude|naked|sex|hentai)?/i, + /\bshota(?:con)?\s+(?:porn|nude|naked|sex|hentai)?/i, + /\bbeastiality\b/i, + /\brape\s+(?:scene|victim)/i, + /\bgore\s+(?:porn|sex)/i, +]; + +/** Returns the matched pattern source when the prompt must be rejected. */ +export function findBlockedPrompt(prompt: string): string | null { + for (const pattern of BLOCKED_PATTERNS) { + if (pattern.test(prompt)) return pattern.source; + } + return null; +} + +const TURNSTILE_VERIFY_URL = + "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +/** + * Verify a Turnstile token server-side. `secret` absent means the gate is + * disabled (local development) and every token passes. + */ +export async function verifyTurnstile( + secret: string | undefined, + token: string | undefined, + remoteIp?: string, +): Promise { + if (!secret) return true; + if (!token) return false; + + const body = new URLSearchParams({ secret, response: token }); + if (remoteIp) body.set("remoteip", remoteIp); + + try { + const response = await fetch(TURNSTILE_VERIFY_URL, { + method: "POST", + body, + }); + if (!response.ok) return false; + const data = (await response.json()) as { success?: boolean }; + return data.success === true; + } catch { + // Network failure verifying the captcha: fail closed. A transient error + // inconveniences one request; an open gate serves abusers. + return false; + } +} + +/** The subset of KV the limiter needs; satisfied by the binding and mocks. */ +export type RateLimitStore = Pick; + +export type RateLimitDecision = { + allowed: boolean; + /** Attempts left in the current window (0 when blocked). */ + remaining: number; +}; + +/** + * Fixed-window limiter. Deliberately best-effort: the get-then-put race can + * undercount by one under bursts, which is fine for an abuse brake (the + * wallet still enforces the real money constraint). + */ +export async function consumeRateLimit( + store: RateLimitStore | undefined, + key: string, + limit: number, + windowSeconds: number, +): Promise { + if (!store) return { allowed: true, remaining: limit }; + + const windowStart = Math.floor(Date.now() / 1000 / windowSeconds); + const windowKey = `rl:${key}:${windowStart}`; + const count = Number((await store.get(windowKey)) ?? "0"); + + if (count >= limit) return { allowed: false, remaining: 0 }; + + await store.put(windowKey, String(count + 1), { + expirationTtl: windowSeconds * 2, + }); + return { allowed: true, remaining: limit - count - 1 }; +} diff --git a/apps/api/lib/wallet.test.ts b/apps/api/lib/wallet.test.ts new file mode 100644 index 000000000..09cc048bb --- /dev/null +++ b/apps/api/lib/wallet.test.ts @@ -0,0 +1,225 @@ +import { creditsAccount, creditsTransaction, user } from "@repo/db"; +import { createTestDatabase } from "@repo/db/testing"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { + creditWallet, + debitWallet, + ensureSignupGrant, + InsufficientCreditsError, + refundGeneration, + WalletAlreadyAppliedError, +} from "./wallet"; + +const { db, reset, close } = await createTestDatabase(); + +afterAll(close); +beforeEach(reset); + +async function insertUser(email = "wallet@example.com") { + const rows = await db + .insert(user) + .values({ name: "Wallet User", email, emailVerified: true }) + .returning(); + return rows[0].id; +} + +async function balanceOf(userId: string) { + const rows = await db + .select({ balance: creditsAccount.balance }) + .from(creditsAccount) + .where(eq(creditsAccount.userId, userId)); + return rows[0]?.balance ?? 0; +} + +async function ledgerOf(userId: string) { + return db + .select() + .from(creditsTransaction) + .where(eq(creditsTransaction.userId, userId)); +} + +describe("signup grant", () => { + it("grants once and replays are no-ops", async () => { + const userId = await insertUser(); + + const first = await ensureSignupGrant(db, userId); + const second = await ensureSignupGrant(db, userId); + + expect(first.applied).toBe(true); + expect(second.applied).toBe(false); + expect(await balanceOf(userId)).toBe(10); + expect((await ledgerOf(userId)).length).toBe(1); + }); +}); + +describe("creditWallet", () => { + it("applies a purchase once and snapshots the balance", async () => { + const userId = await insertUser(); + + const first = await creditWallet(db, { + userId, + amount: 500, + type: "pack_purchase", + refId: "cs_test_123", + }); + const replay = await creditWallet(db, { + userId, + amount: 500, + type: "pack_purchase", + refId: "cs_test_123", + }); + + expect(first.applied).toBe(true); + expect(replay.applied).toBe(false); + expect(await balanceOf(userId)).toBe(500); + + const ledger = await ledgerOf(userId); + expect(ledger.length).toBe(1); + expect(ledger[0].balanceAfter).toBe(500); + }); +}); + +describe("debitWallet", () => { + it("debits when the balance covers it", async () => { + const userId = await insertUser(); + await creditWallet(db, { + userId, + amount: 5, + type: "pack_purchase", + refId: "topup", + }); + + const debit = await debitWallet(db, { + userId, + amount: 3, + type: "generation", + refId: "gen_1", + }); + + expect(debit).toMatchObject({ applied: true, balance: 2 }); + expect(await balanceOf(userId)).toBe(2); + }); + + it("rejects an overdraft and writes no ledger row", async () => { + const userId = await insertUser(); + await creditWallet(db, { + userId, + amount: 1, + type: "pack_purchase", + refId: "topup", + }); + + await expect( + debitWallet(db, { + userId, + amount: 2, + type: "generation", + refId: "gen_1", + }), + ).rejects.toBeInstanceOf(InsufficientCreditsError); + + expect(await balanceOf(userId)).toBe(1); + // Only the top-up row: the failed debit leaves no trace. + expect((await ledgerOf(userId)).length).toBe(1); + }); + + it("lets exactly one of two concurrent debits win a 1-credit balance", async () => { + const userId = await insertUser(); + await creditWallet(db, { + userId, + amount: 1, + type: "pack_purchase", + refId: "topup", + }); + + const results = await Promise.allSettled([ + debitWallet(db, { + userId, + amount: 1, + type: "generation", + refId: "gen_a", + }), + debitWallet(db, { + userId, + amount: 1, + type: "generation", + refId: "gen_b", + }), + ]); + + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + + expect(fulfilled.length).toBe(1); + expect(rejected.length).toBe(1); + expect( + rejected[0].status === "rejected" && + rejected[0].reason instanceof InsufficientCreditsError, + ).toBe(true); + expect(await balanceOf(userId)).toBe(0); + expect((await ledgerOf(userId)).length).toBe(2); // top-up + one debit + }); + + it("aborts a replayed debit so the speculative update rolls back", async () => { + const userId = await insertUser(); + await creditWallet(db, { + userId, + amount: 5, + type: "pack_purchase", + refId: "topup", + }); + + await debitWallet(db, { + userId, + amount: 1, + type: "generation", + refId: "gen_replay", + }); + await expect( + debitWallet(db, { + userId, + amount: 1, + type: "generation", + refId: "gen_replay", + }), + ).rejects.toBeInstanceOf(WalletAlreadyAppliedError); + + // Balance unchanged by the rejected replay: 5 - 1 = 4. + expect(await balanceOf(userId)).toBe(4); + }); +}); + +describe("refundGeneration", () => { + it("restores the spend exactly once", async () => { + const userId = await insertUser(); + await creditWallet(db, { + userId, + amount: 5, + type: "pack_purchase", + refId: "topup", + }); + await debitWallet(db, { + userId, + amount: 2, + type: "generation", + refId: "gen_x", + }); + + const first = await refundGeneration(db, { + userId, + amount: 2, + generationId: "gen_x", + }); + const replay = await refundGeneration(db, { + userId, + amount: 2, + generationId: "gen_x", + }); + + expect(first.applied).toBe(true); + expect(replay.applied).toBe(false); + expect(await balanceOf(userId)).toBe(5); + }); +}); diff --git a/apps/api/lib/wallet.ts b/apps/api/lib/wallet.ts new file mode 100644 index 000000000..2ad604718 --- /dev/null +++ b/apps/api/lib/wallet.ts @@ -0,0 +1,235 @@ +/** + * Credits wallet operations. + * + * Every balance move happens inside one database transaction and produces + * exactly one append-only ledger row. Guarantees: + * + * - Idempotency: `(type, refId)` is unique. Replaying an already-applied + * credit is a no-op (`applied: false`); replaying a debit aborts its + * transaction (throwing `WalletAlreadyAppliedError`), so the speculative + * balance update rolls back and money is never moved twice. + * - No overdraft: debits use a conditional update (`balance >= amount`), + * so concurrent spenders cannot drive the balance negative. + * - Refunds are credits with `type: "refund"` and their own ref; a refund + * of the same generation twice is a no-op by the same anchor. + */ + +import { + creditsAccount, + creditsTransaction, + type CreditTxnType, + type CreditsAccount, + type Database, +} from "@repo/db"; +import { and, eq, gte, sql } from "drizzle-orm"; + +/** Credits granted once, lazily, the first time a user touches the wallet. */ +export const SIGNUP_GRANT_CREDITS = 10; + +/** Thrown when a debit cannot move because the balance is too low. */ +export class InsufficientCreditsError extends Error { + constructor( + readonly balance: number, + readonly amount: number, + ) { + super(`Insufficient credits: have ${balance}, need ${amount}`); + this.name = "InsufficientCreditsError"; + } +} + +/** Thrown when a debit's (type, refId) was already applied before. */ +export class WalletAlreadyAppliedError extends Error { + constructor(readonly refId: string) { + super(`Wallet entry already applied for ref ${refId}`); + this.name = "WalletAlreadyAppliedError"; + } +} + +export type WalletResult = { + applied: boolean; + balance: number; + transactionId: string | null; +}; + +/** The user's account row, created on first touch. Never returns null. */ +export async function ensureWallet( + db: Database, + userId: string, +): Promise { + await db.insert(creditsAccount).values({ userId }).onConflictDoNothing(); + const [account] = await db + .select() + .from(creditsAccount) + .where(eq(creditsAccount.userId, userId)); + return account; +} + +/** + * Add credits (positive delta). Idempotent per (type, refId): the first call + * moves the balance and writes the ledger row; replays return the current + * balance with `applied: false` and change nothing. + */ +export async function creditWallet( + db: Database, + params: { + userId: string; + /** Must be positive; enforce at call sites. */ + amount: number; + type: Extract< + CreditTxnType, + | "signup_grant" + | "subscription_grant" + | "pack_purchase" + | "refund" + | "admin_adjust" + >; + refId: string; + }, +): Promise { + if (params.amount <= 0) throw new Error("credit amount must be positive"); + + return db.transaction(async (tx) => { + await tx + .insert(creditsAccount) + .values({ userId: params.userId }) + .onConflictDoNothing(); + + const inserted = await tx + .insert(creditsTransaction) + .values({ + userId: params.userId, + delta: params.amount, + type: params.type, + refId: params.refId, + // Snapshot filled after the balance update below, same transaction. + balanceAfter: 0, + }) + .onConflictDoNothing({ + target: [creditsTransaction.type, creditsTransaction.refId], + }) + .returning({ id: creditsTransaction.id }); + + const [insertedTxn] = inserted; + if (!insertedTxn) { + const [current] = await tx + .select({ balance: creditsAccount.balance }) + .from(creditsAccount) + .where(eq(creditsAccount.userId, params.userId)); + return { + applied: false, + balance: current?.balance ?? 0, + transactionId: null, + }; + } + + const [account] = await tx + .update(creditsAccount) + .set({ balance: sql`${creditsAccount.balance} + ${params.amount}` }) + .where(eq(creditsAccount.userId, params.userId)) + .returning(); + + await tx + .update(creditsTransaction) + .set({ balanceAfter: account.balance }) + .where(eq(creditsTransaction.id, insertedTxn.id)); + + return { + applied: true, + balance: account.balance, + transactionId: insertedTxn.id, + }; + }); +} + +/** + * Spend credits (debit). The conditional update makes the check-and-move + * atomic, so two concurrent spends of a 1-credit balance cannot both win. + * + * A replay of an already-applied (type, refId) throws + * `WalletAlreadyAppliedError` AFTER the speculative update, which rolls the + * whole transaction back — net zero on the ledger and the balance. + */ +export async function debitWallet( + db: Database, + params: { + userId: string; + amount: number; + type: Extract; + refId: string; + }, +): Promise { + if (params.amount <= 0) throw new Error("debit amount must be positive"); + + return db.transaction(async (tx) => { + const [account] = await tx + .update(creditsAccount) + .set({ balance: sql`${creditsAccount.balance} - ${params.amount}` }) + .where( + and( + eq(creditsAccount.userId, params.userId), + gte(creditsAccount.balance, params.amount), + ), + ) + .returning(); + + if (!account) { + const [current] = await tx + .select({ balance: creditsAccount.balance }) + .from(creditsAccount) + .where(eq(creditsAccount.userId, params.userId)); + throw new InsufficientCreditsError(current?.balance ?? 0, params.amount); + } + + const [insertedTxn] = await tx + .insert(creditsTransaction) + .values({ + userId: params.userId, + delta: -params.amount, + type: params.type, + refId: params.refId, + balanceAfter: account.balance, + }) + .onConflictDoNothing({ + target: [creditsTransaction.type, creditsTransaction.refId], + }) + .returning({ id: creditsTransaction.id }); + + if (!insertedTxn) { + // Speculative debit of an already-charged ref: abort so the update above + // rolls back with the transaction. + throw new WalletAlreadyAppliedError(params.refId); + } + + return { + applied: true, + balance: account.balance, + transactionId: insertedTxn.id, + }; + }); +} + +/** Refund a generation: a credit keyed by its own anchor, replay-safe. */ +export function refundGeneration( + db: Database, + params: { userId: string; amount: number; generationId: string }, +): Promise { + return creditWallet(db, { + userId: params.userId, + amount: params.amount, + type: "refund", + refId: `refund:${params.generationId}`, + }); +} + +/** Lazy one-time signup grant; safe to call on every wallet touch. */ +export function ensureSignupGrant( + db: Database, + userId: string, +): Promise { + return creditWallet(db, { + userId, + amount: SIGNUP_GRANT_CREDITS, + type: "signup_grant", + refId: `signup:${userId}`, + }); +} diff --git a/apps/api/routers/credits.ts b/apps/api/routers/credits.ts new file mode 100644 index 000000000..7ef47ab87 --- /dev/null +++ b/apps/api/routers/credits.ts @@ -0,0 +1,41 @@ +/** + * Credits queries: current balance (with the lazy signup grant) and the + * spend/top-up history. Mutations live in `generate` (spend) and billing + * webhooks (top-up) – the client never writes the wallet directly. + */ + +import { creditsTransaction, type Database } from "@repo/db"; +import { desc, eq } from "drizzle-orm"; + +import { protectedProcedure, router } from "../lib/trpc.js"; +import { ensureSignupGrant, ensureWallet } from "../lib/wallet.js"; + +export const creditsRouter = router({ + balance: protectedProcedure.query(async ({ ctx }) => { + await ensureSignupGrant(ctx.db, ctx.user.id); + const account = await ensureWallet(ctx.db, ctx.user.id); + return { balance: account.balance }; + }), + + transactions: protectedProcedure.query(async ({ ctx }) => { + return selectRecentTransactions(ctx.db, ctx.user.id); + }), +}); + +async function selectRecentTransactions(db: Database, userId: string) { + return db + .select({ + id: creditsTransaction.id, + delta: creditsTransaction.delta, + type: creditsTransaction.type, + refId: creditsTransaction.refId, + balanceAfter: creditsTransaction.balanceAfter, + createdAt: creditsTransaction.createdAt, + }) + .from(creditsTransaction) + .where(eq(creditsTransaction.userId, userId)) + .orderBy(desc(creditsTransaction.createdAt)) + .limit(50); +} + +export type CreditsRouter = typeof creditsRouter; diff --git a/apps/api/routers/generate.test.ts b/apps/api/routers/generate.test.ts new file mode 100644 index 000000000..8b1749e4f --- /dev/null +++ b/apps/api/routers/generate.test.ts @@ -0,0 +1,360 @@ +import { creditsAccount, generation, user } from "@repo/db"; +import { createTestDatabase } from "@repo/db/testing"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach } from "vitest"; + +import type { TRPCContext } from "../lib/context"; +import { createCallerFactory } from "../lib/trpc"; +import { debitWallet } from "../lib/wallet"; +import { generateRouter } from "./generate"; +import { historyRouter } from "./history"; + +const createCaller = createCallerFactory(generateRouter); +const createHistoryCaller = createCallerFactory(historyRouter); + +const { db, reset, close } = await createTestDatabase(); + +afterAll(close); +beforeEach(reset); + +const PNG_BASE64 = Buffer.from("fake-png-bytes").toString("base64"); + +/** In-memory KV double for the rate limiter. */ +function mockKv() { + const store = new Map(); + return { + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => void store.set(key, value), + }; +} + +type EnvOverrides = { + turnstileSecret?: string; + aiFails?: boolean; + r2Fails?: boolean; +}; + +function testEnv(overrides: EnvOverrides = {}) { + const kv = mockKv(); + const r2Keys: string[] = []; + + const env = { + AI: { + run: vi.fn(async () => + overrides.aiFails + ? Promise.reject(new Error("model exploded")) + : Promise.resolve({ image: PNG_BASE64 }), + ), + }, + IMAGES: { + put: vi.fn(async (key: string) => { + if (overrides.r2Fails) throw new Error("r2 exploded"); + r2Keys.push(key); + }), + }, + RATELIMIT: kv, + ...(overrides.turnstileSecret + ? { TURNSTILE_SECRET_KEY: overrides.turnstileSecret } + : {}), + } as unknown as TRPCContext["env"]; + + return { env, r2Keys }; +} + +function testCtx(userId: string, env: TRPCContext["env"]): TRPCContext { + return { + req: new Request("http://localhost"), + info: {} as TRPCContext["info"], + session: { + id: "ses_test", + createdAt: new Date(), + updatedAt: new Date(), + userId, + expiresAt: new Date(Date.now() + 60_000), + token: "token", + activeOrganizationId: null, + }, + user: { + id: userId, + createdAt: new Date(), + updatedAt: new Date(), + email: "test@example.com", + emailVerified: true, + name: "Test User", + }, + db, + dbCached: db, + env, + }; +} + +async function insertUser(email = "gen@example.com") { + const rows = await db + .insert(user) + .values({ name: "Gen User", email, emailVerified: true }) + .returning(); + return rows[0].id; +} + +async function balanceOf(userId: string) { + const rows = await db + .select({ balance: creditsAccount.balance }) + .from(creditsAccount) + .where(eq(creditsAccount.userId, userId)); + return rows[0]?.balance ?? 0; +} + +function prompt(overrides: Record = {}) { + return { prompt: "a red panda barista", steps: 4, ...overrides }; +} + +function trpcCode(error: unknown) { + if (error instanceof TRPCError) return error.code; + throw error; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("generate success path", () => { + it("debits, generates, stores, and reports the new balance", async () => { + const userId = await insertUser(); + const { env, r2Keys } = testEnv(); + const caller = createCaller(testCtx(userId, env)); + + const result = await caller.generate(prompt()); + + expect(result.status).toBe("succeeded"); + expect(result.cost).toBe(1); + expect(result.balance).toBe(9); // 10 signup - 1 + expect(result.imageKey).toMatch(new RegExp("^" + userId + "/.+\\.png$")); + expect(r2Keys).toEqual([result.imageKey]); + expect(await balanceOf(userId)).toBe(9); + }); + + it("charges double for high-step generations", async () => { + const userId = await insertUser(); + const caller = createCaller(testCtx(userId, testEnv().env)); + + const result = await caller.generate(prompt({ steps: 8 })); + + expect(result.cost).toBe(2); + expect(result.balance).toBe(8); + }); +}); + +describe("generate failure rollback", () => { + it("refunds and marks failed when the model errors", async () => { + const userId = await insertUser(); + const caller = createCaller( + testCtx(userId, testEnv({ aiFails: true }).env), + ); + + await expect(caller.generate(prompt())).rejects.toThrow( + /generation failed, credits refunded/i, + ); + + const rows = await db + .select() + .from(generation) + .where(eq(generation.userId, userId)); + expect(rows[0].status).toBe("failed"); + expect(rows[0].errorKind).toBe("model_unavailable"); + // Debit and refund cancel out; only the signup grant remains. + expect(await balanceOf(userId)).toBe(10); + }); + + it("refunds and marks failed when R2 errors", async () => { + const userId = await insertUser(); + const caller = createCaller( + testCtx(userId, testEnv({ r2Fails: true }).env), + ); + + await expect(caller.generate(prompt())).rejects.toThrow( + /storage failed, credits refunded/i, + ); + + const rows = await db + .select() + .from(generation) + .where(eq(generation.userId, userId)); + expect(rows[0].errorKind).toBe("storage_failed"); + expect(await balanceOf(userId)).toBe(10); + }); + + it("rejects with PAYMENT_REQUIRED when the wallet is empty", async () => { + const userId = await insertUser(); + const { env } = testEnv(); + + // Drain the signup grant first. + await createCaller(testCtx(userId, env)).generate(prompt()); + await debitWallet(db, { + userId, + amount: 9, + type: "generation", + refId: "manual_drain", + }); + + const error = await createCaller(testCtx(userId, env)) + .generate(prompt()) + .catch((e: unknown) => e); + + expect(trpcCode(error)).toBe("PAYMENT_REQUIRED"); + expect(await balanceOf(userId)).toBe(0); + }); +}); + +describe("generate idempotency", () => { + it("replays the original outcome without a second charge", async () => { + const userId = await insertUser(); + const { env } = testEnv(); + const caller = createCaller(testCtx(userId, env)); + const key = "retry-key-123"; + + const first = await caller.generate(prompt({ idempotencyKey: key })); + const second = await caller.generate(prompt({ idempotencyKey: key })); + + expect(second.id).toBe(first.id); + expect(second.status).toBe("succeeded"); + expect(await balanceOf(userId)).toBe(9); + expect(env.AI!.run).toHaveBeenCalledTimes(1); + }); +}); + +describe("generate guardrails", () => { + it("rejects blocked prompts without creating rows or charges", async () => { + const userId = await insertUser(); + const caller = createCaller(testCtx(userId, testEnv().env)); + + const error = await caller + .generate(prompt({ prompt: "csam please" })) + .catch((e: unknown) => e); + + expect(trpcCode(error)).toBe("BAD_REQUEST"); + const rows = await db + .select() + .from(generation) + .where(eq(generation.userId, userId)); + expect(rows.length).toBe(0); + // The blocklist fires before the signup grant, so no wallet row exists + // yet: balanceOf reads as 0, not 10. + expect(await balanceOf(userId)).toBe(0); + }); + + it("rejects invalid steps and oversized prompts at the schema layer", async () => { + const userId = await insertUser(); + const caller = createCaller(testCtx(userId, testEnv().env)); + + await expect(caller.generate(prompt({ steps: 9 }))).rejects.toThrow(); + await expect( + caller.generate(prompt({ prompt: "x".repeat(2049) })), + ).rejects.toThrow(); + }); + + it("rate-limits the 7th attempt in a window", async () => { + const userId = await insertUser(); + const caller = createCaller(testCtx(userId, testEnv().env)); + + for (let attempt = 0; attempt < 6; attempt++) { + await caller.generate(prompt({ prompt: `cat ${attempt}` })); + } + + const error = await caller + .generate(prompt({ prompt: "one too many" })) + .catch((e: unknown) => e); + + expect(trpcCode(error)).toBe("TOO_MANY_REQUESTS"); + // Six succeeded: 10 - 6 = 4. + expect(await balanceOf(userId)).toBe(4); + }); + + it("fails closed on Turnstile when a secret is configured", async () => { + const userId = await insertUser(); + const { env } = testEnv({ + turnstileSecret: "1x0000000000000000000000000000000AA", + }); + + const missingToken = await createCaller(testCtx(userId, env)) + .generate(prompt()) + .catch((e: unknown) => e); + expect(trpcCode(missingToken)).toBe("BAD_REQUEST"); + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ success: false }), { status: 200 }), + ), + ); + + const badToken = await createCaller(testCtx(userId, env)) + .generate(prompt({ turnstileToken: "invalid" })) + .catch((e: unknown) => e); + expect(trpcCode(badToken)).toBe("BAD_REQUEST"); + + const rows = await db + .select() + .from(generation) + .where(eq(generation.userId, userId)); + expect(rows.length).toBe(0); + }); + + it("passes with a verified Turnstile token", async () => { + const userId = await insertUser(); + const { env } = testEnv({ + turnstileSecret: "1x0000000000000000000000000000000AA", + }); + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ success: true }), { status: 200 }), + ), + ); + + const result = await createCaller(testCtx(userId, env)).generate( + prompt({ turnstileToken: "valid" }), + ); + expect(result.status).toBe("succeeded"); + }); + + it("reports unavailable when AI or R2 bindings are missing", async () => { + const userId = await insertUser(); + const env = {} as TRPCContext["env"]; + + const error = await createCaller(testCtx(userId, env)) + .generate(prompt()) + .catch((e: unknown) => e); + + expect(trpcCode(error)).toBe("SERVICE_UNAVAILABLE"); + }); +}); + +describe("history router", () => { + it("lists my generations and isolates other users' rows", async () => { + const userA = await insertUser("a@example.com"); + const userB = await insertUser("b@example.com"); + const callerA = createCaller(testCtx(userA, testEnv().env)); + const historyA = createHistoryCaller(testCtx(userA, testEnv().env)); + const historyB = createHistoryCaller(testCtx(userB, testEnv().env)); + + const created = await callerA.generate(prompt()); + + const mine = await historyA.list(); + expect(mine.length).toBe(1); + expect(mine[0].id).toBe(created.id); + + const theirs = await historyB.list(); + expect(theirs.length).toBe(0); + + await expect(historyB.get({ id: created.id })).rejects.toThrow( + /not found/i, + ); + const found = await historyA.get({ id: created.id }); + expect(found.id).toBe(created.id); + }); +}); diff --git a/apps/api/routers/generate.ts b/apps/api/routers/generate.ts new file mode 100644 index 000000000..662850f3c --- /dev/null +++ b/apps/api/routers/generate.ts @@ -0,0 +1,250 @@ +/** + * Image generation: the money path. + * + * Order of operations (deliberate – debiting before the model runs prevents + * one balance from funding N concurrent in-flight requests): + * + * 1. guardrails (Turnstile, rate limit, blocklist, validation) + * 2. idempotency replay check + * 3. create `generation` row in `processing` + * 4. DEBIT the wallet, anchored on the generation id + * 5. run the model; on failure → refund + mark `failed` + rethrow + * 6. store bytes in R2 under a server-generated key + * (on failure → refund + mark `failed` + rethrow) + * 7. mark `succeeded` + * + * Every failure path refunds through the same append-only ledger, so balance + * and ledger always reconcile; a debit can never exist without either a + * `succeeded` row or a matching `refund` entry. + * + * Persistence lives in lib/generations.ts; wallet moves in lib/wallet.ts. + * Read access (list/get for the client, public gallery later) is exposed + * through `routers/history.ts` to keep this file the money path only. + */ + +import type { Database } from "@repo/db"; +import { TRPCError } from "@trpc/server"; + +import { + findGenerationByIdempotencyKey, + insertGeneration, + markGenerationFailed, + markGenerationSucceeded, + type GenerationRow, +} from "../lib/generations.js"; +import { + consumeRateLimit, + creditCost, + findBlockedPrompt, + generateInputSchema, + verifyTurnstile, +} from "../lib/guard.js"; +import { protectedProcedure, router } from "../lib/trpc.js"; +import { + debitWallet, + ensureSignupGrant, + ensureWallet, + refundGeneration, +} from "../lib/wallet.js"; + +const MODEL_ID = "@cf/black-forest-labs/flux-1-schnell"; +/** Per-user generate attempts per window; the wallet caps the real damage. */ +const RATE_LIMIT = { key: "generate", limit: 6, windowSeconds: 60 } as const; + +/** Client-facing projection; never exposes internal columns wholesale. */ +function toGenerationDto(row: GenerationRow) { + return { + id: row.id, + status: row.status, + prompt: row.prompt, + steps: row.steps, + cost: row.cost, + imageKey: row.r2Key, + errorKind: row.errorKind, + createdAt: row.createdAt, + }; +} + +/** Workers AI returns the image as base64; the API worker enables + * nodejs_compat, so Buffer handles the decode in one step. */ +function decodeBase64Image(value: string): Uint8Array { + return Uint8Array.from(Buffer.from(value, "base64")); +} + +/** Refund + mark failed. Swallows refund errors so the row still fails. */ +async function failGeneration( + db: Database, + row: { id: string; userId: string; cost: number }, + kind: Parameters[2], + message: string, +): Promise { + try { + await refundGeneration(db, { + userId: row.userId, + amount: row.cost, + generationId: row.id, + }); + } catch { + // Ledger still shows the debit; reconcile sweeps refund-pending rows. + } + await markGenerationFailed(db, row.id, kind, message); +} + +export const generateRouter = router({ + generate: protectedProcedure + .input(generateInputSchema) + .mutation(async ({ ctx, input }) => { + // ————————————————— 1. guardrails ————————————————— + const ownerId = ctx.user.id; + + const captchaOk = await verifyTurnstile( + ctx.env.TURNSTILE_SECRET_KEY, + input.turnstileToken, + ); + if (!captchaOk) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Captcha verification failed", + }); + } + + const rate = await consumeRateLimit( + ctx.env.RATELIMIT, + RATE_LIMIT.key + ":" + ownerId, + RATE_LIMIT.limit, + RATE_LIMIT.windowSeconds, + ); + if (!rate.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many generation attempts, retry in a minute", + }); + } + + if (findBlockedPrompt(input.prompt) !== null) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Prompt rejected by content policy", + }); + } + + if (!ctx.env.AI || !ctx.env.IMAGES) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Image generation is not configured", + }); + } + + // ————————————————— 2. idempotent replay ————————————————— + if (input.idempotencyKey) { + const existing = await findGenerationByIdempotencyKey( + ctx.db, + ownerId, + input.idempotencyKey, + ); + if (existing) { + const account = await ensureWallet(ctx.db, ownerId); + return { ...toGenerationDto(existing), balance: account.balance }; + } + } + + await ensureSignupGrant(ctx.db, ownerId); + + // ————————————————— 3. job row ————————————————— + const cost = creditCost(input.steps); + const row = await insertGeneration(ctx.db, { + userId: ownerId, + idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(), + prompt: input.prompt, + model: MODEL_ID, + steps: input.steps, + cost, + }); + + // ————————————————— 4. debit ————————————————— + let debitBalance: number; + try { + const debit = await debitWallet(ctx.db, { + userId: ownerId, + amount: cost, + type: "generation", + refId: row.id, + }); + debitBalance = debit.balance; + } catch (error) { + // A debit exception means NO money moved (the wallet transaction + // aborts atomically). Do NOT refund here – refunding an unbilled + // row would mint credits out of thin air. + await markGenerationFailed( + ctx.db, + row.id, + "internal", + "wallet debit failed", + ); + if ( + error instanceof Error && + error.name === "InsufficientCreditsError" + ) { + throw new TRPCError({ + code: "PAYMENT_REQUIRED", + message: error.message, + }); + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Wallet debit failed", + }); + } + + // ————————————————— 5. model ————————————————— + let bytes: Uint8Array; + try { + const result = (await ctx.env.AI.run(MODEL_ID, { + prompt: row.prompt, + steps: row.steps, + })) as { image?: string }; + + if (typeof result?.image !== "string") { + throw new Error("model returned no image"); + } + bytes = decodeBase64Image(result.image); + } catch (error) { + await failGeneration( + ctx.db, + row, + "model_unavailable", + error instanceof Error ? error.message : "model call failed", + ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Image generation failed, credits refunded", + }); + } + + // ————————————————— 6. storage ————————————————— + // Server-generated key: user input never reaches object keys. + const r2Key = row.userId + "/" + row.id + ".png"; + try { + await ctx.env.IMAGES.put(r2Key, bytes, { + httpMetadata: { contentType: "image/png" }, + }); + } catch (error) { + await failGeneration( + ctx.db, + row, + "storage_failed", + error instanceof Error ? error.message : "r2 put failed", + ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Image storage failed, credits refunded", + }); + } + + // ————————————————— 7. commit ————————————————— + const done = + (await markGenerationSucceeded(ctx.db, row.id, r2Key)) ?? row; + + return { ...toGenerationDto(done), balance: debitBalance }; + }), +}); diff --git a/apps/api/routers/history.ts b/apps/api/routers/history.ts new file mode 100644 index 000000000..bda01a39c --- /dev/null +++ b/apps/api/routers/history.ts @@ -0,0 +1,58 @@ +/** + * Read-only generation history for the signed-in user. The public gallery + * (unauthenticated, SEO pages) will be a separate router with its own + * visibility rules; this one is strictly "my generations". + */ + +import type { Database } from "@repo/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { + findGenerationById, + listRecentGenerations, + type GenerationRow, +} from "../lib/generations.js"; +import { protectedProcedure, router } from "../lib/trpc.js"; + +export const historyRouter = router({ + list: protectedProcedure.query(async ({ ctx }) => { + return selectRecentGenerations(ctx.db, ctx.user.id); + }), + + get: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const found = await selectGeneration(ctx.db, ctx.user.id, input.id); + if (!found) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Generation not found", + }); + } + return found; + }), +}); + +function toGenerationDto(row: GenerationRow) { + return { + id: row.id, + status: row.status, + prompt: row.prompt, + steps: row.steps, + cost: row.cost, + imageKey: row.r2Key, + errorKind: row.errorKind, + createdAt: row.createdAt, + }; +} + +async function selectRecentGenerations(db: Database, userId: string) { + const rows = await listRecentGenerations(db, userId); + return rows.map(toGenerationDto); +} + +async function selectGeneration(db: Database, userId: string, id: string) { + const row = await findGenerationById(db, userId, id); + return row ? toGenerationDto(row) : null; +} diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 2f215e3cf..217332532 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -29,6 +29,20 @@ { "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_UNCACHED", "id": "your-hyperdrive-uncached-id-here" } ], + // Engine bindings: Workers AI for image generation, R2 for image storage, + // KV for the best-effort generate rate limiter. Local `wrangler dev` + // simulates all three without provisioning. + "ai": { "binding": "AI" }, + "r2_buckets": [ + { + "binding": "IMAGES", + "bucket_name": "your-images-bucket-name-here", + "preview_bucket_name": "images-preview" + } + ], + "kv_namespaces": [ + { "binding": "RATELIMIT", "id": "your-ratelimit-kv-id-here" } + ], // Deploy fails fast when one of these is unset. Values live in Cloudflare // (`wrangler secret put`); only the required-name contract is versioned. // @@ -62,6 +76,18 @@ { "binding": "HYPERDRIVE_CACHED", "id": "local-dev-placeholder" }, { "binding": "HYPERDRIVE_UNCACHED", "id": "local-dev-placeholder" } ], + // Named environments do not inherit top-level bindings. + "ai": { "binding": "AI" }, + "r2_buckets": [ + { + "binding": "IMAGES", + "bucket_name": "images-dev", + "preview_bucket_name": "images-dev-preview" + } + ], + "kv_namespaces": [ + { "binding": "RATELIMIT", "id": "local-dev-placeholder" } + ], "secrets": { "required": ["BETTER_AUTH_SECRET", "RESEND_API_KEY"] } diff --git a/db/migrations/0001_uneven_iron_lad.sql b/db/migrations/0001_uneven_iron_lad.sql new file mode 100644 index 000000000..18ead8edd --- /dev/null +++ b/db/migrations/0001_uneven_iron_lad.sql @@ -0,0 +1,44 @@ +CREATE TABLE "credits_account" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "balance" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "credits_account_userId_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "credits_transaction" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "delta" integer NOT NULL, + "type" text NOT NULL, + "ref_id" text NOT NULL, + "balance_after" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "credits_txn_type_ref_unique" UNIQUE("type","ref_id") +); +--> statement-breakpoint +CREATE TABLE "generation" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "status" text DEFAULT 'processing' NOT NULL, + "prompt" text NOT NULL, + "model" text NOT NULL, + "steps" integer NOT NULL, + "cost" integer NOT NULL, + "r2_key" text, + "error_kind" text, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "generation_idempotency_key_unique" UNIQUE("user_id","idempotency_key") +); +--> statement-breakpoint +ALTER TABLE "credits_account" ADD CONSTRAINT "credits_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credits_transaction" ADD CONSTRAINT "credits_transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "generation" ADD CONSTRAINT "generation_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "credits_account_user_id_idx" ON "credits_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "credits_txn_user_created_idx" ON "credits_transaction" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "generation_user_created_idx" ON "generation" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "generation_status_idx" ON "generation" USING btree ("status"); \ No newline at end of file diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..3e43abb88 --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1394 @@ +{ + "id": "16e5ee8f-91aa-490f-a029-3f2c5d1223cf", + "prevId": "2f162304-a16e-4ba9-bf5b-dac3c1e4f6c0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.credits_account": { + "name": "credits_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credits_account_user_id_idx": { + "name": "credits_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credits_account_user_id_user_id_fk": { + "name": "credits_account_user_id_user_id_fk", + "tableFrom": "credits_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credits_account_userId_unique": { + "name": "credits_account_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credits_transaction": { + "name": "credits_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credits_txn_user_created_idx": { + "name": "credits_txn_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credits_transaction_user_id_user_id_fk": { + "name": "credits_transaction_user_id_user_id_fk", + "tableFrom": "credits_transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credits_txn_type_ref_unique": { + "name": "credits_txn_type_ref_unique", + "nullsNotDistinct": false, + "columns": ["type", "ref_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation": { + "name": "generation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steps": { + "name": "steps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_kind": { + "name": "error_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_user_created_idx": { + "name": "generation_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_status_idx": { + "name": "generation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_user_id_user_id_fk": { + "name": "generation_user_id_user_id_fk", + "tableFrom": "generation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "generation_idempotency_key_unique": { + "name": "generation_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity": { + "name": "identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_user_id_idx": { + "name": "identity_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_user_id_user_id_fk": { + "name": "identity_user_id_user_id_fk", + "tableFrom": "identity", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "identity_provider_account_unique": { + "name": "identity_provider_account_unique", + "nullsNotDistinct": false, + "columns": ["provider_id", "account_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_inviter_id_idx": { + "name": "invitation_inviter_id_idx", + "columns": [ + { + "expression": "inviter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_org_email_unique": { + "name": "invitation_org_email_unique", + "nullsNotDistinct": false, + "columns": ["organization_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member_user_org_unique": { + "name": "member_user_org_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "organization_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_user_id_idx": { + "name": "passkey_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credentialID_unique": { + "name": "passkey_credentialID_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_active_org_id_idx": { + "name": "session_active_org_id_idx", + "columns": [ + { + "expression": "active_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_reference_id_idx": { + "name": "subscription_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_stripe_customer_id_idx": { + "name": "subscription_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscription_stripeSubscriptionId_unique": { + "name": "subscription_stripeSubscriptionId_unique", + "nullsNotDistinct": false, + "columns": ["stripe_subscription_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_value_idx": { + "name": "verification_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_identifier_value_unique": { + "name": "verification_identifier_value_unique", + "nullsNotDistinct": false, + "columns": ["identifier", "value"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index ee86eb424..f6b5a0340 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1751197781613, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791386625638, + "tag": "0001_uneven_iron_lad", + "breakpoints": true } ] } diff --git a/db/schema/credits.ts b/db/schema/credits.ts new file mode 100644 index 000000000..4f00bc5a3 --- /dev/null +++ b/db/schema/credits.ts @@ -0,0 +1,116 @@ +/** + * Credits wallet: prepaid balance plus an append-only ledger. + * + * `credits_account` holds one row per user with the live balance; the balance + * column is only ever mutated inside the wallet helpers in `apps/api/lib/wallet` + * together with a matching ledger row, in the same transaction. + * + * `credits_transaction` is the fact source for auditing and reconciliation: + * rows are inserted, never updated (except the `balanceAfter` snapshot written + * by the same transaction that moves the balance). The `(type, refId)` unique + * constraint is the idempotency anchor: webhooks and retries replay the same + * ref and the insert simply loses, so credits are never applied twice. + */ + +import { relations } from "drizzle-orm"; +import { + index, + integer, + pgTable, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; + +import { generateId } from "./id"; +import { user } from "./user"; + +/** What moved credits. `refId` semantics per type: + * - signup_grant → `signup:{userId}` (lazy, granted once per user) + * - subscription_grant → Stripe invoice id + * - pack_purchase → Stripe checkout session id / Creem event id + * - generation → generation id being paid for + * - refund → `refund:{generationId}` (failed generation) + * - admin_adjust → operator-supplied reference + */ +export const CREDIT_TXN_TYPES = [ + "signup_grant", + "subscription_grant", + "pack_purchase", + "generation", + "refund", + "admin_adjust", +] as const; + +export type CreditTxnType = (typeof CREDIT_TXN_TYPES)[number]; + +export const creditsAccount = pgTable( + "credits_account", + { + id: text() + .primaryKey() + .$defaultFn(() => generateId("wal")), + userId: text() + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + balance: integer().default(0).notNull(), + createdAt: timestamp({ withTimezone: true, mode: "date" }) + .defaultNow() + .notNull(), + updatedAt: timestamp({ withTimezone: true, mode: "date" }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("credits_account_user_id_idx").on(table.userId)], +); + +export type CreditsAccount = typeof creditsAccount.$inferSelect; +export type NewCreditsAccount = typeof creditsAccount.$inferInsert; + +export const creditsTransaction = pgTable( + "credits_transaction", + { + id: text() + .primaryKey() + .$defaultFn(() => generateId("trx")), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + /** Positive = credited, negative = spent. */ + delta: integer().notNull(), + type: text().$type().notNull(), + /** Idempotency anchor; meaning depends on `type` (see CREDIT_TXN_TYPES). */ + refId: text().notNull(), + /** Balance after this entry; written by the same transaction as the move. */ + balanceAfter: integer().notNull(), + createdAt: timestamp({ withTimezone: true, mode: "date" }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("credits_txn_type_ref_unique").on(table.type, table.refId), + index("credits_txn_user_created_idx").on(table.userId, table.createdAt), + ], +); + +export type CreditsTransaction = typeof creditsTransaction.$inferSelect; +export type NewCreditsTransaction = typeof creditsTransaction.$inferInsert; + +export const creditsAccountRelations = relations(creditsAccount, ({ one }) => ({ + user: one(user, { + fields: [creditsAccount.userId], + references: [user.id], + }), +})); + +export const creditsTransactionRelations = relations( + creditsTransaction, + ({ one }) => ({ + user: one(user, { + fields: [creditsTransaction.userId], + references: [user.id], + }), + }), +); diff --git a/db/schema/generation.ts b/db/schema/generation.ts new file mode 100644 index 000000000..10ce34bd7 --- /dev/null +++ b/db/schema/generation.ts @@ -0,0 +1,114 @@ +/** + * Image generation job records: one row per paid generation attempt. + * + * The status column is a small state machine (see GENERATION_STATUS and + * GENERATION_TRANSITIONS). Credits are debited before the model runs and + * refunded automatically when a terminal `failed` state is reached, so a + * `succeeded` row always corresponds to exactly one non-refunded `generation` + * ledger entry, and a `failed` row to a `generation` + `refund` pair. + */ + +import { relations } from "drizzle-orm"; +import { + index, + integer, + pgTable, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; + +import { generateId } from "./id"; +import { user } from "./user"; + +/** + * Generation lifecycle: + * + * processing ──▶ succeeded + * │ + * └───────▶ failed (credits auto-refunded) + * + * `processing` is short-lived (the row is created and finished within one + * request). A row stuck in `processing` after a worker crash is swept as + * `failed` with a matching refund by reconcile; clients must treat it as + * pending, never as success. + */ +export const GENERATION_STATUS = ["processing", "succeeded", "failed"] as const; + +export type GenerationStatus = (typeof GENERATION_STATUS)[number]; + +/** Allowed transitions; the updater is responsible for refunding on → failed. */ +export const GENERATION_TRANSITIONS: Record< + GenerationStatus, + readonly GenerationStatus[] +> = { + processing: ["succeeded", "failed"], + succeeded: [], + failed: [], +}; + +/** Generation error kinds stored in `errorKind` for triage. */ +export const GENERATION_ERROR_KINDS = [ + "model_unavailable", + "model_rejected", + "storage_failed", + "refund_pending", + "internal", +] as const; + +export type GenerationErrorKind = (typeof GENERATION_ERROR_KINDS)[number]; + +export const generation = pgTable( + "generation", + { + id: text() + .primaryKey() + .$defaultFn(() => generateId("gen")), + userId: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + /** + * Client-supplied retry key: a network retry of the same request replays + * the same key and receives the original outcome instead of a second + * charge. Unique per user, so key collisions across users are harmless. + */ + idempotencyKey: text().notNull(), + status: text().$type().default("processing").notNull(), + prompt: text().notNull(), + /** Model identifier as passed to the AI binding, e.g. flux-1-schnell. */ + model: text().notNull(), + /** Diffusion steps; more steps cost more credits (see router pricing). */ + steps: integer().notNull(), + /** Credits charged; mirrored in the matching ledger entry. */ + cost: integer().notNull(), + /** R2 object key, set on success. User input never reaches key material. */ + r2Key: text(), + errorKind: text().$type(), + errorMessage: text(), + createdAt: timestamp({ withTimezone: true, mode: "date" }) + .defaultNow() + .notNull(), + updatedAt: timestamp({ withTimezone: true, mode: "date" }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + unique("generation_idempotency_key_unique").on( + table.userId, + table.idempotencyKey, + ), + index("generation_user_created_idx").on(table.userId, table.createdAt), + index("generation_status_idx").on(table.status), + ], +); + +export type Generation = typeof generation.$inferSelect; +export type NewGeneration = typeof generation.$inferInsert; + +export const generationRelations = relations(generation, ({ one }) => ({ + user: one(user, { + fields: [generation.userId], + references: [user.id], + }), +})); diff --git a/db/schema/index.ts b/db/schema/index.ts index 31f4389ac..0ff6b1a39 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -4,3 +4,5 @@ export * from "./organization"; export * from "./passkey"; export * from "./subscription"; export * from "./user"; +export * from "./credits"; +export * from "./generation";