feat: add bottomless replication

This commit is contained in:
Oliver Geneser
2025-09-14 11:30:21 +02:00
parent 307916a49a
commit 53a11b81d6
10 changed files with 7153 additions and 11 deletions

View File

@@ -39,7 +39,9 @@ import { RestoreBackup } from "./restore-backup";
interface Props {
id: string;
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
databaseType?:
| Exclude<ServiceType, "application" | "redis" | "libsql">
| "web-server";
backupType?: "database" | "compose";
}
export const ShowBackups = ({
@@ -53,14 +55,14 @@ export const ShowBackups = ({
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
@@ -77,10 +79,10 @@ export const ShowBackups = ({
const mutationMap =
backupType === "database"
? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
postgres: api.backup.manualBackupPostgres.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {

View File

@@ -0,0 +1,202 @@
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
libsqlId: string;
enableBottomlessReplication: boolean;
bottomlessReplicationDestinationId?: string | null;
}
export const ShowBottomlessReplication = ({
libsqlId,
enableBottomlessReplication,
bottomlessReplicationDestinationId,
}: Props) => {
const utils = api.useUtils();
const switchId = useId();
const commandId = useId();
const { mutateAsync, isLoading } = api.libsql.update.useMutation();
const { data: destinations, isLoading: isLoadingDestinations } =
api.destination.all.useQuery();
const [isDestinationOpen, setIsDestinationOpen] = useState(false);
const handleToggle = async (checked: boolean) => {
try {
await mutateAsync({
libsqlId,
enableBottomlessReplication: checked,
});
toast.success("Bottomless replication updated successfully");
utils.libsql.one.invalidate({ libsqlId });
} catch (error) {
toast.error("Error updating bottomless replication");
}
};
const handleDestinationSelect = async (destinationId: string | null) => {
try {
await mutateAsync({
libsqlId,
enableBottomlessReplication:
destinationId === null ? false : enableBottomlessReplication,
bottomlessReplicationDestinationId: destinationId,
});
toast.success("Bottomless replication destination updated successfully");
utils.libsql.one.invalidate({ libsqlId });
setIsDestinationOpen(false);
} catch (error) {
toast.error("Error updating bottomless replication destination");
}
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Bottomless Replication</CardTitle>
<CardDescription>
Bottomless replication allows automatically backing up your database
to an S3-compatible storage.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<AlertBlock type="warning">
The service needs to be restarted for bottomless replication changes
to take effect. Please redeploy the service after enabling or
disabling this feature.
</AlertBlock>
<div className="flex items-center justify-between">
<div className="space-y-1">
<label htmlFor={switchId} className="text-sm font-medium">
Enable Bottomless Replication
</label>
<p className="text-sm text-muted-foreground">
Automatically replicate database changes to S3-compatible storage
</p>
{!bottomlessReplicationDestinationId && (
<p className="text-sm text-orange-600">
Select a destination above to enable bottomless replication
</p>
)}
</div>
<Switch
id={switchId}
checked={enableBottomlessReplication}
onCheckedChange={handleToggle}
disabled={isLoading || !bottomlessReplicationDestinationId}
/>
</div>
<div className="space-y-2">
<label htmlFor={commandId} className="text-sm font-medium">
Destination
</label>
<p className="text-sm text-muted-foreground">
Select the S3-compatible destination for bottomless replication
</p>
<Popover open={isDestinationOpen} onOpenChange={setIsDestinationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!bottomlessReplicationDestinationId &&
"text-muted-foreground",
)}
>
{isLoadingDestinations
? "Loading...."
: bottomlessReplicationDestinationId
? destinations?.find(
(destination) =>
destination.destinationId ===
bottomlessReplicationDestinationId,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command id={commandId}>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoadingDestinations && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{destinations?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() =>
handleDestinationSelect(destination.destinationId)
}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId ===
bottomlessReplicationDestinationId
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
<CommandItem
value="none"
onSelect={() => handleDestinationSelect(null)}
>
None
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
!bottomlessReplicationDestinationId
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
</div>
</CardContent>
</Card>
);
};

View File

@@ -144,7 +144,7 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort;
return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
return `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
};
setConnectionUrl(buildConnectionUrl());
@@ -153,7 +153,7 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
if (data?.sqldNode === "replica") return "";
const port = form.watch("externalGRPCPort") || data?.externalGRPCPort;
return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
return `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
};
setGRPCConnectionUrl(buildGRPCConnectionUrl());

View File

@@ -0,0 +1,3 @@
ALTER TABLE "libsql" ADD COLUMN "enableBottomlessReplication" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "libsql" ADD COLUMN "bottomlessReplicationDestinationId" text;--> statement-breakpoint
ALTER TABLE "libsql" ADD CONSTRAINT "libsql_bottomlessReplicationDestinationId_destination_destinationId_fk" FOREIGN KEY ("bottomlessReplicationDestinationId") REFERENCES "public"."destination"("destinationId") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -785,6 +785,13 @@
"when": 1757764040899,
"tag": "0111_opposite_outlaw_kid",
"breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1757841949939,
"tag": "0112_mean_cammi",
"breakpoints": true
}
]
}

View File

@@ -13,6 +13,7 @@ import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowBottomlessReplication } from "@/components/dashboard/libsql/general/show-bottomless-replication";
import { ShowExternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-external-libsql-credentials";
import { ShowGeneralLibsql } from "@/components/dashboard/libsql/general/show-general-libsql";
import { ShowInternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-internal-libsql-credentials";
@@ -273,6 +274,19 @@ const Libsql = (
/>
</div>
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBottomlessReplication
libsqlId={libsqlId}
enableBottomlessReplication={
data?.enableBottomlessReplication || false
}
bottomlessReplicationDestinationId={
data?.bottomlessReplicationDestinationId
}
/>
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings

View File

@@ -3,6 +3,8 @@ import { boolean, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { backups } from "./backups";
import { destinations } from "./destination";
import { environments } from "./environment";
import { mounts } from "./mount";
import { server } from "./server";
@@ -42,6 +44,14 @@ export const libsql = pgTable("libsql", {
sqldNode: sqldNode("sqldNode").notNull().default("primary"),
sqldPrimaryUrl: text("sqldPrimaryUrl"),
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
enableBottomlessReplication: boolean("enableBottomlessReplication")
.notNull()
.default(false),
bottomlessReplicationDestinationId: text(
"bottomlessReplicationDestinationId",
).references(() => destinations.destinationId, {
onDelete: "set null",
}),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
env: text("env"),
@@ -89,6 +99,10 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
fields: [libsql.serverId],
references: [server.serverId],
}),
bottomlessReplicationDestination: one(destinations, {
fields: [libsql.bottomlessReplicationDestinationId],
references: [destinations.destinationId],
}),
}));
const createSchema = createInsertSchema(libsql, {
@@ -106,6 +120,8 @@ const createSchema = createInsertSchema(libsql, {
sqldNode: z.enum(sqldNode.enumValues),
sqldPrimaryUrl: z.string().nullable(),
enableNamespaces: z.boolean().default(false),
enableBottomlessReplication: z.boolean().default(false),
bottomlessReplicationDestinationId: z.string().nullable(),
dockerImage: z.string().default("ghcr.io/tursodatabase/libsql-server:latest"),
command: z.string().optional(),
env: z.string().optional(),

View File

@@ -59,6 +59,7 @@ export const findLibsqlById = async (libsqlId: string) => {
},
mounts: true,
server: true,
bottomlessReplicationDestination: true,
// backups: {
// with: {
// destination: true,

View File

@@ -31,7 +31,11 @@ const getLibsqlImage = (arch: string): string => {
export type LibsqlNested = InferResultType<
"libsql",
{ mounts: true; environment: { with: { project: true } } }
{
mounts: true;
environment: { with: { project: true } };
bottomlessReplicationDestination: true;
}
>;
export const buildLibsql = async (libsql: LibsqlNested) => {
const {
@@ -53,6 +57,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
mounts,
serverId,
enableNamespaces,
enableBottomlessReplication,
bottomlessReplicationDestination,
} = libsql;
let finalDockerImage = dockerImage;
@@ -66,10 +72,20 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
"utf-8",
).toString("base64");
const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
let defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
// Add bottomless replication environment variables if destination is configured
if (enableBottomlessReplication && bottomlessReplicationDestination) {
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_DATABASE_ID="${appName}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_BUCKET="${bottomlessReplicationDestination.bucket}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_ENDPOINT="${bottomlessReplicationDestination.endpoint}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_SECRET_ACCESS_KEY="${bottomlessReplicationDestination.secretAccessKey}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_ACCESS_KEY_ID="${bottomlessReplicationDestination.accessKey}"`;
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_DEFAULT_REGION="${bottomlessReplicationDestination.region}"`;
}
const {
HealthCheck,
RestartPolicy,
@@ -103,6 +119,9 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
if (enableNamespaces) {
finalCommand += " --enable-namespaces";
}
if (enableBottomlessReplication) {
finalCommand += " --enable-bottomless-replication";
}
const settings: CreateServiceOptions = {
Name: appName,