1c9b531996
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.
78 lines
2.5 KiB
TypeScript
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 }),
|
|
);
|
|
});
|
|
}
|