Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions scripts/jest.mjs
Original file line number Diff line number Diff line change
@@ -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";
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);
21 changes: 21 additions & 0 deletions scripts/openDevHealth.ts
Original file line number Diff line number Diff line change
@@ -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();
105 changes: 88 additions & 17 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -168,34 +217,55 @@ 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(`
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 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) {
Expand All @@ -205,4 +275,5 @@ const tx = db.transaction(() => {
});

tx();
console.log(`✅ Seeded ${notes.length} lab notes`);

console.log(`✅ Seeded ${notes.length} lab notes`);
22 changes: 13 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,18 @@
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";
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();
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
}
}
62 changes: 33 additions & 29 deletions src/db.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Loading