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",
|
||||
"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": {}
|
||||
}
|
||||
|
||||
@@ -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