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 {
|
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 && (
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,3 +41,19 @@ describe("providers", () => {
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user