mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
fix: harden backup restore, first-user race, and several reliability issues
- Block all non-health API requests with 503 while a database restore is in progress (`withDatabaseAccessBlocked` in `@sofa/db/client`); pause and resume cron jobs around the restore window - Replace the two-hook first-user admin promotion with an atomic `claimInitialAdmin` query that uses a DB-level unique constraint so concurrent sign-ups during the first-run window can't each see `userCount === 0` - Fix `refreshAvailability` to always call `replaceAvailabilityTransaction` (clearing stale rows) even when TMDB returns no US providers, instead of returning early and leaving old data in place - Fix `performUpdateCheck` to read `release_url` (snake_case) from the public API response instead of `releaseUrl` - Fix `createJob` import handler to catch `SQLITE_CONSTRAINT_UNIQUE` and surface it as an `IMPORT_ALREADY_RUNNING` conflict error instead of a 500 - Relax public API telemetry schema to accept `string | number` for `users` and `titles` fields - Add tests for availability clearing, import deduplication, and `claimInitialAdmin`
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import type { Logger } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
@@ -16,6 +17,13 @@ const drizzleLogger: Logger = {
|
||||
},
|
||||
};
|
||||
|
||||
export class DatabaseRestoreInProgressError extends Error {
|
||||
constructor(message = "Database is temporarily unavailable during restore") {
|
||||
super(message);
|
||||
this.name = "DatabaseRestoreInProgressError";
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-init singleton via globalThis. Next.js evaluates module-level code at
|
||||
// build time (when no database exists) and re-imports modules on HMR in dev
|
||||
// (which would create duplicate connections). Stashing the instances on
|
||||
@@ -30,9 +38,19 @@ const drizzleLogger: Logger = {
|
||||
const globalForDb = globalThis as unknown as {
|
||||
_db: ReturnType<typeof drizzle> | undefined;
|
||||
_client: Database | undefined;
|
||||
_accessBlocked: boolean | undefined;
|
||||
};
|
||||
|
||||
const dbAccessBypass = new AsyncLocalStorage<boolean>();
|
||||
|
||||
function assertDatabaseAccessible() {
|
||||
if (globalForDb._accessBlocked && !dbAccessBypass.getStore()) {
|
||||
throw new DatabaseRestoreInProgressError();
|
||||
}
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
assertDatabaseAccessible();
|
||||
if (!globalForDb._client) {
|
||||
globalForDb._client = new Database(DATABASE_URL);
|
||||
globalForDb._client.run("PRAGMA journal_mode = WAL");
|
||||
@@ -47,6 +65,7 @@ function getClient() {
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
assertDatabaseAccessible();
|
||||
if (!globalForDb._db) {
|
||||
globalForDb._db = drizzle({
|
||||
client: getClient(),
|
||||
@@ -59,6 +78,7 @@ function getDb() {
|
||||
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(_, prop) {
|
||||
assertDatabaseAccessible();
|
||||
return Reflect.get(getDb(), prop);
|
||||
},
|
||||
});
|
||||
@@ -72,6 +92,19 @@ export function vacuumDatabase(into: string): void {
|
||||
getClient().run("VACUUM INTO ?", [into.replace(/'/g, "''")]);
|
||||
}
|
||||
|
||||
export function isDatabaseAccessBlocked(): boolean {
|
||||
return globalForDb._accessBlocked === true;
|
||||
}
|
||||
|
||||
export async function withDatabaseAccessBlocked<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
globalForDb._accessBlocked = true;
|
||||
try {
|
||||
return await dbAccessBypass.run(true, fn);
|
||||
} finally {
|
||||
globalForDb._accessBlocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
|
||||
export function closeDatabase() {
|
||||
globalForDb._client?.close();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { appSettings, user } from "../schema";
|
||||
|
||||
const INITIAL_ADMIN_KEY = "initialAdminAssigned";
|
||||
|
||||
export function getSettingValue(key: string): string | null {
|
||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
@@ -19,3 +21,42 @@ export function getUserCount(): number {
|
||||
const result = db.select({ count: count() }).from(user).get();
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
export function claimInitialAdmin(userId: string): boolean {
|
||||
let claimed = false;
|
||||
|
||||
db.transaction((tx) => {
|
||||
const firstUser = tx
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.orderBy(asc(user.createdAt), asc(user.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (!firstUser || firstUser.id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lock = tx
|
||||
.insert(appSettings)
|
||||
.values({ key: INITIAL_ADMIN_KEY, value: userId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ key: appSettings.key })
|
||||
.get();
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
tx.update(user).set({ role: "admin" }).where(eq(user.id, userId)).run();
|
||||
tx.insert(appSettings)
|
||||
.values({ key: "registrationOpen", value: "false" })
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.key,
|
||||
set: { value: "false" },
|
||||
})
|
||||
.run();
|
||||
|
||||
claimed = true;
|
||||
});
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, int, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Helper for UUID primary keys
|
||||
@@ -472,6 +473,9 @@ export const importJobs = sqliteTable(
|
||||
(table) => [
|
||||
index("importJobs_userId_createdAt").on(table.userId, table.createdAt),
|
||||
index("importJobs_status").on(table.status),
|
||||
uniqueIndex("importJobs_active_user")
|
||||
.on(table.userId)
|
||||
.where(sql`${table.status} in ('pending', 'running')`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user