feat: add postgres schema and migration runner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 01:30:28 -06:00
parent ea4746f023
commit 8da610a895
7 changed files with 396 additions and 3 deletions
+13
View File
@@ -0,0 +1,13 @@
"use strict"
function required(name, env = process.env) {
const value = String(env[name] || "").trim()
if (!value) throw new Error(`${name} is required`)
return value
}
function loadDatabaseConfig(env = process.env) {
return { databaseUrl: required("DATABASE_URL", env) }
}
module.exports = { required, loadDatabaseConfig }
+75
View File
@@ -0,0 +1,75 @@
"use strict"
const { Pool } = require("pg")
const fs = require("fs")
const path = require("path")
const MIGRATIONS_DIR = path.join(__dirname, "..", "migrations")
const ADVISORY_LOCK_KEY = 12345678
function createPool(databaseUrl) {
return new Pool({ connectionString: databaseUrl })
}
async function withTransaction(pool, fn) {
const client = await pool.connect()
try {
await client.query("BEGIN")
const result = await fn(client)
await client.query("COMMIT")
return result
} catch (err) {
await client.query("ROLLBACK")
throw err
} finally {
client.release()
}
}
async function runMigrations(pool) {
const client = await pool.connect()
try {
// Acquire advisory lock to prevent concurrent migrations
await client.query(`SELECT pg_advisory_lock(${ADVISORY_LOCK_KEY})`)
try {
// Create schema_migrations table if it doesn't exist
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name text PRIMARY KEY,
applied_at timestamptz DEFAULT now()
)
`)
// Read all .sql files from migrations directory, sorted alphabetically
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.sort()
for (const file of files) {
// Check if migration has already been applied
const { rows } = await client.query(
"SELECT name FROM schema_migrations WHERE name = $1",
[file]
)
if (rows.length === 0) {
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8")
await client.query(sql)
await client.query(
"INSERT INTO schema_migrations (name) VALUES ($1)",
[file]
)
}
}
} finally {
// Always release the advisory lock
await client.query(`SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`)
}
} finally {
client.release()
}
}
module.exports = { createPool, withTransaction, runMigrations }
+70
View File
@@ -0,0 +1,70 @@
create table admin_sessions (
token_hash text primary key,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
last_seen_at timestamptz not null default now(),
revoked_at timestamptz
);
create table api_users (
id text primary key,
display_name text not null,
status text not null check (status in ('active', 'revoked', 'deleted')),
allow_pre boolean not null default false,
allow_post boolean not null default false,
requests_per_minute integer not null check (requests_per_minute > 0),
monthly_token_limit bigint not null check (monthly_token_limit > 0),
expires_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_used_at timestamptz,
revoked_at timestamptz,
deleted_at timestamptz
);
create table api_keys (
id text primary key,
api_user_id text not null references api_users(id),
key_hash text not null unique,
key_suffix text not null,
created_at timestamptz not null default now(),
revoked_at timestamptz
);
create unique index api_keys_one_active_per_user
on api_keys(api_user_id) where revoked_at is null;
create table usage_events (
id text primary key,
api_user_id text not null,
api_user_name text not null,
api_key_id text not null,
route text not null check (route in ('pre', 'post')),
request_started_at timestamptz not null,
request_completed_at timestamptz,
http_status integer,
model text,
prompt_tokens bigint not null default 0,
completion_tokens bigint not null default 0,
total_tokens bigint not null default 0,
latency_ms bigint,
error_code text,
audit_complete boolean not null default false
);
create index usage_events_user_started_idx on usage_events(api_user_id, request_started_at);
create table message_logs (
usage_event_id text primary key references usage_events(id),
request_json jsonb,
request_text text,
response_json jsonb,
response_text text,
response_content_type text,
streaming boolean not null default false,
partial boolean not null default false,
created_at timestamptz not null default now(),
delete_after timestamptz not null
);
create index message_logs_delete_after_idx on message_logs(delete_after);
+165
View File
@@ -0,0 +1,165 @@
{
"name": "hermes-control-plane",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-control-plane",
"version": "0.1.0",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {},
"engines": {
"node": ">=20"
}
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+6 -3
View File
@@ -6,12 +6,15 @@
"type": "commonjs",
"scripts": {
"start": "node server.cjs",
"check": "node -c server.cjs && docker compose --env-file .env.example config",
"test": "node test/status-identities.test.cjs"
"start:gateway": "node api-gateway.cjs",
"check": "node -c server.cjs && node -c api-gateway.cjs && docker compose --env-file .env.example config",
"test": "node --test test/*.test.cjs"
},
"engines": {
"node": ">=20"
},
"dependencies": {},
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {}
}
+24
View File
@@ -0,0 +1,24 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { withTestDatabase } = require("./helpers/db-test.cjs")
const { runMigrations } = require("../lib/db.cjs")
test("runMigrations creates the admin and API-user schema idempotently", async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await runMigrations(pool)
const result = await pool.query(`
select table_name from information_schema.tables
where table_schema = current_schema()
order by table_name
`)
const names = result.rows.map((row) => row.table_name)
assert(names.includes("admin_sessions"))
assert(names.includes("api_users"))
assert(names.includes("api_keys"))
assert(names.includes("usage_events"))
assert(names.includes("message_logs"))
})
})
+43
View File
@@ -0,0 +1,43 @@
"use strict"
const { Pool } = require("pg")
const crypto = require("crypto")
async function withTestDatabase(t, fn) {
const testDatabaseUrl = process.env.TEST_DATABASE_URL
if (!testDatabaseUrl) {
t.skip("TEST_DATABASE_URL not set")
return
}
const schemaName = `test_${crypto.randomBytes(8).toString("hex")}`
const pool = new Pool({
connectionString: testDatabaseUrl,
options: `-c search_path=${schemaName}`,
})
try {
// Use a dedicated client for schema creation (before pool search_path takes effect)
const setupClient = await pool.connect()
try {
await setupClient.query(`CREATE SCHEMA ${schemaName}`)
} finally {
setupClient.release()
}
await fn({ pool, schemaName })
} finally {
t.after(async () => {
// Use a plain pool without search_path override to drop the schema
const adminPool = new Pool({ connectionString: testDatabaseUrl })
try {
await adminPool.query(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`)
} finally {
await adminPool.end()
}
await pool.end()
})
}
}
module.exports = { withTestDatabase }