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
|
resolutionPreset String
|
||||||
batchSize Int
|
batchSize Int
|
||||||
imageStrength Decimal? @db.Decimal(4, 3)
|
imageStrength Decimal? @db.Decimal(4, 3)
|
||||||
idempotencyKey String? @unique
|
idempotencyKey String?
|
||||||
terminalErrorCode String?
|
terminalErrorCode String?
|
||||||
terminalErrorText String?
|
terminalErrorText String?
|
||||||
requestedAt DateTime @default(now())
|
requestedAt DateTime @default(now())
|
||||||
@@ -200,6 +200,7 @@ model GenerationRequest {
|
|||||||
assets GeneratedAsset[]
|
assets GeneratedAsset[]
|
||||||
usageLedgerEntry UsageLedgerEntry?
|
usageLedgerEntry UsageLedgerEntry?
|
||||||
|
|
||||||
|
@@unique([userId, idempotencyKey])
|
||||||
@@index([userId, status, requestedAt])
|
@@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 () => {
|
test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => {
|
||||||
const database = createGenerationDatabase({
|
const database = createGenerationDatabase({
|
||||||
subscription: createSubscriptionFixture({
|
subscriptions: [
|
||||||
|
createSubscriptionFixture({
|
||||||
status: "active",
|
status: "active",
|
||||||
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
|
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
|
||||||
}),
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
const store = createPrismaGenerationStore(database.client);
|
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.subscriptionUpdateMany.length, 1);
|
||||||
assert.equal(database.calls.generationRequestCreate.length, 0);
|
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: {
|
function createGenerationDatabase(input: {
|
||||||
subscription: ReturnType<typeof createSubscriptionFixture> | null;
|
subscriptions: Array<ReturnType<typeof createSubscriptionFixture>>;
|
||||||
|
generationRequests?: Array<ReturnType<typeof createGenerationRequestFixture>>;
|
||||||
}) {
|
}) {
|
||||||
const calls = {
|
const calls = {
|
||||||
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
|
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
|
||||||
@@ -41,7 +99,8 @@ function createGenerationDatabase(input: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
subscription: input.subscription,
|
subscriptions: [...input.subscriptions],
|
||||||
|
generationRequests: [...(input.generationRequests ?? [])],
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
@@ -51,15 +110,19 @@ function createGenerationDatabase(input: {
|
|||||||
}: {
|
}: {
|
||||||
where: { userId: string; status?: "active" };
|
where: { userId: string; status?: "active" };
|
||||||
}) => {
|
}) => {
|
||||||
if (!state.subscription || state.subscription.userId !== where.userId) {
|
return (
|
||||||
return null;
|
state.subscriptions.find((subscription) => {
|
||||||
|
if (subscription.userId !== where.userId) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (where.status && state.subscription.status !== where.status) {
|
if (where.status && subscription.status !== where.status) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.subscription;
|
return true;
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
},
|
},
|
||||||
updateMany: async ({
|
updateMany: async ({
|
||||||
where,
|
where,
|
||||||
@@ -70,19 +133,21 @@ function createGenerationDatabase(input: {
|
|||||||
}) => {
|
}) => {
|
||||||
calls.subscriptionUpdateMany.push({ where, data });
|
calls.subscriptionUpdateMany.push({ where, data });
|
||||||
|
|
||||||
if (
|
let updatedCount = 0;
|
||||||
state.subscription &&
|
|
||||||
state.subscription.id === where.id &&
|
state.subscriptions = state.subscriptions.map((subscription) => {
|
||||||
state.subscription.status === where.status
|
if (subscription.id !== where.id || subscription.status !== where.status) {
|
||||||
) {
|
return subscription;
|
||||||
state.subscription = {
|
|
||||||
...state.subscription,
|
|
||||||
status: data.status,
|
|
||||||
};
|
|
||||||
return { count: 1 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { count: 0 };
|
updatedCount += 1;
|
||||||
|
return {
|
||||||
|
...subscription,
|
||||||
|
status: data.status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { count: updatedCount };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
usageLedgerEntry: {
|
usageLedgerEntry: {
|
||||||
@@ -95,28 +160,37 @@ function createGenerationDatabase(input: {
|
|||||||
generationRequest: {
|
generationRequest: {
|
||||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
calls.generationRequestCreate.push({ data });
|
calls.generationRequestCreate.push({ data });
|
||||||
return {
|
const request = createGenerationRequestFixture({
|
||||||
id: "request_1",
|
id: `request_${state.generationRequests.length + 1}`,
|
||||||
userId: data.userId as string,
|
userId: data.userId as string,
|
||||||
mode: data.mode as string,
|
mode: data.mode as string,
|
||||||
status: "queued",
|
|
||||||
providerModel: data.providerModel as string,
|
providerModel: data.providerModel as string,
|
||||||
prompt: data.prompt as string,
|
prompt: data.prompt as string,
|
||||||
sourceImageKey: null,
|
|
||||||
resolutionPreset: data.resolutionPreset as string,
|
resolutionPreset: data.resolutionPreset as string,
|
||||||
batchSize: data.batchSize as number,
|
batchSize: data.batchSize as number,
|
||||||
imageStrength: null,
|
...(data.sourceImageKey !== undefined
|
||||||
idempotencyKey: null,
|
? { sourceImageKey: data.sourceImageKey as string }
|
||||||
terminalErrorCode: null,
|
: {}),
|
||||||
terminalErrorText: null,
|
...(data.imageStrength !== undefined
|
||||||
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
|
? { imageStrength: data.imageStrength as Prisma.Decimal }
|
||||||
startedAt: null,
|
: {}),
|
||||||
completedAt: null,
|
...(data.idempotencyKey !== undefined
|
||||||
createdAt: new Date("2026-03-10T12:00:00.000Z"),
|
? { idempotencyKey: data.idempotencyKey as string }
|
||||||
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
|
: {}),
|
||||||
};
|
});
|
||||||
|
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];
|
} as unknown as Parameters<typeof createPrismaGenerationStore>[0];
|
||||||
|
|
||||||
@@ -128,12 +202,14 @@ function createGenerationDatabase(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createSubscriptionFixture(input: {
|
function createSubscriptionFixture(input: {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
status: "active" | "expired" | "past_due";
|
status: "active" | "expired" | "past_due";
|
||||||
currentPeriodEnd: Date;
|
currentPeriodEnd: Date;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: "subscription_1",
|
id: input.id ?? "subscription_1",
|
||||||
userId: "user_1",
|
userId: input.userId ?? "user_1",
|
||||||
planId: "plan_1",
|
planId: "plan_1",
|
||||||
status: input.status,
|
status: input.status,
|
||||||
renewsManually: true,
|
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