Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,7 @@ logging and table-scoped cache invalidation stay automatic.
- `deno task lint:ci` - Strict, read-only lint (`check --error-on-warnings`, no `--write`). Fails on lint warnings (e.g. cognitive complexity) and on any code that *would* be reformatted, without touching the checkout. This is the lint `deno task precommit` runs in **every** environment, so a clean `precommit` locally means the lint step will pass in CI too. Run `deno task lint` to auto-fix before re-running.
- `deno task build:edge` - Build for Bunny Edge deployment
- `deno task backup` - Dump the database out-of-band to a `.zip`. Uploads to the configured storage zone by default (so it appears on the Backups page and lets the next migration skip its own inline backup); pass `--out <path>` to write a local file. Runs in a full Deno process, so unlike the in-edge backup it has no per-request subrequest budget and can dump arbitrarily large databases.
- `deno task snapshot --out <path.sqlite>` - Sync the complete remote database to a standalone local SQLite file. The task prefers `DB_URL` and `DB_TOKEN` from `.env` over shell values. This developer-only task checkpoints and verifies the file, refuses to overwrite an existing path, and removes its temporary replica on success or failure.
- `deno task precommit` - Run all checks (typecheck, lint, tests)
- `deno task precommit:mutation` - The precommit mutation gate, runnable on its own: mutation-test every `src/` file this branch changed and demand a 100% kill rate. A source's mirror-located direct tests run first; changed tests under `test/integration/` or `test/e2e/` run only for direct-test survivors. Any other unmatched test is an error: move it to the path that mirrors its source while strengthening it to kill the mutants. The changed set is the branch's committed diff against the integration branch (`origin/main`, else a local `main`) via `base...HEAD` — three-dot/merge-base, so it's the branch's full diff vs main and stays bounded to the branch's own commits (precommit runs post-commit on a clean tree, so the index is empty). Skips cheaply when there is no base ref or no changed `src/` files (and likewise when src changed without any changed test). If a badly stale local `origin/main` balloons the changed set past `STALE_BASE_SOURCE_LIMIT`, it skips with a "run `git fetch origin main`" hint instead of mutating most of the tree. See [Mutation Testing](#mutation-testing).
- `deno task mutation <source-glob> <test-glob>` - Mutation-test your tests on demand in an isolated `.mutation-runs/<id>/work` copy: mutate operators in the source and check your tests catch it (see [Mutation Testing](#mutation-testing))
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ deno task typecheck # Type check
deno task build:edge # Build for Bunny Edge
deno task deploy:edge <script-id> # Build, upload, and publish to Bunny Edge using BUNNY_ACCESS_KEY from .env
deno task backup # Dump the database out-of-band (uploads to storage; --out <path> for a local .zip)
deno task snapshot --out <path.sqlite> # Copy the complete remote database to a local SQLite file
deno task precommit # All checks (typecheck, lint, cpd, build:edge, test:coverage)
```

Expand Down Expand Up @@ -360,6 +361,15 @@ Optional:

**Backups:** every table is dumped to a single `.zip`, with table reads keyset-paginated so no single response trips libsqld's "Response is too large" payload cap (the server limit behind Bunny's databases). Backups run **out-of-band**, not inside the migration: a full dump of a ~31-table schema can't fit alongside a migration within one edge request's [50-subrequest budget](https://docs.bunny.net/scripting/limits), so migrations just migrate, and a backup is taken by GitHub Actions (or `deno task backup`) beforehand. To enforce that, **`/admin/update` and the per-site update button refuse to deploy unless a backup of that database was taken in the last hour.**

**Local SQLite snapshots:** developers can copy a remote database into one complete SQLite file using the same `DB_URL` and `DB_TOKEN` as the app:

```bash
mkdir -p .local-data
deno task snapshot --out .local-data/site.sqlite
```

The task reads `DB_URL` and `DB_TOKEN` from `.env`. Values in that file take priority over existing shell variables. It then uses libSQL page-level sync, moves all WAL data into the main file, and checks the finished database before publishing it. It refuses to replace an existing file. The temporary replica is created beside the output and removed on success or failure. Local `.sqlite` files and `.local-data/` are ignored by Git. Run this only on a development machine; it needs a local filesystem and the native libSQL client. The file contains the database exactly as stored, including encrypted personal data, but it does not include environment secrets or files held in Bunny Storage.

The deploy workflows back a site up (via `POST /instance/site-credentials`) before deploying to it (the staging push-to-`main` trigger is the one exception — see below):

- **`.github/workflows/backup.yml`** (manual) — backs up the main instance's own database with `DB_URL` / `DB_TOKEN` / `STORAGE_ZONE_NAME` / `STORAGE_ZONE_KEY` repository secrets.
Expand Down
4 changes: 3 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test:raw": "deno task build:static && deno test --no-check --allow-net --allow-env --allow-read --allow-write --allow-run --allow-sys --allow-ffi test/",
"lint": "deno run -A scripts/biome.ts check --write --error-on-warnings src test scripts cli e2e-payments",
"lint:ci": "deno run -A scripts/biome.ts check --error-on-warnings src test scripts cli e2e-payments",
"typecheck": "deno check src/**/*.ts src/**/*.tsx test/**/*.ts cli/**/*.ts scripts/mutation.ts scripts/mutation/**/*.ts scripts/precommit.ts scripts/precommit/**/*.ts scripts/check-copy.ts scripts/check-copy/**/*.ts scripts/bench/bundle-composition/**/*.ts scripts/screenshots.ts scripts/screenshots/**/*.ts && deno check --config=e2e-payments/deno.json e2e-payments/src/**/*.ts",
"typecheck": "deno check src/**/*.ts src/**/*.tsx test/**/*.ts cli/**/*.ts scripts/database-snapshot.ts scripts/mutation.ts scripts/mutation/**/*.ts scripts/precommit.ts scripts/precommit/**/*.ts scripts/check-copy.ts scripts/check-copy/**/*.ts scripts/bench/bundle-composition/**/*.ts scripts/screenshots.ts scripts/screenshots/**/*.ts && deno check --config=e2e-payments/deno.json e2e-payments/src/**/*.ts",
"cpd": "deno run -A scripts/cpd.ts --config .jscpd.json && deno run -A scripts/cpd.ts --config .jscpd.test.json && deno run -A scripts/cpd.ts --config .jscpd.css.json src/ui/static/style.scss",
"check:copy": "deno run --allow-read=src/locales scripts/check-copy.ts",
"precommit": "deno run -A scripts/precommit.ts",
Expand All @@ -27,6 +27,7 @@
"unit-tests-report": "deno run --allow-read=src,test,deno.json scripts/unit-tests-report.ts",
"upgrade": "deno run --allow-read --allow-write --allow-net scripts/safe-upgrade.ts",
"backup": "deno run --env-file --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/backup.ts",
"snapshot": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/database-snapshot.ts",
"cli:tui": "deno run --allow-env --allow-read --allow-run cli/tui.ts",
"cli:api": "deno run --allow-env --allow-read --allow-run cli/api.ts",
"test:quality-audit": "deno run --allow-read scripts/test-quality-audit.ts",
Expand Down Expand Up @@ -65,6 +66,7 @@
"fast-check": "npm:fast-check@^3.23.0",
"intl-messageformat": "npm:intl-messageformat@^10",
"@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1",
"@std/dotenv": "jsr:@std/dotenv@^0.225.7",
"@std/http": "jsr:@std/http@^1.1.1",
"@sentry/core": "npm:@sentry/core@^10.60.0",
"@sentry/deno": "npm:@sentry/deno@^10.60.0",
Expand Down
7 changes: 6 additions & 1 deletion deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

294 changes: 294 additions & 0 deletions scripts/database-snapshot-lib.ts
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 => {
Comment thread
stefan-burke marked this conversation as resolved.
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)) {
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
return value;
}
Comment thread
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 })]);
};
Loading