d35c806d5b
- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability. - Improved type definitions in index.ts for better code understanding and usage. - Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service. - Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend. - Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker. - Included a .dockerignore file to exclude unnecessary files from Docker builds.
196 lines
6.9 KiB
JavaScript
196 lines
6.9 KiB
JavaScript
// ── Automated Database Migration Runner ──────────────────────────────
|
||
// Reads .env.local for DATABASE_URL, tracks applied migrations in
|
||
// a _migrations table, and runs unapplied .sql files via psql.
|
||
// Runs automatically on npm run dev and npm run setup.
|
||
// ============================================================================
|
||
|
||
import { readFileSync, existsSync } from "node:fs"
|
||
import { execSync } from "node:child_process"
|
||
import { resolve, dirname } from "node:path"
|
||
import { fileURLToPath } from "node:url"
|
||
import { createRequire } from "node:module"
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||
const ROOT = resolve(__dirname, "..")
|
||
|
||
// ── Load .env.local into process.env ─────────────────────────────────
|
||
// Simple key=value parser (no dotenv dependency needed).
|
||
// Handles quoted values and skips comments / blank lines.
|
||
|
||
const envPath = resolve(ROOT, ".env.local")
|
||
if (existsSync(envPath)) {
|
||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||
const trimmed = line.trim()
|
||
if (!trimmed || trimmed.startsWith("#")) continue
|
||
const eq = trimmed.indexOf("=")
|
||
if (eq === -1) continue
|
||
const key = trimmed.slice(0, eq).trim()
|
||
let value = trimmed.slice(eq + 1).trim()
|
||
// Strip matching surrounding quotes (single or double)
|
||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1)
|
||
}
|
||
// Don't override already-set env vars
|
||
if (!process.env[key]) process.env[key] = value
|
||
}
|
||
}
|
||
|
||
const DATABASE_URL = process.env.DATABASE_URL
|
||
if (!DATABASE_URL) {
|
||
console.log(" ~ DATABASE_URL not set — skipping migrations")
|
||
process.exit(0)
|
||
}
|
||
|
||
// ── Find psql ────────────────────────────────────────────────────────
|
||
// Check PATH first, then probe common PostgreSQL v14–17 install paths.
|
||
|
||
function findPsql() {
|
||
try {
|
||
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
|
||
return "psql"
|
||
} catch {}
|
||
|
||
const candidates = [
|
||
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
|
||
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
|
||
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
|
||
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
|
||
]
|
||
for (const p of candidates) {
|
||
if (existsSync(p)) return `"${p}"`
|
||
}
|
||
return null
|
||
}
|
||
|
||
const PSQL = findPsql()
|
||
if (!PSQL) {
|
||
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
|
||
process.exit(0)
|
||
}
|
||
|
||
// ── Database connection (for tracking table) ─────────────────────────
|
||
|
||
const require = createRequire(import.meta.url)
|
||
const { Pool } = require("pg")
|
||
|
||
let pool
|
||
try {
|
||
pool = new Pool({
|
||
connectionString: DATABASE_URL,
|
||
max: 1,
|
||
connectionTimeoutMillis: 5000,
|
||
idleTimeoutMillis: 10000,
|
||
})
|
||
await pool.query("SELECT 1")
|
||
} catch {
|
||
console.log(" ~ Database not available — skipping migrations")
|
||
process.exit(0)
|
||
}
|
||
|
||
// ── Migration logic ──────────────────────────────────────────────────
|
||
|
||
/** Run all unapplied database migrations in the order defined by run_all.sql. */
|
||
async function runMigrations() {
|
||
// 1. Ensure the _migrations tracking table exists (idempotent)
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS _migrations (
|
||
id SERIAL PRIMARY KEY,
|
||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`)
|
||
|
||
// 2. Determine file order from run_all.sql (authoritative source of truth)
|
||
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
||
const runAllContent = readFileSync(runAllPath, "utf-8")
|
||
const fileOrder = []
|
||
for (const line of runAllContent.split("\n")) {
|
||
// `\i filename.sql` is the psql include directive
|
||
const match = line.match(/\\i\s+(\S+\.sql)/)
|
||
if (match) fileOrder.push(match[1])
|
||
}
|
||
|
||
if (fileOrder.length === 0) {
|
||
console.log(" ~ No migration files found in run_all.sql")
|
||
return
|
||
}
|
||
|
||
// 3. Get already-applied files
|
||
let appliedSet
|
||
try {
|
||
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
|
||
appliedSet = new Set(applied.map((r) => r.filename))
|
||
} catch {
|
||
appliedSet = new Set()
|
||
}
|
||
|
||
// 4. Build psql command base with ON_ERROR_STOP=1 so psql aborts on first error
|
||
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
|
||
|
||
// 5. Run unapplied files in order.
|
||
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
|
||
// If the error is "already exists", the file was already applied before
|
||
// tracking existed — mark it as applied and continue.
|
||
// Other errors are genuine failures — abort.
|
||
const ALREADY_APPLIED_PATTERNS = [
|
||
"already exists",
|
||
"duplicate key",
|
||
"cannot insert multiple commands into a prepared statement",
|
||
]
|
||
|
||
let count = 0
|
||
for (const file of fileOrder) {
|
||
// Skip files already recorded in the _migrations table
|
||
if (appliedSet.has(file)) continue
|
||
|
||
const filePath = resolve(ROOT, "database", "migrations", file)
|
||
if (!existsSync(filePath)) {
|
||
console.error(` ✗ Migration file not found: ${file}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
let stderr = ""
|
||
try {
|
||
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
|
||
timeout: 30000,
|
||
encoding: "utf-8",
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
})
|
||
// psql outputs notices/warnings to stderr even on success — capture for diagnostics
|
||
stderr = result.stderr || ""
|
||
} catch (err) {
|
||
// Distinguish between "already applied" (harmless) and genuine failures
|
||
const msg = err.stderr || err.stdout || err.message
|
||
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
||
if (isAlreadyApplied) {
|
||
// File was applied before tracking existed — record it and continue
|
||
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
|
||
console.log(` ~ Already applied: ${file}`)
|
||
count++
|
||
continue
|
||
}
|
||
console.error(` ✗ Migration failed: ${file}`)
|
||
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
// Record successful migration
|
||
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
||
console.log(` ✓ Applied: ${file}`)
|
||
count++
|
||
}
|
||
|
||
if (count === 0) {
|
||
console.log(" ~ All migrations up to date")
|
||
}
|
||
}
|
||
|
||
// ── Run ──────────────────────────────────────────────────────────────
|
||
|
||
try {
|
||
await runMigrations()
|
||
} finally {
|
||
await pool.end()
|
||
}
|