Files
PubScript/server/src/db.ts
T
Schwitzz 1c9b531996 Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed
Complete the server-backed storage layer for PubScript so the app can run
against a real Fastify + SQLite backend instead of only local IndexedDB.

Server (@pubscript/server):
- db.ts: better-sqlite3 schema + migrations (users, shares, projects, books,
  chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent
  admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD.
- auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes.
- routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions
  with multi-tenant isolation and optimistic concurrency (baseRev).
- ws.ts: Fastify websocket hub for autosave + presence events.
- mapping.ts: row <-> core type conversion.

Core (@pubscript/core):
- Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions)
  exported from index.
- storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters
  implementing the StorageAdapter contract.

Tests & tooling:
- storage.contract.test.ts: parameterized over both adapters; wired as
  `pnpm contract` and added to CI.
- seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor"
  — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board.
- Dockerfile + compose wiring for the server runtime.

Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
2026-10-24 18:31:20 -05:00

78 lines
2.5 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { ulid } from "ulid";
import { config } from "./config.js";
export type Db = Database.Database;
/**
* Open (or create) the SQLite database and bring it up to the latest
* migration. Migrations are plain `.sql` files in `src/migrations/`, applied
* in filename order and recorded in `schema_migrations` so each runs once.
*/
export function openDb(dbPath: string = config.dbPath): Db {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
migrate(db);
return db;
}
function migrate(db: Db): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)
`);
const applied = new Set(
(db.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((r) => r.name),
);
const dir = path.resolve(import.meta.dirname, "migrations");
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), "utf8");
const apply = db.transaction(() => {
db.exec(sql);
db.prepare("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)").run(
file,
new Date().toISOString(),
);
});
apply();
}
}
/** Bootstrap the first admin from env on first boot (idempotent). */
export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise<string>): Promise<void> {
const email = process.env.PUBSCRIPT_ADMIN_EMAIL;
const password = process.env.PUBSCRIPT_ADMIN_PASSWORD;
if (!email || !password) return Promise.resolve();
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as { id: string } | undefined;
if (existing) return Promise.resolve();
return hashPassword(password).then((passHash) => {
const id = ulid();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, email.split("@")[0] ?? "admin", now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email }),
);
});
}