feat(libsql): add support for libsql database backups and restores

- Updated backup and restore functionalities to include support for the 'libsql' database type.
- Enhanced the backup process with new methods for running and restoring libsql backups.
- Modified existing components and schemas to accommodate libsql, including updates to the database type enumerations and backup schemas.
- Removed obsolete bottomless replication features from the libsql schema.
- Updated related UI components to reflect changes in backup handling for libsql.
This commit is contained in:
Mauricio Siu
2026-03-19 16:00:39 -06:00
parent a03ec76b6f
commit bb56a0bae8
25 changed files with 8420 additions and 291 deletions

View File

@@ -65,7 +65,7 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server" | "libsql";
const Schema = z
.object({
@@ -77,7 +77,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
@@ -209,7 +209,7 @@ export const HandleBackup = ({
const form = useForm({
defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "",
database: databaseType === "web-server" ? "dokploy" : databaseType === "libsql" ? "iku.db" : "",
destinationId: "",
enabled: true,
prefix: "/",
@@ -246,7 +246,9 @@ export const HandleBackup = ({
? backup?.database
: databaseType === "web-server"
? "dokploy"
: "",
: databaseType === "libsql"
? "iku.db"
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
@@ -281,6 +283,10 @@ export const HandleBackup = ({
? {
mongoId: id,
}
: databaseType === "libsql"
? {
libsqlId: id,
}
: databaseType === "web-server"
? {
userId: id,

View File

@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
message: "Database name is required",
}),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
metadata: z

View File

@@ -40,7 +40,7 @@ import { RestoreBackup } from "./restore-backup";
interface Props {
id: string;
databaseType?:
| Exclude<ServiceType, "application" | "redis" | "libsql">
| Exclude<ServiceType, "application" | "redis">
| "web-server";
backupType?: "database" | "compose";
}
@@ -63,6 +63,8 @@ export const ShowBackups = ({
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
libsql: () =>
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
@@ -83,6 +85,7 @@ export const ShowBackups = ({
mongo: api.backup.manualBackupMongo.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
postgres: api.backup.manualBackupPostgres.useMutation(),
libsql: api.backup.manualBackupLibsql.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {

View File

@@ -1,202 +0,0 @@
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

@@ -56,7 +56,7 @@ import { api } from "@/utils/api";
type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
libsql: "ghcr.io/tursodatabase/libsql-server:latest",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
@@ -104,7 +104,7 @@ const mySchema = z
type: z.literal("libsql"),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:latest"),
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
databaseUser: z.string().default("libsql"),
sqldNode: z.enum(["primary", "replica"]).default("primary"),
sqldPrimaryUrl: z.string().optional(),

View File

@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-inset",
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View File

@@ -0,0 +1,7 @@
ALTER TYPE "public"."databaseType" ADD VALUE 'libsql';--> statement-breakpoint
ALTER TABLE "libsql" DROP CONSTRAINT "libsql_bottomlessReplicationDestinationId_destination_destinationId_fk";
--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "libsqlId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_libsqlId_libsql_libsqlId_fk" FOREIGN KEY ("libsqlId") REFERENCES "public"."libsql"("libsqlId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "libsql" DROP COLUMN "enableBottomlessReplication";--> statement-breakpoint
ALTER TABLE "libsql" DROP COLUMN "bottomlessReplicationDestinationId";

File diff suppressed because it is too large Load Diff

View File

@@ -1079,6 +1079,13 @@
"when": 1773940853496,
"tag": "0153_even_morlocks",
"breakpoints": true
},
{
"idx": 154,
"version": "7",
"when": 1773942481550,
"tag": "0154_tan_living_mummy",
"breakpoints": true
}
]
}

View File

@@ -13,7 +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 { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
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";
@@ -276,14 +276,10 @@ const Libsql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBottomlessReplication
libsqlId={libsqlId}
enableBottomlessReplication={
data?.enableBottomlessReplication || false
}
bottomlessReplicationDestinationId={
data?.bottomlessReplicationDestinationId
}
<ShowBackups
id={libsqlId}
databaseType="libsql"
backupType="database"
/>
</div>
</TabsContent>

View File

@@ -3,6 +3,8 @@ import {
findBackupById,
findComposeByBackupId,
findComposeById,
findLibsqlByBackupId,
findLibsqlById,
findMariadbByBackupId,
findMariadbById,
findMongoByBackupId,
@@ -16,6 +18,7 @@ import {
keepLatestNBackups,
removeBackupById,
removeScheduleBackup,
runLibsqlBackup,
runMariadbBackup,
runMongoBackup,
runMySqlBackup,
@@ -36,6 +39,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import {
restoreComposeBackup,
restoreLibsqlBackup,
restoreMariadbBackup,
restoreMongoBackup,
restoreMySqlBackup,
@@ -82,6 +86,7 @@ export const backupRouter = createTRPCRouter({
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.libsqlId ||
input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -103,6 +108,8 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId;
} else if (databaseType === "libsql" && backup.libsql?.serverId) {
serverId = backup.libsql.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
@@ -154,6 +161,7 @@ export const backupRouter = createTRPCRouter({
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.libsqlId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -173,6 +181,7 @@ export const backupRouter = createTRPCRouter({
existing.mysqlId ||
existing.mariadbId ||
existing.mongoId ||
existing.libsqlId ||
existing.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -400,6 +409,33 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupLibsql: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.libsqlId) {
await checkServicePermissionAndAccess(ctx, backup.libsqlId, {
backup: ["create"],
});
}
const libsql = await findLibsqlByBackupId(backup.backupId);
await runLibsqlBackup(libsql, backup);
await keepLatestNBackups(backup, libsql?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error running manual Libsql backup ",
cause: error,
});
}
}),
manualBackupWebServer: withPermission("backup", "create")
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
@@ -536,6 +572,12 @@ export const backupRouter = createTRPCRouter({
queue.push(log);
});
}
if (input.databaseType === "libsql") {
const libsql = await findLibsqlById(input.databaseId);
restoreLibsqlBackup(libsql, destination, input, (log) => {
queue.push(log);
});
}
if (input.databaseType === "web-server") {
restoreWebServerBackup(destination, input.backupFile, (log) => {
queue.push(log);