From 0c05b091d04fa7f2029efdceaf10fc6ecfc09a55 Mon Sep 17 00:00:00 2001 From: sirily Date: Wed, 11 Mar 2026 13:43:56 +0300 Subject: [PATCH] fix: scope generation idempotency per user --- .../migration.sql | 4 + packages/db/prisma/schema.prisma | 3 +- packages/db/src/generation-store.test.ts | 193 ++++++++++++++---- 3 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql diff --git a/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql b/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql new file mode 100644 index 0000000..12d202c --- /dev/null +++ b/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql @@ -0,0 +1,4 @@ +DROP INDEX "GenerationRequest_idempotencyKey_key"; + +CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key" +ON "GenerationRequest"("userId", "idempotencyKey"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3e9432e..ba6b7cc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -187,7 +187,7 @@ model GenerationRequest { resolutionPreset String batchSize Int imageStrength Decimal? @db.Decimal(4, 3) - idempotencyKey String? @unique + idempotencyKey String? terminalErrorCode String? terminalErrorText String? requestedAt DateTime @default(now()) @@ -200,6 +200,7 @@ model GenerationRequest { assets GeneratedAsset[] usageLedgerEntry UsageLedgerEntry? + @@unique([userId, idempotencyKey]) @@index([userId, status, requestedAt]) } diff --git a/packages/db/src/generation-store.test.ts b/packages/db/src/generation-store.test.ts index 45230b8..631e4ad 100644 --- a/packages/db/src/generation-store.test.ts +++ b/packages/db/src/generation-store.test.ts @@ -6,10 +6,12 @@ import { createPrismaGenerationStore } from "./generation-store.js"; test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => { const database = createGenerationDatabase({ - subscription: createSubscriptionFixture({ - status: "active", - currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), - }), + subscriptions: [ + createSubscriptionFixture({ + status: "active", + currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), + }), + ], }); const store = createPrismaGenerationStore(database.client); @@ -29,11 +31,67 @@ test("createGenerationRequest rejects an expired active subscription and marks i assert.equal(database.calls.subscriptionUpdateMany.length, 1); assert.equal(database.calls.generationRequestCreate.length, 0); - assert.equal(database.state.subscription?.status, "expired"); + assert.equal(database.state.subscriptions[0]?.status, "expired"); +}); + +test("createGenerationRequest scopes idempotency-key reuse per user", async () => { + const database = createGenerationDatabase({ + subscriptions: [ + createSubscriptionFixture({ + userId: "user_1", + status: "active", + currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"), + }), + createSubscriptionFixture({ + id: "subscription_2", + userId: "user_2", + status: "active", + currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"), + }), + ], + generationRequests: [ + createGenerationRequestFixture({ + id: "request_existing", + userId: "user_1", + idempotencyKey: "shared-key", + }), + ], + }); + const store = createPrismaGenerationStore(database.client); + + const reused = await createGenerationRequest(store, { + userId: "user_1", + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + idempotencyKey: "shared-key", + }); + + assert.equal(reused.reusedExistingRequest, true); + assert.equal(reused.request.id, "request_existing"); + + const created = await createGenerationRequest(store, { + userId: "user_2", + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + idempotencyKey: "shared-key", + }); + + assert.equal(created.reusedExistingRequest, false); + assert.equal(created.request.userId, "user_2"); + assert.equal(created.request.idempotencyKey, "shared-key"); + assert.equal(database.calls.generationRequestCreate.length, 1); + assert.equal(database.state.generationRequests.length, 2); }); function createGenerationDatabase(input: { - subscription: ReturnType | null; + subscriptions: Array>; + generationRequests?: Array>; }) { const calls = { subscriptionUpdateMany: [] as Array>, @@ -41,7 +99,8 @@ function createGenerationDatabase(input: { }; const state = { - subscription: input.subscription, + subscriptions: [...input.subscriptions], + generationRequests: [...(input.generationRequests ?? [])], }; const client = { @@ -51,15 +110,19 @@ function createGenerationDatabase(input: { }: { where: { userId: string; status?: "active" }; }) => { - if (!state.subscription || state.subscription.userId !== where.userId) { - return null; - } + return ( + state.subscriptions.find((subscription) => { + if (subscription.userId !== where.userId) { + return false; + } - if (where.status && state.subscription.status !== where.status) { - return null; - } + if (where.status && subscription.status !== where.status) { + return false; + } - return state.subscription; + return true; + }) ?? null + ); }, updateMany: async ({ where, @@ -70,19 +133,21 @@ function createGenerationDatabase(input: { }) => { calls.subscriptionUpdateMany.push({ where, data }); - if ( - state.subscription && - state.subscription.id === where.id && - state.subscription.status === where.status - ) { - state.subscription = { - ...state.subscription, + let updatedCount = 0; + + state.subscriptions = state.subscriptions.map((subscription) => { + if (subscription.id !== where.id || subscription.status !== where.status) { + return subscription; + } + + updatedCount += 1; + return { + ...subscription, status: data.status, }; - return { count: 1 }; - } + }); - return { count: 0 }; + return { count: updatedCount }; }, }, usageLedgerEntry: { @@ -95,28 +160,37 @@ function createGenerationDatabase(input: { generationRequest: { create: async ({ data }: { data: Record }) => { calls.generationRequestCreate.push({ data }); - return { - id: "request_1", + const request = createGenerationRequestFixture({ + id: `request_${state.generationRequests.length + 1}`, userId: data.userId as string, mode: data.mode as string, - status: "queued", providerModel: data.providerModel as string, prompt: data.prompt as string, - sourceImageKey: null, resolutionPreset: data.resolutionPreset as string, batchSize: data.batchSize as number, - imageStrength: null, - idempotencyKey: null, - terminalErrorCode: null, - terminalErrorText: null, - requestedAt: new Date("2026-03-10T12:00:00.000Z"), - startedAt: null, - completedAt: null, - createdAt: new Date("2026-03-10T12:00:00.000Z"), - updatedAt: new Date("2026-03-10T12:00:00.000Z"), - }; + ...(data.sourceImageKey !== undefined + ? { sourceImageKey: data.sourceImageKey as string } + : {}), + ...(data.imageStrength !== undefined + ? { imageStrength: data.imageStrength as Prisma.Decimal } + : {}), + ...(data.idempotencyKey !== undefined + ? { idempotencyKey: data.idempotencyKey as string } + : {}), + }); + state.generationRequests.push(request); + return request; }, - findFirst: async () => null, + findFirst: async ({ + where, + }: { + where: { userId: string; idempotencyKey: string }; + }) => + state.generationRequests.find( + (request) => + request.userId === where.userId && + request.idempotencyKey === where.idempotencyKey, + ) ?? null, }, } as unknown as Parameters[0]; @@ -128,12 +202,14 @@ function createGenerationDatabase(input: { } function createSubscriptionFixture(input: { + id?: string; + userId?: string; status: "active" | "expired" | "past_due"; currentPeriodEnd: Date; }) { return { - id: "subscription_1", - userId: "user_1", + id: input.id ?? "subscription_1", + userId: input.userId ?? "user_1", planId: "plan_1", status: input.status, renewsManually: true, @@ -156,3 +232,38 @@ function createSubscriptionFixture(input: { }, }; } + +function createGenerationRequestFixture(input: { + id: string; + userId: string; + mode?: string; + status?: string; + providerModel?: string; + prompt?: string; + sourceImageKey?: string; + resolutionPreset?: string; + batchSize?: number; + imageStrength?: Prisma.Decimal; + idempotencyKey?: string; +}) { + return { + id: input.id, + userId: input.userId, + mode: input.mode ?? "text_to_image", + status: input.status ?? "queued", + providerModel: input.providerModel ?? "nano-banana", + prompt: input.prompt ?? "hello", + sourceImageKey: input.sourceImageKey ?? null, + resolutionPreset: input.resolutionPreset ?? "1024", + batchSize: input.batchSize ?? 1, + imageStrength: input.imageStrength ?? null, + idempotencyKey: input.idempotencyKey ?? null, + terminalErrorCode: null, + terminalErrorText: null, + requestedAt: new Date("2026-03-10T12:00:00.000Z"), + startedAt: null, + completedAt: null, + createdAt: new Date("2026-03-10T12:00:00.000Z"), + updatedAt: new Date("2026-03-10T12:00:00.000Z"), + }; +}