feat: PlanetScale managed-MySQL preset with setup guide and host detection (Task 3)

This commit is contained in:
2026-09-09 18:05:13 +08:00
parent 28ce32ce91
commit 45e3327db4
10 changed files with 67 additions and 17 deletions
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps { export interface DetailedConnectionFormProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null; managedPreset?: "supabase" | "neon" | "planetscale" | null;
} }
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps { export interface GeneralTabProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null; managedPreset?: "supabase" | "neon" | "planetscale" | null;
} }
const AUTH_OPTIONS = ["User & Password"]; const AUTH_OPTIONS = ["User & Password"];
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
)} )}
{managedPreset && ( {managedPreset && (
<p className="text-xs text-accent-muted mt-1.5"> <p className="text-xs text-accent-muted mt-1.5">
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL. {managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.
</p> </p>
)} )}
</div> </div>
@@ -74,7 +74,7 @@ export function NewConnectionScreen({
}: NewConnectionScreenProps) { }: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry"); const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState< const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | null "supabase" | "neon" | "planetscale" | null
>(null); >(null);
const [form, setForm] = useState<ConnectionFormData>(() => const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm( createEmptyForm(
@@ -99,7 +99,7 @@ export function NewConnectionScreen({
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (parsed) {
const provider = const provider =
parsed.db_type === "postgresql" parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host) ? detectProviderFromHost(parsed.host)
: null; : null;
setManagedPreset(provider); setManagedPreset(provider);
@@ -139,7 +139,9 @@ export function NewConnectionScreen({
(id: ProviderId) => { (id: ProviderId) => {
const provider = getProviderById(id)!; const provider = getProviderById(id)!;
setManagedPreset( setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null, provider.isManagedPreset
? (id as "supabase" | "neon" | "planetscale")
: null,
); );
revealConfigured({ revealConfigured({
db_type: provider.dbType, db_type: provider.dbType,
@@ -148,6 +150,7 @@ export function NewConnectionScreen({
? null ? null
: getDefaultPort(provider.dbType), : getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}), ...(provider.dbType === "sqlite" ? { host: "" } : {}),
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
}); });
}, },
[revealConfigured], [revealConfigured],
@@ -164,7 +167,7 @@ export function NewConnectionScreen({
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (parsed) {
const provider = const provider =
parsed.db_type === "postgresql" parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host) ? detectProviderFromHost(parsed.host)
: null; : null;
setManagedPreset(provider); setManagedPreset(provider);
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
import { SETUP_GUIDES } from "../../lib/providers"; import { SETUP_GUIDES } from "../../lib/providers";
interface ProviderSetupGuideProps { interface ProviderSetupGuideProps {
provider: "supabase" | "neon"; provider: "supabase" | "neon" | "planetscale";
} }
const SSL_NOTE: Record<"supabase" | "neon", string> = { const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = {
supabase: "SSL is required by Supabase.", supabase: "SSL is required by Supabase.",
neon: "Neon requires SSL.", neon: "Neon requires SSL.",
planetscale: "PlanetScale requires SSL with identity verification.",
}; };
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) { export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
@@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps
}`} }`}
> >
<span className="w-8 h-8 flex items-center justify-center"> <span className="w-8 h-8 flex items-center justify-center">
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />} {p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon" | "planetscale"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
</span> </span>
<span className="text-sm font-medium">{p.label}</span> <span className="text-sm font-medium">{p.label}</span>
{p.isManagedPreset && ( {p.isManagedPreset && (
+13
View File
@@ -194,6 +194,19 @@ describe("detectProviderFromHost", () => {
}); });
}); });
describe("detectProviderFromHost", () => {
it("detects PlanetScale access hosts", () => {
expect(detectProviderFromHost("xxxx.us-east-2.psdb.cloud")).toBe("planetscale");
expect(detectProviderFromHost("aws.connect.psdb.cloud")).toBe("planetscale");
});
it("still detects supabase and neon", () => {
expect(detectProviderFromHost("db.abc.supabase.co")).toBe("supabase");
expect(detectProviderFromHost("ep-1-pooler.eu.aws.neon.tech")).toBe("neon");
expect(detectProviderFromHost("example.com")).toBeNull();
});
});
describe("buildConnectionUrl", () => { describe("buildConnectionUrl", () => {
it("builds a PostgreSQL URL with password and sslmode", () => { it("builds a PostgreSQL URL with password and sslmode", () => {
const url = buildConnectionUrl( const url = buildConnectionUrl(
+4 -1
View File
@@ -85,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean {
/** Detect a managed-PostgreSQL provider from a connection host, purely for /** Detect a managed-PostgreSQL provider from a connection host, purely for
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is * UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
* unaffected (both presets persist as `postgresql`). */ * unaffected (both presets persist as `postgresql`). */
export function detectProviderFromHost(host: string): "supabase" | "neon" | null { export function detectProviderFromHost(
host: string,
): "supabase" | "neon" | "planetscale" | null {
const h = host.toLowerCase(); const h = host.toLowerCase();
if (h.endsWith(".supabase.co")) return "supabase"; if (h.endsWith(".supabase.co")) return "supabase";
if (h.endsWith(".neon.tech")) return "neon"; if (h.endsWith(".neon.tech")) return "neon";
if (h.endsWith(".psdb.cloud")) return "planetscale";
return null; return null;
} }
+4 -2
View File
@@ -1,4 +1,4 @@
import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons";
import type { DbType } from "./types"; import type { DbType } from "./types";
// Brand colors from simple-icons // Brand colors from simple-icons
@@ -64,16 +64,18 @@ export const DB_ICONS: Record<DbType, string> = {
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ────────── // ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
// These are NOT DbType values; connections persist as db_type="postgresql". // These are NOT DbType values; connections persist as db_type="postgresql".
type ProviderIconId = "supabase" | "neon"; type ProviderIconId = "supabase" | "neon" | "planetscale";
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = { const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path }, supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
neon: { hex: `#${siNeon.hex}`, path: siNeon.path }, neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path },
}; };
export const PROVIDER_LABELS: Record<ProviderIconId, string> = { export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
supabase: "Supabase", supabase: "Supabase",
neon: "NeonDB", neon: "NeonDB",
planetscale: "PlanetScale",
}; };
interface ProviderIconProps { interface ProviderIconProps {
+18 -2
View File
@@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest";
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers"; import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
describe("providers", () => { describe("providers", () => {
it("defines exactly 7 provider tabs in order", () => { it("defines exactly 8 provider tabs in order", () => {
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([ expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
"postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale",
]); ]);
}); });
@@ -40,4 +40,20 @@ describe("providers", () => {
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true); expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
expect(SETUP_GUIDES.neon.sslRequired).toBe(true); expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
}); });
});
describe("PlanetScale preset", () => {
it("registers as a managed MySQL preset persisting db_type mysql", () => {
const tab = PROVIDER_TABS.find((p) => p.id === "planetscale");
expect(tab).toBeDefined();
expect(tab!.dbType).toBe("mysql");
expect(tab!.isManagedPreset).toBe(true);
});
it("ships a setup guide requiring SSL with concrete steps", () => {
const guide = SETUP_GUIDES.planetscale;
expect(guide.sslRequired).toBe(true);
expect(guide.steps.length).toBeGreaterThanOrEqual(4);
expect(guide.steps.some((s) => s.detail.includes("psdb.cloud"))).toBe(true);
});
}); });
+14 -2
View File
@@ -1,6 +1,6 @@
import type { DbType } from "./types"; import type { DbType } from "./types";
export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon"; export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale";
export interface ProviderTab { export interface ProviderTab {
id: ProviderId; id: ProviderId;
@@ -20,6 +20,7 @@ export const PROVIDER_TABS: ProviderTab[] = [
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false }, { id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true }, { id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true }, { id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
{ id: "planetscale", label: "PlanetScale", dbType: "mysql", isManagedPreset: true },
]; ];
export function getProviderById(id: string): ProviderTab | undefined { export function getProviderById(id: string): ProviderTab | undefined {
@@ -40,7 +41,7 @@ export interface SetupGuide {
/** Static, researched setup instructions. No network calls at runtime. /** Static, researched setup instructions. No network calls at runtime.
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */ * Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = { export const SETUP_GUIDES: Record<"supabase" | "neon" | "planetscale", SetupGuide> = {
supabase: { supabase: {
blurb: "Managed PostgreSQL with a pooled connection string.", blurb: "Managed PostgreSQL with a pooled connection string.",
sslRequired: true, sslRequired: true,
@@ -66,6 +67,17 @@ export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
}, },
], ],
}, },
planetscale: {
blurb: "Managed MySQL (Vitess) with database branching.",
sslRequired: true,
steps: [
{ title: "Create a branch password", detail: "In the PlanetScale dashboard open your database → Connect → pick a branch → Create password. The password (pscale_pw_…) is shown ONCE and works only for that branch." },
{ title: "Copy the credentials", detail: "Note the Access Host URL (e.g. aws.connect.psdb.cloud or xxxx.us-east-2.psdb.cloud), the username, the password, and your database name." },
{ title: "Fill Host / Port / Database", detail: "Host is the access host, port is 3306, Database is your database name. Save stores the password in your OS keychain." },
{ title: "SSL is mandatory", detail: "Gridline pre-selects Verify Full — PlanetScale requires TLS with identity verification." },
{ title: "Vitess caveats", detail: "Passwords are branch-scoped; deleting one disconnects its clients within 5 minutes. With Safe Migrations ON, schema changes go through deploy requests, so the changes queue may be blocked on production branches. Foreign keys follow your database's foreign_key_constraint setting." },
],
},
neon: { neon: {
blurb: "Serverless Postgres with pooled or direct endpoints.", blurb: "Serverless Postgres with pooled or direct endpoints.",
sslRequired: true, sslRequired: true,