diff --git a/.gitignore b/.gitignore index f843c4a..04b4cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,9 @@ build/ /cli/node_modules/ /cli/package-lock.json /scripts/seed-test-data.ts -/.env.local +/.env.development /.env.prod /.env.production -data/lab.local.db +data/lab.dev.db +/data/lab.dev.db diff --git a/package.json b/package.json index da6f21c..7ca9cd1 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "passport-github2": "^0.1.12" }, "scripts": { - "dev": "cross-env NODE_ENV=local tsx watch src/index.ts", + "dev": "cross-env NODE_ENV=development tsx watch src/index.ts & tsx scripts/openDevHealth.ts", "build": "tsc && node scripts/copy-openapi.js", "start": "cross-env NODE_ENV=production node dist/index.js", - "test": "node --experimental-vm-modules scripts/jest.mjs", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules npx --no-install jest", "pm2:start": "pm2 start ecosystem.config.cjs --env production", "pm2:stop": "pm2 stop lab-api", "pm2:restart": "pm2 restart lab-api", diff --git a/scripts/jest.mjs b/scripts/jest.mjs index f8a636b..bfc1e9f 100644 --- a/scripts/jest.mjs +++ b/scripts/jest.mjs @@ -1,5 +1,16 @@ -// scripts/jest.mjs +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; + process.env.NODE_ENV = "test"; -// Forward to Jest’s CLI entrypoint -import "../node_modules/jest/bin/jest.js"; \ No newline at end of file +const require = createRequire(import.meta.url); + +// Resolve the actual jest executable regardless of CWD +const jestBin = require.resolve("jest/bin/jest.js"); + +const result = spawnSync(process.execPath, ["--experimental-vm-modules", jestBin, ...process.argv.slice(2)], { + stdio: "inherit", + env: process.env +}); + +process.exit(result.status ?? 1); diff --git a/scripts/openDevHealth.ts b/scripts/openDevHealth.ts new file mode 100644 index 0000000..6cbe298 --- /dev/null +++ b/scripts/openDevHealth.ts @@ -0,0 +1,21 @@ +// scripts/openDevHealth.ts +import http from "node:http"; +import { exec } from "node:child_process"; + +const url = "http://localhost:8001/health"; + +function waitForServer(retries = 20) { + http + .get(url, () => { + exec('start "" chrome --incognito ' + url); + }) + .on("error", () => { + if (retries <= 0) { + console.error("Server did not start in time."); + return; + } + setTimeout(() => waitForServer(retries - 1), 500); + }); +} + +waitForServer(); diff --git a/scripts/seed.ts b/scripts/seed.ts index e6308ad..2be7914 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -4,37 +4,86 @@ import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; -// ── Path resolution (same logic as API) ───────────────── +/* =========================================================== + 🌱 HUMAN PATTERN LAB — DB SEED SCRIPT + ----------------------------------------------------------- + Purpose: Populate lab_notes + lab_note_tags with canonical + starter content for development environments. + Safety: - Refuses to run in NODE_ENV=test + - Refuses to run in production unless explicitly allowed + =========================================================== */ + +// ── ENV NORMALIZATION ────────────────────────────────────── +const rawEnv = process.env.NODE_ENV ?? "development"; +const NODE_ENV = + rawEnv === "dev" ? "development" : + rawEnv === "prod" ? "production" : + rawEnv; + +if (!["development", "test", "production"].includes(NODE_ENV)) { + throw new Error(`Invalid NODE_ENV: ${NODE_ENV}`); +} + +// ── PATH RESOLUTION (MATCHES API DEFAULTS) ───────────────── const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const dbPath = - process.env.DB_PATH - ? path.resolve(process.env.DB_PATH) +// scripts/ is sibling to src/, so ../data resolves to projectRoot/data +const defaultDbFile = + NODE_ENV === "development" + ? path.join(__dirname, "../data/lab.dev.db") : path.join(__dirname, "../data/lab.db"); -if (!fs.existsSync(dbPath)) { - throw new Error(`DB not found at ${dbPath}`); +const dbPath = + NODE_ENV === "test" + ? ":memory:" + : process.env.DB_PATH + ? path.resolve(process.env.DB_PATH) + : defaultDbFile; + +// ── GUARDRAILS ───────────────────────────────────────────── +if (NODE_ENV === "test") { + throw new Error("❌ Refusing to seed in NODE_ENV=test."); +} + +if (NODE_ENV === "production" && process.env.ALLOW_PROD_SEED !== "1") { + throw new Error( + "❌ Refusing to seed in NODE_ENV=production. Set ALLOW_PROD_SEED=1 if intentional." + ); } +// Ensure parent folder exists (SQLite does NOT create directories) +if (dbPath !== ":memory:") { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); +} + +console.log(`🌱 Seeding DB at: ${dbPath}`); +console.log(`🧭 NODE_ENV=${NODE_ENV}`); + const db = new Database(dbPath); -console.log("🌱 Seeding DB at:", dbPath); -// ── Seed Data ─────────────────────────────────────────── +// ── TYPES ───────────────────────────────────────────────── type SeedNote = { id: string; title: string; slug: string; category: string; excerpt: string; + department_id: string; shadow_density: number; safer_landing: boolean; read_time_minutes: number; published_at: string; + + coherence_score?: number; + content_html?: string | null; + content_md?: string | null; + tags: string[]; }; +// ── SEED DATA ───────────────────────────────────────────── const notes: SeedNote[] = [ { id: "invitation", @@ -168,14 +217,26 @@ const notes: SeedNote[] = [ } ]; -// ── Inserts (idempotent) ──────────────────────────────── +// ── INSERTS (IDEMPOTENT) ────────────────────────────────── const insertNote = db.prepare(` - INSERT OR IGNORE INTO lab_notes ( - id, title, slug, category, excerpt, - department_id, shadow_density, - safer_landing, read_time_minutes, published_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR IGNORE INTO lab_notes ( + id, + title, + slug, + category, + excerpt, + content_html, + content_md, + department_id, + shadow_density, + coherence_score, + safer_landing, + read_time_minutes, + published_at, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertTag = db.prepare(` @@ -183,19 +244,28 @@ const insertTag = db.prepare(` VALUES (?, ?) `); +const nowIso = () => new Date().toISOString(); + const tx = db.transaction(() => { for (const note of notes) { + const timestamp = nowIso(); + insertNote.run( note.id, note.title, note.slug, note.category, note.excerpt, + note.content_html ?? null, + note.content_md ?? null, note.department_id, note.shadow_density, + note.coherence_score ?? 1.0, note.safer_landing ? 1 : 0, note.read_time_minutes, - note.published_at + note.published_at, + timestamp, + timestamp ); for (const tag of note.tags) { @@ -205,4 +275,5 @@ const tx = db.transaction(() => { }); tx(); -console.log(`✅ Seeded ${notes.length} lab notes`); + +console.log(`✅ Seeded ${notes.length} lab notes`); \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 00a169d..6147031 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,10 +2,9 @@ import express from "express"; import cors from "cors"; import session from "express-session"; -import dotenv from "dotenv"; import passport, { configurePassport } from "./auth.js"; -import { resolveDbPath, openDb, bootstrapDb, seedMarkerNote } from "./db.js"; +import { resolveDbPath, openDb, bootstrapDb, seedMarkerNote, isDbEmpty } from "./db.js"; import { registerHealthRoutes } from "./routes/healthRoutes.js"; import { registerLabNotesRoutes } from "./routes/labNotesRoutes.js"; import { registerAdminRoutes } from "./routes/adminRoutes.js"; @@ -13,10 +12,8 @@ import OpenApiValidator from "express-openapi-validator"; import { registerOpenApiRoutes } from "./routes/openapiRoutes.js"; import fs from "node:fs"; import path from "node:path"; - -if (process.env.NODE_ENV !== "test") { - dotenv.config(); -} +import { env } from "./env.js"; +import { seedDevDb } from "./seed/devSeed.js"; export function createApp() { const app = express(); @@ -25,12 +22,20 @@ export function createApp() { const db = openDb(dbPath); bootstrapDb(db); + + // Auto-seed: only once, only in development, only if empty + if (env.NODE_ENV === "development" && isDbEmpty(db)) { + console.log("🌱 DB empty in development — auto-seeding…"); + seedDevDb(db); + } + + // Optional: marker note is fine, but only after seeding (so it doesn't block "empty" detection) seedMarkerNote(db); app.use(cors()); app.use(express.json()); - const isTest = process.env.NODE_ENV === "test"; + const isTest = env.NODE_ENV === "test"; const specPath = path.join(process.cwd(), "openapi", "openapi.json"); if (!isTest && fs.existsSync(specPath)) { @@ -61,8 +66,7 @@ export function createApp() { registerLabNotesRoutes(app, db); registerAdminRoutes(app, db); - // Optional: also guard this route registration, or let openapiRoutes.ts guard internally registerOpenApiRoutes(app); return app; -} \ No newline at end of file +} diff --git a/src/db.ts b/src/db.ts index 94d36b5..6808599 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,29 +1,28 @@ // src/db.ts import Database from "better-sqlite3"; import path from "path"; -import fs from "fs"; import { fileURLToPath } from "url"; +import { env } from "./env.js"; export function resolveDbPath(): string { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const env = process.env.NODE_ENV; - + // Default DB file paths (only used when NOT in test, and DB_PATH not provided) const defaultDbFile = - env === "local" - ? path.join(__dirname, "../data/lab.local.db") + env.NODE_ENV === "development" + ? path.join(__dirname, "../data/lab.dev.db") : path.join(__dirname, "../data/lab.db"); const dbPath = - env === "test" + env.NODE_ENV === "test" ? ":memory:" - : process.env.DB_PATH - ? path.resolve(process.env.DB_PATH) + : env.DB_PATH + ? path.resolve(env.DB_PATH) : defaultDbFile; // Guardrail: tests must NEVER hit a file DB - if (env === "test" && dbPath !== ":memory:") { + if (env.NODE_ENV === "test" && dbPath !== ":memory:") { throw new Error(`Refusing to run tests on file DB: ${dbPath}`); } @@ -38,29 +37,29 @@ export function openDb(dbPath: string) { export function bootstrapDb(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS lab_notes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - slug TEXT UNIQUE NOT NULL, - category TEXT, - excerpt TEXT, - content_html TEXT, - content_md TEXT, - department_id TEXT DEFAULT 'SCMS', - shadow_density INTEGER DEFAULT 0, - coherence_score REAL DEFAULT 1.0, - safer_landing BOOLEAN DEFAULT 0, - read_time_minutes INTEGER, - published_at TEXT, - created_at TEXT, - updated_at TEXT + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + category TEXT, + excerpt TEXT, + content_html TEXT, + content_md TEXT, + department_id TEXT DEFAULT 'SCMS', + shadow_density INTEGER DEFAULT 0, + coherence_score REAL DEFAULT 1.0, + safer_landing BOOLEAN DEFAULT 0, + read_time_minutes INTEGER, + published_at TEXT, + created_at TEXT, + updated_at TEXT ); CREATE TABLE IF NOT EXISTS lab_note_tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - note_id TEXT NOT NULL, - tag TEXT NOT NULL, - UNIQUE(note_id, tag), - FOREIGN KEY (note_id) REFERENCES lab_notes(id) ON DELETE CASCADE + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id TEXT NOT NULL, + tag TEXT NOT NULL, + UNIQUE(note_id, tag), + FOREIGN KEY (note_id) REFERENCES lab_notes(id) ON DELETE CASCADE ); DROP VIEW IF EXISTS v_lab_notes; @@ -95,3 +94,8 @@ export function seedMarkerNote(db: Database.Database) { new Date().toISOString() ); } + +export function isDbEmpty(db: Database.Database): boolean { + const row = db.prepare(`SELECT COUNT(*) as count FROM lab_notes`).get() as { count: number }; + return row.count === 0; +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..8d3d2bd --- /dev/null +++ b/src/env.ts @@ -0,0 +1,76 @@ +// src/env.ts +import dotenv from "dotenv"; + +/* =========================================================== + 🌱 HUMAN PATTERN LAB — ENV LOADER + VALIDATOR (Zod-style) + =========================================================== */ + +type NodeEnv = "development" | "test" | "production"; + +type Env = { + NODE_ENV: NodeEnv; + PORT: number; + DB_PATH: string; // optional override; db.ts decides defaults + SESSION_SECRET?: string; +}; + +class EnvError extends Error { + constructor(message: string) { + super(message); + this.name = "EnvError"; + } +} + +// ── Load dotenv as early as possible ────────────────────── +const rawNodeEnv = process.env.NODE_ENV ?? "development"; +if (rawNodeEnv !== "test") { + dotenv.config(); +} + +// ── Normalizers ─────────────────────────────────────────── +function normalizeNodeEnv(value: string): NodeEnv { + const v = value === "dev" ? "development" : value === "prod" ? "production" : value; + + if (v === "development" || v === "test" || v === "production") return v; + throw new EnvError(`Invalid NODE_ENV="${value}". Use "development", "test", or "production".`); +} + +function parsePort(value: string | undefined, fallback: number): number { + if (value == null || value.trim() === "") return fallback; + const n = Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 65535) { + throw new EnvError(`Invalid PORT="${value}". Must be an integer from 1-65535.`); + } + return n; +} + +// ── Validator (Zod-style) ───────────────────────────────── +function validateEnv(input: NodeJS.ProcessEnv): Env { + const NODE_ENV = normalizeNodeEnv(input.NODE_ENV ?? "development"); + const PORT = parsePort(input.PORT, 8001); + + const DB_PATH = (input.DB_PATH ?? "").trim(); + + // Safety rule: tests must never point at a file DB + if (NODE_ENV === "test" && DB_PATH !== "" && DB_PATH !== ":memory:") { + throw new EnvError( + `Refusing NODE_ENV=test with DB_PATH="${DB_PATH}". Use DB_PATH=":memory:" or unset DB_PATH.` + ); + } + + // Optional: gently discourage missing secret in production + const SESSION_SECRET = input.SESSION_SECRET?.trim(); + if (NODE_ENV === "production" && (!SESSION_SECRET || SESSION_SECRET.length < 16)) { + // throw if you want strict; warn if you want lenient + console.warn("⚠️ SESSION_SECRET is missing/short in production. Set a strong secret."); + } + + return { + NODE_ENV, + PORT, + DB_PATH, + SESSION_SECRET, + }; +} + +export const env = validateEnv(process.env); diff --git a/src/seed/devSeed.ts b/src/seed/devSeed.ts new file mode 100644 index 0000000..3a4c027 --- /dev/null +++ b/src/seed/devSeed.ts @@ -0,0 +1,72 @@ +// src/seed/devSeed.ts +import Database from "better-sqlite3"; + +type SeedNote = { + id: string; + title: string; + slug: string; + category: string; + excerpt: string; + department_id: string; + shadow_density: number; + safer_landing: boolean; + read_time_minutes: number; + published_at: string; + coherence_score?: number; + content_html?: string | null; + content_md?: string | null; + tags: string[]; +}; + +const notes: SeedNote[] = [ + // paste your seed notes here (same as scripts/seed.ts) +]; + +export function seedDevDb(db: Database.Database) { + const insertNote = db.prepare(` + INSERT OR IGNORE INTO lab_notes ( + id, title, slug, category, excerpt, + content_html, content_md, + department_id, shadow_density, coherence_score, + safer_landing, read_time_minutes, + published_at, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertTag = db.prepare(` + INSERT OR IGNORE INTO lab_note_tags (note_id, tag) + VALUES (?, ?) + `); + + const nowIso = () => new Date().toISOString(); + + const tx = db.transaction(() => { + for (const note of notes) { + const ts = nowIso(); + insertNote.run( + note.id, + note.title, + note.slug, + note.category, + note.excerpt, + note.content_html ?? null, + note.content_md ?? null, + note.department_id, + note.shadow_density, + note.coherence_score ?? 1.0, + note.safer_landing ? 1 : 0, + note.read_time_minutes, + note.published_at, + ts, + ts + ); + + for (const tag of note.tags) { + insertTag.run(note.id, tag); + } + } + }); + + tx(); +}