fix: scope generation idempotency per user
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
DROP INDEX "GenerationRequest_idempotencyKey_key";
|
||||
|
||||
CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key"
|
||||
ON "GenerationRequest"("userId", "idempotencyKey");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
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<typeof createSubscriptionFixture> | null;
|
||||
subscriptions: Array<ReturnType<typeof createSubscriptionFixture>>;
|
||||
generationRequests?: Array<ReturnType<typeof createGenerationRequestFixture>>;
|
||||
}) {
|
||||
const calls = {
|
||||
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
|
||||
@@ -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,
|
||||
status: data.status,
|
||||
};
|
||||
return { count: 1 };
|
||||
let updatedCount = 0;
|
||||
|
||||
state.subscriptions = state.subscriptions.map((subscription) => {
|
||||
if (subscription.id !== where.id || subscription.status !== where.status) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
return { count: 0 };
|
||||
updatedCount += 1;
|
||||
return {
|
||||
...subscription,
|
||||
status: data.status,
|
||||
};
|
||||
});
|
||||
|
||||
return { count: updatedCount };
|
||||
},
|
||||
},
|
||||
usageLedgerEntry: {
|
||||
@@ -95,28 +160,37 @@ function createGenerationDatabase(input: {
|
||||
generationRequest: {
|
||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||
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<typeof createPrismaGenerationStore>[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"),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user