diff --git a/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/migration.sql b/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/migration.sql new file mode 100644 index 000000000..d08532f27 --- /dev/null +++ b/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "Project" +ADD COLUMN "isAvailableAsPreviewProject" BOOLEAN NOT NULL DEFAULT false; + +-- Partial index for fast pool claiming: only indexes the (tiny) subset of rows +-- that are currently available, ordered by creation time so the oldest is claimed first. +CREATE INDEX "Project_isAvailableAsPreviewProject_createdAt_idx" +ON "Project" ("createdAt" ASC) +WHERE "isAvailableAsPreviewProject" = true; diff --git a/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/tests/default-value.ts b/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/tests/default-value.ts new file mode 100644 index 000000000..ee958ae04 --- /dev/null +++ b/apps/backend/prisma/migrations/20260624000000_add_is_available_as_preview_project/tests/default-value.ts @@ -0,0 +1,22 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Preview Pool Test Project', '', false) + `; + return { projectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "isAvailableAsPreviewProject" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].isAvailableAsPreviewProject).toBe(false); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 8eb12390e..24afecea6 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -30,6 +30,8 @@ model Project { onboardingStatus String @default("completed") onboardingState Json? + isAvailableAsPreviewProject Boolean @default(false) + logoUrl String? logoFullUrl String? logoDarkModeUrl String? diff --git a/apps/backend/src/app/api/latest/internal/preview/create-project/route.tsx b/apps/backend/src/app/api/latest/internal/preview/create-project/route.tsx index 2eebcb859..e57abe1e9 100644 --- a/apps/backend/src/app/api/latest/internal/preview/create-project/route.tsx +++ b/apps/backend/src/app/api/latest/internal/preview/create-project/route.tsx @@ -1,12 +1,69 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { isPreviewModeEnabled } from "@/lib/preview-mode"; import { seedDummyProject } from "@/lib/seed-dummy-data"; -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { Prisma } from "@/generated/prisma/client"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { ignoreUnhandledRejection } from "@hexclave/shared/dist/utils/promises"; +/** + * Atomically claims one pre-seeded preview project from the pool by flipping + * its `isAvailableAsPreviewProject` flag to false and assigning the given owner + * team. Returns the project ID if one was available, or null otherwise. + */ +async function claimPoolProject(ownerTeamId: string): Promise { + const rows = await globalPrismaClient.$queryRaw>(Prisma.sql` + UPDATE "Project" + SET "isAvailableAsPreviewProject" = false, + "ownerTeamId" = ${ownerTeamId}::uuid, + "updatedAt" = NOW() + WHERE "id" = ( + SELECT "id" FROM "Project" + WHERE "isAvailableAsPreviewProject" = true + ORDER BY "createdAt" ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING "id" + `); + return rows[0]?.id ?? null; +} + +/** + * Asynchronously seeds a new preview project into the pool (with + * isAvailableAsPreviewProject = true) so a future request can claim it + * instantly. + * + * Pool projects have ownerTeamId = null so they don't appear in any user's + * dashboard. The claim query assigns the real ownerTeamId when a project is + * claimed. + */ +function replenishPreviewProjectPool(ownerTeamId: string): void { + runAsynchronouslyAndWaitUntil(async () => { + const clickhouseClient = getClickhouseAdminClient(); + const projectId = await seedDummyProject({ + ownerTeamId, + oauthProviderIds: ['github', 'google', 'microsoft', 'spotify'], + excludeAlphaApps: true, + skipGithubConfigSource: true, + clickhouseClient, + }); + // Mark as available and null out ownerTeamId so the pool project doesn't + // appear in the seeding user's dashboard. The claim query sets the real + // ownerTeamId when the project is claimed. + await globalPrismaClient.project.update({ + where: { id: projectId }, + data: { + isAvailableAsPreviewProject: true, + ownerTeamId: null, + }, + }); + }); +} + export const POST = createSmartRouteHandler({ metadata: { summary: "Create a preview project", @@ -37,17 +94,6 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.Forbidden, "This endpoint is only available in preview mode"); } - // Pre-warm the ClickHouse Cloud connection, then hand the same client to - // seedDummyProject so every analytics insert reuses it. The first insert - // otherwise pays a one-time ~0.7s cold cost (idle-service wake-up + TLS). - // Firing a trivial query now — unawaited — overlaps that wake-up and the - // TLS handshake with the Postgres-heavy seeding below; threading the warmed - // client through means the handshake is established exactly once. - const clickhouseClient = getClickhouseAdminClient(); - const clickhouseWarmup = clickhouseClient - .command({ query: "SELECT 1" }); - ignoreUnhandledRejection(clickhouseWarmup); - const userId = auth.user.id; const prisma = await getPrismaClientForTenancy(auth.tenancy); @@ -66,17 +112,33 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, "User must belong to a team to create a preview project"); } - const projectId = await seedDummyProject({ - ownerTeamId: membership.teamId, - oauthProviderIds: ['github', 'google', 'microsoft', 'spotify'], - excludeAlphaApps: true, - skipGithubConfigSource: true, - clickhouseClient, - }); + // Try to claim a pre-seeded project from the pool (near-instant). + const claimedProjectId = await claimPoolProject(membership.teamId); - // Settle the warm-up promise (long since resolved by now) so it does not - // float past the handler return. - await clickhouseWarmup; + let projectId: string; + if (claimedProjectId) { + projectId = claimedProjectId; + } else { + // Pool empty — fall back to creating a fresh project synchronously. + const clickhouseClient = getClickhouseAdminClient(); + const clickhouseWarmup = clickhouseClient.command({ query: "SELECT 1" }); + ignoreUnhandledRejection(clickhouseWarmup); + + projectId = await seedDummyProject({ + ownerTeamId: membership.teamId, + oauthProviderIds: ['github', 'google', 'microsoft', 'spotify'], + excludeAlphaApps: true, + skipGithubConfigSource: true, + clickhouseClient, + }); + + await clickhouseWarmup; + } + + // Replenish the pool asynchronously so the next request can be served + // instantly. ownerTeamId is needed for seedDummyProject but gets nulled out + // afterward — the claim query assigns the real owner. + replenishPreviewProjectPool(membership.teamId); return { statusCode: 200,