feat: PlanetScale managed-MySQL preset with setup guide and host detection (Task 3)
This commit is contained in:
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface GeneralTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
}
|
||||
|
||||
const AUTH_OPTIONS = ["User & Password"];
|
||||
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
||||
)}
|
||||
{managedPreset && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function NewConnectionScreen({
|
||||
}: NewConnectionScreenProps) {
|
||||
const [stage, setStage] = useState<Stage>("entry");
|
||||
const [managedPreset, setManagedPreset] = useState<
|
||||
"supabase" | "neon" | null
|
||||
"supabase" | "neon" | "planetscale" | null
|
||||
>(null);
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(
|
||||
@@ -99,7 +99,7 @@ export function NewConnectionScreen({
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
@@ -139,7 +139,9 @@ export function NewConnectionScreen({
|
||||
(id: ProviderId) => {
|
||||
const provider = getProviderById(id)!;
|
||||
setManagedPreset(
|
||||
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
|
||||
provider.isManagedPreset
|
||||
? (id as "supabase" | "neon" | "planetscale")
|
||||
: null,
|
||||
);
|
||||
revealConfigured({
|
||||
db_type: provider.dbType,
|
||||
@@ -148,6 +150,7 @@ export function NewConnectionScreen({
|
||||
? null
|
||||
: getDefaultPort(provider.dbType),
|
||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
||||
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
|
||||
});
|
||||
},
|
||||
[revealConfigured],
|
||||
@@ -164,7 +167,7 @@ export function NewConnectionScreen({
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
|
||||
import { SETUP_GUIDES } from "../../lib/providers";
|
||||
|
||||
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.",
|
||||
neon: "Neon requires SSL.",
|
||||
planetscale: "PlanetScale requires SSL with identity verification.",
|
||||
};
|
||||
|
||||
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">
|
||||
{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 className="text-sm font-medium">{p.label}</span>
|
||||
{p.isManagedPreset && (
|
||||
|
||||
@@ -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", () => {
|
||||
it("builds a PostgreSQL URL with password and sslmode", () => {
|
||||
const url = buildConnectionUrl(
|
||||
|
||||
@@ -85,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean {
|
||||
/** Detect a managed-PostgreSQL provider from a connection host, purely for
|
||||
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
|
||||
* 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();
|
||||
if (h.endsWith(".supabase.co")) return "supabase";
|
||||
if (h.endsWith(".neon.tech")) return "neon";
|
||||
if (h.endsWith(".psdb.cloud")) return "planetscale";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
// Brand colors from simple-icons
|
||||
@@ -64,16 +64,18 @@ export const DB_ICONS: Record<DbType, string> = {
|
||||
|
||||
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
||||
// 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 }> = {
|
||||
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
||||
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
||||
planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path },
|
||||
};
|
||||
|
||||
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
||||
supabase: "Supabase",
|
||||
neon: "NeonDB",
|
||||
planetscale: "PlanetScale",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./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([
|
||||
"postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon",
|
||||
"postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -41,3 +41,19 @@ describe("providers", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
id: ProviderId;
|
||||
@@ -20,6 +20,7 @@ export const PROVIDER_TABS: ProviderTab[] = [
|
||||
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
|
||||
{ id: "supabase", label: "Supabase", 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 {
|
||||
@@ -40,7 +41,7 @@ export interface SetupGuide {
|
||||
|
||||
/** Static, researched setup instructions. No network calls at runtime.
|
||||
* 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: {
|
||||
blurb: "Managed PostgreSQL with a pooled connection string.",
|
||||
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: {
|
||||
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
||||
sslRequired: true,
|
||||
|
||||
Reference in New Issue
Block a user