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.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user