2026-02-18 11:45:43 -06:00
|
|
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
2026-02-17 12:24:43 -06:00
|
|
|
import { createServer } from "node:http";
|
|
|
|
|
import { resolve } from "node:path";
|
2026-02-18 11:45:43 -06:00
|
|
|
import {
|
|
|
|
|
createDb,
|
|
|
|
|
ensurePostgresDatabase,
|
|
|
|
|
migratePostgresIfEmpty,
|
|
|
|
|
} from "@paperclip/db";
|
|
|
|
|
import detectPort from "detect-port";
|
2026-02-16 13:31:58 -06:00
|
|
|
import { createApp } from "./app.js";
|
|
|
|
|
import { loadConfig } from "./config.js";
|
|
|
|
|
import { logger } from "./middleware/logger.js";
|
2026-02-17 12:24:43 -06:00
|
|
|
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
|
|
|
|
import { heartbeatService } from "./services/index.js";
|
2026-02-18 11:45:43 -06:00
|
|
|
import { printStartupBanner } from "./startup-banner.js";
|
|
|
|
|
|
|
|
|
|
type EmbeddedPostgresInstance = {
|
|
|
|
|
initialise(): Promise<void>;
|
|
|
|
|
start(): Promise<void>;
|
|
|
|
|
stop(): Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type EmbeddedPostgresCtor = new (opts: {
|
|
|
|
|
databaseDir: string;
|
|
|
|
|
user: string;
|
|
|
|
|
password: string;
|
|
|
|
|
port: number;
|
|
|
|
|
persistent: boolean;
|
|
|
|
|
}) => EmbeddedPostgresInstance;
|
2026-02-16 13:31:58 -06:00
|
|
|
|
|
|
|
|
const config = loadConfig();
|
2026-02-16 19:07:37 -06:00
|
|
|
|
|
|
|
|
let db;
|
2026-02-18 11:45:43 -06:00
|
|
|
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
|
|
|
|
let embeddedPostgresStartedByThisProcess = false;
|
|
|
|
|
let migrationSummary = "skipped";
|
|
|
|
|
let startupDbInfo:
|
|
|
|
|
| { mode: "external-postgres"; connectionString: string }
|
|
|
|
|
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
2026-02-16 19:07:37 -06:00
|
|
|
if (config.databaseUrl) {
|
2026-02-18 11:45:43 -06:00
|
|
|
const migration = await migratePostgresIfEmpty(config.databaseUrl);
|
|
|
|
|
if (migration.migrated) {
|
|
|
|
|
logger.info("Empty PostgreSQL database detected; applied migrations");
|
|
|
|
|
migrationSummary = "applied (empty database)";
|
|
|
|
|
} else if (migration.reason === "not-empty-no-migration-journal") {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ tableCount: migration.tableCount },
|
|
|
|
|
"PostgreSQL has existing tables but no migration journal; skipped auto-migrate",
|
|
|
|
|
);
|
|
|
|
|
migrationSummary = "skipped (existing schema, no migration journal)";
|
|
|
|
|
} else {
|
|
|
|
|
migrationSummary = "already applied";
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 19:07:37 -06:00
|
|
|
db = createDb(config.databaseUrl);
|
2026-02-18 11:45:43 -06:00
|
|
|
logger.info("Using external PostgreSQL via DATABASE_URL/config");
|
|
|
|
|
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
|
2026-02-16 19:07:37 -06:00
|
|
|
} else {
|
2026-02-18 11:45:43 -06:00
|
|
|
const moduleName = "embedded-postgres";
|
|
|
|
|
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
|
|
|
|
try {
|
|
|
|
|
const mod = await import(moduleName);
|
|
|
|
|
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"Embedded PostgreSQL mode requires optional dependency `embedded-postgres`. Install optional dependencies or set DATABASE_URL for external Postgres.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dataDir = resolve(config.embeddedPostgresDataDir);
|
|
|
|
|
const port = config.embeddedPostgresPort;
|
|
|
|
|
|
|
|
|
|
if (config.databaseMode === "postgres") {
|
|
|
|
|
logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(`No DATABASE_URL set — using embedded PostgreSQL (${dataDir}) on port ${port}`);
|
|
|
|
|
embeddedPostgres = new EmbeddedPostgres({
|
|
|
|
|
databaseDir: dataDir,
|
|
|
|
|
user: "paperclip",
|
|
|
|
|
password: "paperclip",
|
|
|
|
|
port,
|
|
|
|
|
persistent: true,
|
|
|
|
|
});
|
|
|
|
|
const clusterVersionFile = resolve(dataDir, "PG_VERSION");
|
|
|
|
|
if (!existsSync(clusterVersionFile)) {
|
|
|
|
|
await embeddedPostgres.initialise();
|
|
|
|
|
} else {
|
|
|
|
|
logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const postmasterPidFile = resolve(dataDir, "postmaster.pid");
|
|
|
|
|
const isPidRunning = (pid: number): boolean => {
|
|
|
|
|
try {
|
|
|
|
|
process.kill(pid, 0);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getRunningPid = (): number | null => {
|
|
|
|
|
if (!existsSync(postmasterPidFile)) return null;
|
|
|
|
|
try {
|
|
|
|
|
const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim();
|
|
|
|
|
const pid = Number(pidLine);
|
|
|
|
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
|
|
|
if (!isPidRunning(pid)) return null;
|
|
|
|
|
return pid;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runningPid = getRunningPid();
|
|
|
|
|
if (runningPid) {
|
|
|
|
|
logger.warn({ pid: runningPid }, "Embedded PostgreSQL already running; reusing existing process");
|
|
|
|
|
} else {
|
|
|
|
|
if (existsSync(postmasterPidFile)) {
|
|
|
|
|
logger.warn("Removing stale embedded PostgreSQL lock file");
|
|
|
|
|
rmSync(postmasterPidFile, { force: true });
|
|
|
|
|
}
|
|
|
|
|
await embeddedPostgres.start();
|
|
|
|
|
embeddedPostgresStartedByThisProcess = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
|
|
|
|
const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip");
|
|
|
|
|
if (dbStatus === "created") {
|
|
|
|
|
logger.info("Created embedded PostgreSQL database: paperclip");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
|
|
|
|
const migration = await migratePostgresIfEmpty(embeddedConnectionString);
|
|
|
|
|
if (migration.migrated) {
|
|
|
|
|
logger.info("Empty embedded PostgreSQL database detected; applied migrations");
|
|
|
|
|
migrationSummary = "applied (empty database)";
|
|
|
|
|
} else if (migration.reason === "not-empty-no-migration-journal") {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ tableCount: migration.tableCount },
|
|
|
|
|
"Embedded PostgreSQL has existing tables but no migration journal; skipped auto-migrate",
|
|
|
|
|
);
|
|
|
|
|
migrationSummary = "skipped (existing schema, no migration journal)";
|
|
|
|
|
} else {
|
|
|
|
|
migrationSummary = "already applied";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db = createDb(embeddedConnectionString);
|
|
|
|
|
logger.info("Embedded PostgreSQL ready");
|
|
|
|
|
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
2026-02-16 19:07:37 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 11:45:43 -06:00
|
|
|
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
|
|
|
|
const app = await createApp(db as any, { uiMode });
|
2026-02-17 12:24:43 -06:00
|
|
|
const server = createServer(app);
|
2026-02-18 11:45:43 -06:00
|
|
|
const listenPort = await detectPort(config.port);
|
|
|
|
|
|
|
|
|
|
if (listenPort !== config.port) {
|
|
|
|
|
logger.warn({ requestedPort: config.port, selectedPort: listenPort }, "Requested port is busy; using next free port");
|
|
|
|
|
}
|
2026-02-16 13:31:58 -06:00
|
|
|
|
2026-02-17 12:24:43 -06:00
|
|
|
setupLiveEventsWebSocketServer(server, db as any);
|
|
|
|
|
|
|
|
|
|
if (config.heartbeatSchedulerEnabled) {
|
|
|
|
|
const heartbeat = heartbeatService(db as any);
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
void heartbeat
|
|
|
|
|
.tickTimers(new Date())
|
|
|
|
|
.then((result) => {
|
|
|
|
|
if (result.enqueued > 0) {
|
|
|
|
|
logger.info({ ...result }, "heartbeat timer tick enqueued runs");
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
logger.error({ err }, "heartbeat timer tick failed");
|
|
|
|
|
});
|
|
|
|
|
}, config.heartbeatSchedulerIntervalMs);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 11:45:43 -06:00
|
|
|
server.listen(listenPort, () => {
|
|
|
|
|
logger.info(`Server listening on :${listenPort}`);
|
|
|
|
|
printStartupBanner({
|
|
|
|
|
requestedPort: config.port,
|
|
|
|
|
listenPort,
|
|
|
|
|
uiMode,
|
|
|
|
|
db: startupDbInfo,
|
|
|
|
|
migrationSummary,
|
|
|
|
|
heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled,
|
|
|
|
|
heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs,
|
|
|
|
|
});
|
2026-02-16 13:31:58 -06:00
|
|
|
});
|
2026-02-18 11:45:43 -06:00
|
|
|
|
|
|
|
|
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
|
|
|
|
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
|
|
|
|
|
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
|
|
|
|
try {
|
|
|
|
|
await embeddedPostgres?.stop();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
|
|
|
|
} finally {
|
|
|
|
|
process.exit(0);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
process.once("SIGINT", () => {
|
|
|
|
|
void shutdown("SIGINT");
|
|
|
|
|
});
|
|
|
|
|
process.once("SIGTERM", () => {
|
|
|
|
|
void shutdown("SIGTERM");
|
|
|
|
|
});
|
|
|
|
|
}
|