2 Commits

9 changed files with 164 additions and 31 deletions

27
develop Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# This file belongs to the framework. You are not expected to modify it.
# Development command runner - parallel to ./mgmt for development tasks
# Usage: ./develop <command> [args...]
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: ./develop <command> [args...]"
echo ""
echo "Available commands:"
for cmd in "$DIR"/framework/develop.d/*; do
if [ -x "$cmd" ]; then
basename "$cmd"
fi
done
exit 1
fi
subcmd="$1"
shift
exec "$DIR"/framework/develop.d/"$subcmd" "$@"

View File

@@ -87,8 +87,8 @@ async function raw<T = unknown>(
// ============================================================================ // ============================================================================
// Migration file naming convention: // Migration file naming convention:
// NNNN_description.sql // yyyy-mm-dd_ss_description.sql
// e.g., 0001_initial.sql, 0002_add_users.sql // e.g., 2025-01-15_01_initial.sql, 2025-01-15_02_add_users.sql
// //
// Migrations directory: express/migrations/ // Migrations directory: express/migrations/
@@ -128,7 +128,7 @@ function getMigrationFiles(): string[] {
return fs return fs
.readdirSync(MIGRATIONS_DIR) .readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql")) .filter((f) => f.endsWith(".sql"))
.filter((f) => /^\d{4}_/.test(f)) .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f))
.sort(); .sort();
} }
@@ -137,6 +137,8 @@ async function runMigration(filename: string): Promise<void> {
const filepath = path.join(MIGRATIONS_DIR, filename); const filepath = path.join(MIGRATIONS_DIR, filename);
const content = fs.readFileSync(filepath, "utf-8"); const content = fs.readFileSync(filepath, "utf-8");
process.stdout.write(` Migration: ${filename}...`);
// Run migration in a transaction // Run migration in a transaction
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -147,8 +149,11 @@ async function runMigration(filename: string): Promise<void> {
[filename], [filename],
); );
await client.query("COMMIT"); await client.query("COMMIT");
console.log(`Applied migration: ${filename}`); console.log(" ✓");
} catch (err) { } catch (err) {
console.log(" ✗");
const message = err instanceof Error ? err.message : String(err);
console.error(` Error: ${message}`);
await client.query("ROLLBACK"); await client.query("ROLLBACK");
throw err; throw err;
} finally { } finally {
@@ -169,11 +174,10 @@ async function migrate(): Promise<void> {
return; return;
} }
console.log(`Running ${pending.length} migration(s)...`); console.log(`Applying ${pending.length} migration(s):`);
for (const file of pending) { for (const file of pending) {
await runMigration(file); await runMigration(file);
} }
console.log("Migrations complete");
} }
// List migration status // List migration status

View File

@@ -0,0 +1,50 @@
// reset-db.ts
// Development command to wipe the database and apply all migrations from scratch
import { migrate, pool, connectionConfig } from "../database";
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Require explicit confirmation unless --force is passed
if (!args.includes("--force")) {
console.error("This will DROP ALL TABLES in the database!");
console.error(` Database: ${connectionConfig.database}`);
console.error(` Host: ${connectionConfig.host}:${connectionConfig.port}`);
console.error("");
console.error("Run with --force to proceed.");
process.exit(1);
}
try {
console.log("Dropping all tables...");
// Get all table names in the public schema
const result = await pool.query<{ tablename: string }>(`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
`);
if (result.rows.length > 0) {
// Drop all tables with CASCADE to handle foreign key constraints
const tableNames = result.rows.map((r) => `"${r.tablename}"`).join(", ");
await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`);
console.log(`Dropped ${result.rows.length} table(s)`);
} else {
console.log("No tables to drop");
}
console.log("");
await migrate();
console.log("");
console.log("Database reset complete.");
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to reset database:", err.message);
process.exit(1);
});

View File

@@ -1,21 +0,0 @@
-- 0001_users.sql
-- Create users table for authentication
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'pending',
roles TEXT[] NOT NULL DEFAULT '{}',
permissions TEXT[] NOT NULL DEFAULT '{}',
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for email lookups (login)
CREATE INDEX users_email_idx ON users (LOWER(email));
-- Index for status filtering
CREATE INDEX users_status_idx ON users (status);

View File

@@ -0,0 +1,29 @@
-- 0001_users.sql
-- Create users table for authentication
CREATE TABLE users (
id UUID PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'active',
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_emails (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
email TEXT NOT NULL,
normalized_email TEXT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Enforce uniqueness only among *active* emails
CREATE UNIQUE INDEX user_emails_unique_active
ON user_emails (normalized_email)
WHERE revoked_at IS NULL;

View File

@@ -2,15 +2,16 @@
-- Create sessions table for auth tokens -- Create sessions table for auth tokens
CREATE TABLE sessions ( CREATE TABLE sessions (
token_id TEXT PRIMARY KEY, id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
user_email_id UUID REFERENCES user_emails(id),
token_type TEXT NOT NULL, token_type TEXT NOT NULL,
auth_method TEXT NOT NULL, auth_method TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT, user_agent TEXT,
ip_address TEXT,
is_used BOOLEAN DEFAULT FALSE is_used BOOLEAN DEFAULT FALSE
); );

View File

@@ -0,0 +1,20 @@
CREATE TABLE roles (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE groups (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_group_roles (
user_id UUID NOT NULL REFERENCES users(id),
group_id UUID NOT NULL REFERENCES groups(id),
role_id UUID NOT NULL REFERENCES roles(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
PRIMARY KEY (user_id, group_id, role_id)
);

View File

@@ -0,0 +1,14 @@
CREATE TABLE capabilities (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE role_capabilities (
role_id UUID NOT NULL REFERENCES roles(id),
capability_id UUID NOT NULL REFERENCES capabilities(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
PRIMARY KEY (role_id, capability_id)
);

9
framework/develop.d/reset-db Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$DIR/../.."
cd "$ROOT/express"
"$DIR"/../cmd.d/tsx develop/reset-db.ts "$@"