Repository navigation
Add complete local SQLite snapshots #1884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c9badf5
Add complete local database snapshots
stefan-burke a33a126
Prefer snapshot credentials from dotenv
stefan-burke 0e1f0a7
Require TLS for remote snapshots
stefan-burke 3b8f226
Restrict snapshot file permissions
stefan-burke 40136ad
Keep local snapshots out of Git
stefan-burke 0c4036c
Share directory listing test helper
stefan-burke 8eb24f8
Reject plaintext libSQL snapshot URLs
stefan-burke 8a56ffe
Merge branch 'main' into feature/database-snapshot
stefan-burke f151f5c
Show database snapshot progress
stefan-burke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| node_modules/ | ||
| tickets.db | ||
| *.db | ||
| *.sqlite | ||
| *.sqlite-shm | ||
| *.sqlite-wal | ||
| .db-key | ||
| .env | ||
| .db-key | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,294 @@ | ||
| import type { Config } from "@libsql/client"; | ||
| import { parseArgs } from "@std/cli/parse-args"; | ||
| import { load } from "@std/dotenv"; | ||
| import { basename, dirname, join, resolve, toFileUrl } from "@std/path"; | ||
| import * as v from "valibot"; | ||
| import { withCleanup } from "#scripts/cleanup.ts"; | ||
| import { getEnv } from "#shared/env.ts"; | ||
|
|
||
| export const SNAPSHOT_USAGE = "Usage: deno task snapshot --out <path.sqlite>"; | ||
|
|
||
| export interface SnapshotOptions { | ||
| outputPath: string; | ||
| } | ||
|
|
||
| export interface SnapshotRequest extends SnapshotOptions { | ||
| dbToken: string; | ||
| dbUrl: string; | ||
| } | ||
|
|
||
| export interface SnapshotQueryResult { | ||
| rows: readonly Readonly<Record<string, unknown>>[]; | ||
| } | ||
|
|
||
| export interface SnapshotClient { | ||
| close(): void; | ||
| execute(sql: string): Promise<SnapshotQueryResult>; | ||
| sync(): Promise<unknown>; | ||
| } | ||
|
|
||
| export type SnapshotClientFactory = (config: Config) => SnapshotClient; | ||
| export type SnapshotEnvReader = (key: string) => string | undefined; | ||
| export type SnapshotEnvFileLoader = ( | ||
| path: string, | ||
| ) => Promise<Record<string, string>>; | ||
|
|
||
| const OutputValuesSchema = v.strictTuple([ | ||
| v.pipe( | ||
| v.string(), | ||
| v.check((path) => path.trim().length > 0), | ||
| ), | ||
| ]); | ||
|
|
||
| const EmptyStringsSchema = v.strictTuple([]); | ||
|
|
||
| const snapshotArgsSchema = < | ||
| Help extends boolean, | ||
| OutSchema extends v.GenericSchema, | ||
| >( | ||
| help: Help, | ||
| out: OutSchema, | ||
| ) => | ||
| v.strictObject({ | ||
| _: EmptyStringsSchema, | ||
| h: v.literal(help), | ||
| help: v.literal(help), | ||
| out, | ||
| }); | ||
|
|
||
| const SnapshotArgsSchema = snapshotArgsSchema(false, OutputValuesSchema); | ||
| const SnapshotHelpArgsSchema = snapshotArgsSchema(true, EmptyStringsSchema); | ||
|
|
||
| const CheckpointRowsSchema = v.pipe( | ||
| v.array( | ||
| v.strictObject({ | ||
| busy: v.literal(0), | ||
| checkpointed: v.literal(0), | ||
| log: v.literal(0), | ||
| }), | ||
| ), | ||
| v.length(1), | ||
| ); | ||
|
|
||
| const IntegrityRowsSchema = v.pipe( | ||
| v.array(v.strictObject({ integrity_check: v.literal("ok") })), | ||
| v.length(1), | ||
| ); | ||
|
|
||
| const SECURE_DATABASE_PROTOCOLS = new Set(["https:", "libsql:"]); | ||
| const HTTP_LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); | ||
|
|
||
| const invalidUsage = (): never => { | ||
| throw new Error(SNAPSHOT_USAGE); | ||
| }; | ||
|
|
||
| export const parseSnapshotArgs = (args: string[]): SnapshotOptions | null => { | ||
| const parsed = parseArgs(args, { | ||
| alias: { h: "help" }, | ||
| boolean: ["help"], | ||
| collect: ["out"], | ||
| string: ["out"], | ||
| }); | ||
| if (parsed.help) { | ||
| if (!v.safeParse(SnapshotHelpArgsSchema, parsed).success) invalidUsage(); | ||
| return null; | ||
| } | ||
| const result = v.safeParse(SnapshotArgsSchema, parsed); | ||
| if (!result.success) return invalidUsage(); | ||
| return { outputPath: result.output.out[0] }; | ||
| }; | ||
|
|
||
| const requiredEnv = (key: string, readEnv: SnapshotEnvReader): string => { | ||
| const value = readEnv(key); | ||
| if (!value || value.trim().length === 0) { | ||
| throw new Error(`${key} environment variable is required`); | ||
| } | ||
| return value; | ||
| }; | ||
|
|
||
| const requireRemoteDatabaseUrl = (value: string): string => { | ||
| try { | ||
| const url = new URL(value); | ||
| const secure = SECURE_DATABASE_PROTOCOLS.has(url.protocol); | ||
| const loopbackHttp = | ||
| url.protocol === "http:" && HTTP_LOOPBACK_HOSTNAMES.has(url.hostname); | ||
| if (url.hostname && (secure || loopbackHttp)) { | ||
|
stefan-burke marked this conversation as resolved.
Outdated
|
||
| return value; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } catch { | ||
| // Invalid URLs share the same clear configuration error as local URLs. | ||
| } | ||
| throw new Error( | ||
| "DB_URL must use libsql or HTTPS. HTTP is allowed only for loopback.", | ||
| ); | ||
| }; | ||
|
|
||
| export const readSnapshotRequest = ( | ||
| options: SnapshotOptions, | ||
| readEnv: SnapshotEnvReader = getEnv, | ||
| ): SnapshotRequest => ({ | ||
| dbToken: requiredEnv("DB_TOKEN", readEnv), | ||
| dbUrl: requireRemoteDatabaseUrl(requiredEnv("DB_URL", readEnv)), | ||
| outputPath: options.outputPath, | ||
| }); | ||
|
|
||
| const loadSnapshotEnvFile: SnapshotEnvFileLoader = (path) => | ||
| load({ envPath: path }); | ||
|
|
||
| export const readSnapshotRequestFromEnvFile = async ( | ||
| options: SnapshotOptions, | ||
| path = ".env", | ||
| readEnv: SnapshotEnvReader = getEnv, | ||
| loadFile: SnapshotEnvFileLoader = loadSnapshotEnvFile, | ||
| ): Promise<SnapshotRequest> => { | ||
| const fileEnv = await loadFile(path); | ||
| return readSnapshotRequest(options, (key) => fileEnv[key] ?? readEnv(key)); | ||
| }; | ||
|
|
||
| const outputAlreadyExists = (path: string): Error => | ||
| new Error(`Output already exists: ${path}`); | ||
|
|
||
| const readOrNullIfMissing = async <Result>( | ||
| read: () => Promise<Result>, | ||
| ): Promise<Result | null> => { | ||
| try { | ||
| return await read(); | ||
| } catch (error) { | ||
| if (error instanceof Deno.errors.NotFound) return null; | ||
| throw error; | ||
| } | ||
| }; | ||
|
|
||
| const fileInfoOrNull = | ||
| (getRead: () => (path: string) => Promise<Deno.FileInfo>) => | ||
| (path: string): Promise<Deno.FileInfo | null> => | ||
| readOrNullIfMissing(() => getRead()(path)); | ||
|
|
||
| const statOrNull = fileInfoOrNull(() => Deno.stat); | ||
| const lstatOrNull = fileInfoOrNull(() => Deno.lstat); | ||
|
|
||
| const requireOutputDirectory = async (path: string): Promise<void> => { | ||
| const info = await statOrNull(path); | ||
| if (info === null) | ||
| throw new Error(`Output directory does not exist: ${path}`); | ||
| if (!info.isDirectory) | ||
| throw new Error(`Output directory is not a directory: ${path}`); | ||
| }; | ||
|
|
||
| const outputFiles = (path: string): string[] => [ | ||
| path, | ||
| `${path}-wal`, | ||
| `${path}-shm`, | ||
| ]; | ||
|
|
||
| const requireMissingOutputFiles = async (path: string): Promise<void> => { | ||
| const existingPath = ( | ||
| await Promise.all( | ||
| outputFiles(path).map(async (filePath) => | ||
| (await lstatOrNull(filePath)) === null ? null : filePath, | ||
| ), | ||
| ) | ||
| ).find((filePath) => filePath !== null); | ||
| if (existingPath !== undefined) throw outputAlreadyExists(existingPath); | ||
| }; | ||
|
|
||
| const withSnapshotClient = <Result>( | ||
| factory: SnapshotClientFactory, | ||
| config: Config, | ||
| run: (client: SnapshotClient) => Promise<Result>, | ||
| ): Promise<Result> => { | ||
| const client = factory(config); | ||
| return withCleanup(() => run(client), [() => client.close()]); | ||
| }; | ||
|
|
||
| const syncReplica = ( | ||
| path: string, | ||
| request: SnapshotRequest, | ||
| factory: SnapshotClientFactory, | ||
| ): Promise<unknown> => | ||
| withSnapshotClient( | ||
| factory, | ||
| { | ||
| authToken: request.dbToken, | ||
| syncUrl: request.dbUrl, | ||
| url: toFileUrl(path).href, | ||
| }, | ||
| (client) => client.sync(), | ||
| ); | ||
|
|
||
| const verifyRows = ( | ||
| schema: v.GenericSchema, | ||
| result: SnapshotQueryResult, | ||
| message: string, | ||
| ): void => { | ||
| if (!v.safeParse(schema, result.rows).success) throw new Error(message); | ||
| }; | ||
|
|
||
| const checkpointAndVerify = ( | ||
| path: string, | ||
| factory: SnapshotClientFactory, | ||
| ): Promise<void> => | ||
| withSnapshotClient(factory, { url: toFileUrl(path).href }, async (client) => { | ||
| verifyRows( | ||
| CheckpointRowsSchema, | ||
| await client.execute("PRAGMA wal_checkpoint(TRUNCATE)"), | ||
| "Database snapshot checkpoint did not empty the WAL", | ||
| ); | ||
| verifyRows( | ||
| IntegrityRowsSchema, | ||
| await client.execute("PRAGMA integrity_check"), | ||
| "Database snapshot integrity check failed", | ||
| ); | ||
| }); | ||
|
|
||
| const fileSizeOrNull = async (path: string): Promise<number | null> => { | ||
| const info = await statOrNull(path); | ||
| // SQLite can either remove an empty WAL or leave a zero-byte file. | ||
| return info?.size ?? null; | ||
| }; | ||
|
|
||
| const requireEmptyWal = async (path: string): Promise<void> => { | ||
| const size = await fileSizeOrNull(`${path}-wal`); | ||
| if (size !== null && size !== 0) { | ||
| throw new Error("Database snapshot WAL is not empty"); | ||
| } | ||
| }; | ||
|
|
||
| const publishSnapshot = async ( | ||
| temporaryPath: string, | ||
| outputPath: string, | ||
| ): Promise<void> => { | ||
| await Deno.chmod(temporaryPath, 0o600); | ||
| await requireMissingOutputFiles(outputPath); | ||
| try { | ||
| await Deno.link(temporaryPath, outputPath); | ||
| } catch (error) { | ||
| if (error instanceof Deno.errors.AlreadyExists) { | ||
| throw outputAlreadyExists(outputPath); | ||
| } | ||
| throw error; | ||
| } | ||
| }; | ||
|
|
||
| export const createDatabaseSnapshot = async ( | ||
| request: SnapshotRequest, | ||
| factory: SnapshotClientFactory, | ||
| ): Promise<string> => { | ||
| const outputPath = resolve(request.outputPath); | ||
| const outputDirectory = dirname(outputPath); | ||
| await requireOutputDirectory(outputDirectory); | ||
| await requireMissingOutputFiles(outputPath); | ||
| const temporaryDirectory = await Deno.makeTempDir({ | ||
| dir: outputDirectory, | ||
| prefix: `.${basename(outputPath)}-snapshot-`, | ||
| }); | ||
|
|
||
| return await withCleanup(async () => { | ||
| const temporaryPath = join(temporaryDirectory, "snapshot.sqlite"); | ||
| await syncReplica(temporaryPath, request, factory); | ||
| await checkpointAndVerify(temporaryPath, factory); | ||
| await requireEmptyWal(temporaryPath); | ||
| await publishSnapshot(temporaryPath, outputPath); | ||
| return outputPath; | ||
| }, [() => Deno.remove(temporaryDirectory, { recursive: true })]); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.