feat: add postgres schema and migration runner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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 }
|
||||||
@@ -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);
|
||||||
Generated
+165
@@ -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
@@ -6,12 +6,15 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.cjs",
|
"start": "node server.cjs",
|
||||||
"check": "node -c server.cjs && docker compose --env-file .env.example config",
|
"start:gateway": "node api-gateway.cjs",
|
||||||
"test": "node test/status-identities.test.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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
Reference in New Issue
Block a user