Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: "lts/*"
cache: "pnpm"

- name: Install Dependencies
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ CLAIM_DB_WORKER_URL="http://127.0.0.1:9999"

### Prerequisites

- Node.js 18+
- Node.js 22.12+ (the published CLIs run on Node.js 20.12+)
- pnpm
- Cloudflare account (for worker development)

Expand Down
4 changes: 2 additions & 2 deletions apps/claim-db-worker/__tests__/callback-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("auth callback API", () => {
const { exchangeCodeForToken, validateProject } = await import(
"@/lib/auth-utils"
);
const { redirectToError, redirectToSuccess } = await import(
const { redirectToSuccess } = await import(
"@/lib/response-utils"
);
const { transferProject } = await import("@/lib/project-transfer");
Expand Down Expand Up @@ -87,7 +87,7 @@ describe("auth callback API", () => {
"http://localhost:3000/api/auth/callback?code=test-code&state=test-state&projectID=test-project-123"
);

const response = await GET(request);
await GET(request);

expect(exchangeCodeForToken).toHaveBeenCalledWith(
"test-code",
Expand Down
6 changes: 4 additions & 2 deletions apps/claim-db-worker/__tests__/claim.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ describe("claim page", () => {
beforeEach(() => {
vi.clearAllMocks();

vi.mocked(useSearchParams).mockReturnValue(mockSearchParams as any);
vi.mocked(useSearchParams).mockReturnValue(
mockSearchParams as unknown as ReturnType<typeof useSearchParams>
);
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
} as any);
} as unknown as ReturnType<typeof useRouter>);
vi.mocked(usePathname).mockReturnValue("/claim");

mockFetch.mockResolvedValue({
Expand Down
4 changes: 3 additions & 1 deletion apps/claim-db-worker/__tests__/success.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ describe("success page", () => {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSearchParams).mockReturnValue(mockSearchParams as any);
vi.mocked(useSearchParams).mockReturnValue(
mockSearchParams as unknown as ReturnType<typeof useSearchParams>
);
});

describe("rendering", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/claim-db-worker/app/api/create-db/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";

interface PrismaProject {
id?: string;
Expand All @@ -22,7 +22,7 @@ interface PrismaProject {
const CREATE_DB_WORKER_URL =
process.env.CREATE_DB_WORKER_URL || "https://create-db-temp.prisma.io";

export async function POST(request: NextRequest) {
export async function POST() {
try {
const name = new Date().toISOString();

Expand Down
131 changes: 81 additions & 50 deletions apps/claim-db-worker/app/api/studio/route.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { createPrismaPostgresHttpClient } from "@prisma/studio-core/data/ppg";
import { serializeError } from "@prisma/studio-core/data/bff";
import postgres from "postgres";
import { serializeError, type StudioBFFRequest } from "@prisma/studio-core/data/bff";
import {
createPostgresJSConnectionConfig,
createPostgresJSExecutor,
} from "@prisma/studio-core/data/postgresjs";

export async function POST(request: NextRequest) {
/**
* The Prisma Postgres host the connection string points at, the only databases
* create-db makes, or undefined for anything else.
*/
function prismaPostgresHost(connectionString: string) {
try {
// 1. Check if request has content
const contentType = request.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
return NextResponse.json([
serializeError(new Error("Content-Type must be application/json")),
]);
}

// 2. Extract the query and custom data from the request
let body;
try {
const text = await request.text();
if (!text || text.trim() === "") {
return NextResponse.json([
serializeError(new Error("Request body is empty")),
]);
}
body = JSON.parse(text) as { query: any };
} catch (parseError) {
return NextResponse.json([
serializeError(
new Error(
`Invalid JSON: ${parseError instanceof Error ? parseError.message : "Unknown error"}`
)
),
]);
}
const url = new URL(connectionString);
const isPrismaPostgres =
(url.protocol === "postgres:" || url.protocol === "postgresql:") &&
/^(?:[a-z0-9-]+\.)*db\.prisma\.io$/.test(url.hostname) &&
(url.port === "" || url.port === "5432");
return isPrismaPostgres ? url.hostname : undefined;
} catch {
return undefined;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const { query } = body;
/** Studio's backend: runs the queries Studio sends against the user's database. */
export async function POST(request: NextRequest) {
const connectionString = request.headers.get("X-Connection-String");
if (!connectionString) {
return NextResponse.json([serializeError(new Error("Connection string not provided"))]);
}
// This route is public, so it must not open connections to arbitrary hosts.
const host = prismaPostgresHost(connectionString);
if (!host) {
return NextResponse.json([
serializeError(new Error("Only Prisma Postgres connection strings are supported")),
]);
}

if (!query) {
return NextResponse.json([
serializeError(new Error("Query is required in request body")),
]);
}
let payload: StudioBFFRequest;
try {
payload = (await request.json()) as StudioBFFRequest;
} catch (parseError) {
return NextResponse.json([
serializeError(
new Error(
`Invalid JSON: ${parseError instanceof Error ? parseError.message : "Unknown error"}`
)
),
]);
}

// 3. Get connection string from custom header
const connectionString = request.headers.get("X-Connection-String");
// One client per request: a worker can't keep connections between requests.
const config = createPostgresJSConnectionConfig(connectionString);
const sql = postgres(config.connectionString, {
...config.options,
// postgres.js parses the host itself (a comma means several hosts), so
// pin the one checked above.
host,
port: 5432,
max: 1,
connect_timeout: 10,
});
const executor = createPostgresJSExecutor(sql);

if (!connectionString) {
return NextResponse.json([
serializeError(new Error("Connection string not provided")),
]);
try {
if (payload.procedure === "query") {
const [error, result] = await executor.execute(payload.query, {
schema: payload.schema,
});
return NextResponse.json([error ? serializeError(error) : null, result]);
}

// 4. Execute the query against Prisma Postgres
const [error, results] = await createPrismaPostgresHttpClient({
url: connectionString,
}).execute(query);
if (payload.procedure === "transaction" && executor.executeTransaction) {
const [error, result] = await executor.executeTransaction(payload.queries);
return NextResponse.json([error ? serializeError(error) : null, result]);
}

// 5. Return results or errors
if (error) {
return NextResponse.json([serializeError(error)]);
if (payload.procedure === "sql-lint" && executor.lintSql) {
const [error, result] = await executor.lintSql({
schema: payload.schema,
schemaVersion: payload.schemaVersion,
sql: payload.sql,
});
return NextResponse.json([error ? serializeError(error) : null, result]);
}

return NextResponse.json([null, results]);
return new NextResponse(`${payload.procedure} is not supported`, { status: 501 });
} catch (error) {
console.error("Studio API error:", error);
return NextResponse.json([serializeError(error as Error)]);
} finally {
// A failed close must not replace the response.
await sql.end().catch((error: unknown) => {
console.error("Failed to close the Studio connection:", error);
});
}
}
4 changes: 2 additions & 2 deletions apps/claim-db-worker/app/api/test/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
export async function GET() {
return NextResponse.json(
{
status: 'success',
Expand Down
60 changes: 37 additions & 23 deletions apps/claim-db-worker/app/web/DatabaseContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { createContext, useContext, useState, useEffect } from "react";
import React, { createContext, useCallback, useContext, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { cookieUtils } from "@/lib/utils";
import { useDropContext } from "../contexts/DropContext";
Expand Down Expand Up @@ -39,6 +39,35 @@ interface DatabaseProviderProps {
children: React.ReactNode;
}

/** The create-db worker's response, as far as this context reads it. */
interface CreateDatabaseResponse {
projectId?: string;
databaseId?: string;
response?: {
data?: {
database?: {
connectionString: string;
directConnection?: { user: string; pass: string; host: string };
};
};
};
}

const saveToCookie = (data: DatabaseInfo) => {
cookieUtils.set(DB_KEY, JSON.stringify(data), 1);
};

const loadFromCookie = (): Partial<DatabaseInfo> | null => {
try {
const stored = cookieUtils.get(DB_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
return Date.now() > data.expirationTime ? null : data;
} catch {
return null;
}
};

export const DatabaseProvider = ({ children }: DatabaseProviderProps) => {
const [dbInfo, setDbInfo] = useState<DatabaseInfo>({
connectionString: "",
Expand All @@ -51,21 +80,6 @@ export const DatabaseProvider = ({ children }: DatabaseProviderProps) => {
const router = useRouter();
const { setIsLoading } = useDropContext();

const saveToCookie = (data: DatabaseInfo) => {
cookieUtils.set(DB_KEY, JSON.stringify(data), 1);
};

const loadFromCookie = () => {
try {
const stored = cookieUtils.get(DB_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
return Date.now() > data.expirationTime ? null : data;
} catch {
return null;
}
};

const clearDatabase = () => {
cookieUtils.remove(DB_KEY);
setDbInfo({
Expand All @@ -77,15 +91,15 @@ export const DatabaseProvider = ({ children }: DatabaseProviderProps) => {
});
};

const refreshDatabase = async () => {
const refreshDatabase = useCallback(async () => {
setLoading(true);
setIsLoading(true);
try {
const response = await fetch("/api/create-db", { method: "POST" });
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const result = (await response.json()) as any;
const result = (await response.json()) as CreateDatabaseResponse;
const db = result.response?.data?.database;

if (db) {
Expand All @@ -94,23 +108,23 @@ export const DatabaseProvider = ({ children }: DatabaseProviderProps) => {
: "";

const newDbInfo = {
projectId: result.projectId,
projectId: result.projectId ?? "",
connectionString: db.connectionString,
directConnectionString: directConnString,
expirationTime: Date.now() + 24 * 60 * 60 * 1000,
databaseId: result.databaseId,
databaseId: result.databaseId ?? "",
};

setDbInfo(newDbInfo);
saveToCookie(newDbInfo);
}
} catch (error) {
} catch {
router.replace(`/error?title=Error&message=Failed to create database`);
} finally {
setLoading(false);
setIsLoading(false);
}
};
}, [router, setIsLoading]);

useEffect(() => {
const initializeDatabase = async () => {
Expand All @@ -133,7 +147,7 @@ export const DatabaseProvider = ({ children }: DatabaseProviderProps) => {
};

initializeDatabase();
}, [router, setIsLoading]);
}, [refreshDatabase, setIsLoading]);

const value = {
dbInfo,
Expand Down
Loading
Loading