Files
Reactive-Resume/apps/server/src/startup/checks.ts
T
Amruth Pillai 8c40313980 feat(deploy): support Vercel Hobby alongside Docker (#3541)
* feat(deploy): support Vercel Hobby alongside Docker

* fix(deploy): include PDFKit runtime font assets

* docs(deploy): document Vercel and Docker setup

* docs(deploy): record storage persistence checks

* refactor(deploy): drop scheduled staging cleanup

Staging uploads are deleted after finalization and expired ones are swept
on each new upload, so the Vercel cron job, its route, and CRON_SECRET are
no longer needed. The Deploy with Vercel wizard now asks for two secrets.

* docs(deploy): restructure Vercel guides

Split the Vercel page into a how-to with its environment reference, move the
large RPC staging protocol to an API reference page, and move CI deployment
checks to the contributing section. Point Deploy with Vercel buttons at main.

* chore: remove agent planning records and fix web app description

Delete superpowers plans/specs, ADRs, issue plans, execution briefs, domain
context maps, and Europass research. Describe apps/web as a TanStack Router
SPA served by apps/server.

* refactor(deploy): simplify Vercel support code

- Share one Redis client and key namespace through @reactive-resume/db/redis
  for API and auth instead of a second auth-only client.
- Drop the auth seeding retry; the provider already treats concurrent inserts
  as no-ops and deployment preparation seeds before runtime.
- Detect staging support from POST /api/storage/stage (404 on Docker) instead
  of a separate GET probe.
- Read staged bodies directly; the signed upload already caps their size.
- Close per-subscription Redis connections with disconnect() alone.
- Check Blob health with one list call instead of write/read/delete.
- Remove redundant tsdown onlyBundle list, dead namespace fallbacks, and the
  conditional spread in the health status.

* fix(deploy): heal stopped runs with dead owners and keep auth up without Redis

- Run owners refresh a Redis heartbeat until they release their claim. Stop
  requests reap the run immediately when the owner has stopped heartbeating,
  instead of leaving the thread blocked until the 15-minute TTL reaper.
- Auth and oRPC rate limiters fall back to per-instance memory limits when
  Redis errors, instead of rejecting every login or failing requests.

* ci: allow esbuild build for Vercel CLI and register deployment deps with knip

pnpm 12 fails dlx installs with ignored build scripts, so allow esbuild
explicitly. The server bundle keeps @vercel/blob, ioredis, and jose external,
and api/index.mjs is the Vercel Function entry.

* fix(web): send buffered RPC bodies instead of teed streams

Reading a request clone turned the original body into a stream, which
browsers send without inspectable request data and which needs duplex
mode. Send the already buffered Blob for direct requests.

* fix(web): send direct RPC bodies as bytes

Blob request bodies are sent as data pipes, so browser tooling cannot
inspect them. Buffer the original request as an ArrayBuffer and send those
bytes; this restores the e2e save assertions that match on request data.
2026-09-26 02:37:22 +02:00

109 lines
3.4 KiB
TypeScript

import { constants, existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
import { verifyMigratedSchema } from "./schema-check";
function resolveFromCurrentModule(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url));
}
function resolveWorkspaceFolder(folderName: string): string {
let dir = resolveFromCurrentModule(".");
while (dir !== path.dirname(dir)) {
const candidate = path.join(dir, folderName);
if (existsSync(candidate)) return candidate;
dir = path.dirname(dir);
}
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
}
export async function runDatabaseMigrations() {
console.info("Running database migrations...");
const pool = new Pool({
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 10_000,
});
try {
const client = await pool.connect();
try {
await client.query("SELECT pg_advisory_lock(721830451)");
const db = drizzle({ client });
try {
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
console.info("Database migrations completed");
} catch (error) {
console.error("Database migrations failed", { error });
throw error;
}
// Post-migration verification is not a migration failure, so it gets its own log
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
// makes the drift fatal instead.
try {
await verifyMigratedSchema(client);
} catch (error) {
console.error("Database schema verification failed", { error });
if (env.STRICT_SCHEMA_CHECK) throw error;
console.error(
"Continuing with a drifted database schema; set STRICT_SCHEMA_CHECK=true to refuse startup instead.",
);
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock(721830451)");
} finally {
client.release();
}
}
} finally {
await pool.end();
}
}
async function validateLocalStoragePath() {
if (env.STORAGE_BACKEND !== "local") return;
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
console.info(`Validating local storage path: ${dataDirectory}`);
try {
await fs.mkdir(dataDirectory, { recursive: true });
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(
`Local storage path is not writable: ${dataDirectory}\n` +
` ${message}\n` +
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
);
throw error;
}
}
async function reapStaleAgentRuns() {
try {
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
await reapStaleAgentRunsAtBoot();
} catch (error) {
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
console.error("Failed to reap stale agent runs at boot", { error });
}
}
export async function runStartupChecks() {
await runDatabaseMigrations();
await validateLocalStoragePath();
await reapStaleAgentRuns();
}