diff --git a/.changeset/trueforge-postgres-schema.md b/.changeset/trueforge-postgres-schema.md new file mode 100644 index 000000000..666ed95e8 --- /dev/null +++ b/.changeset/trueforge-postgres-schema.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": minor +--- + +Store Postgres app tables and Kysely migration bookkeeping in a dedicated `trueforge` schema, with an automatic one-time move from `public` so existing installs keep their data and migration history. diff --git a/packages/trueforge/src/db/migratePostgres.ts b/packages/trueforge/src/db/migratePostgres.ts index b7cec5da7..b00aba9a0 100644 --- a/packages/trueforge/src/db/migratePostgres.ts +++ b/packages/trueforge/src/db/migratePostgres.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { Kysely } from 'kysely'; import { FileMigrationProvider, Migrator } from 'kysely/migration'; +import { ensureTrueforgeSchema, TRUEFORGE_SCHEMA } from './postgres/schema'; import type { Database } from './postgres/types'; function createMigrator(db: Kysely): Migrator { @@ -14,10 +15,13 @@ function createMigrator(db: Kysely): Migrator { path, migrationFolder: path.join(import.meta.dirname, 'postgres', 'migrations'), }), + migrationTableSchema: TRUEFORGE_SCHEMA, }); } async function runMigrations(input: { db: Kysely; targetMigrationName: string | undefined }): Promise { + await ensureTrueforgeSchema(input.db); + const migrator = createMigrator(input.db); const { error, results } = diff --git a/packages/trueforge/src/db/postgres/AGENTS.md b/packages/trueforge/src/db/postgres/AGENTS.md index e6bc31ffd..380038e50 100644 --- a/packages/trueforge/src/db/postgres/AGENTS.md +++ b/packages/trueforge/src/db/postgres/AGENTS.md @@ -2,3 +2,4 @@ - Application timestamps MUST be treated as UTC instants. Serialize with `Date.prototype.toISOString()` (always `...Z` with milliseconds). - Do not run DB queries inside loops (N+1). Prefer a single batched query, a join, or an `IN`/`ANY` lookup over per-item round-trips. - Postgres migrations MUST start `up`/`down` with `SET LOCAL lock_timeout = '5s'` so waiting DDL fails fast instead of blocking later queries (including `SELECT`s) behind it in the lock queue. +- All app tables and Kysely migration bookkeeping live in the `trueforge` schema (not `public`). `runMigrations` bootstraps that schema (CREATE + move legacy `public` tables) before the Migrator runs; `createDb` sets `search_path=trueforge`. diff --git a/packages/trueforge/src/db/postgres/client.ts b/packages/trueforge/src/db/postgres/client.ts index 32979114d..6a31fbc4c 100644 --- a/packages/trueforge/src/db/postgres/client.ts +++ b/packages/trueforge/src/db/postgres/client.ts @@ -1,6 +1,7 @@ import { Kysely, PostgresDialect } from 'kysely'; import pg, { Pool } from 'pg'; +import { TRUEFORGE_SCHEMA } from './schema'; import type { Database } from './types'; const INT8_OID = 20; @@ -50,6 +51,7 @@ export function createDb(options: { max: poolMax, statement_timeout: statementTimeoutMs, idle_in_transaction_session_timeout: idleInTransactionSessionTimeoutMs, + options: `-c search_path=${TRUEFORGE_SCHEMA}`, }), }), }); diff --git a/packages/trueforge/src/db/postgres/schema.ts b/packages/trueforge/src/db/postgres/schema.ts new file mode 100644 index 000000000..07ab2a08f --- /dev/null +++ b/packages/trueforge/src/db/postgres/schema.ts @@ -0,0 +1,50 @@ +import { type Kysely, sql } from 'kysely'; + +import type { Database } from './types'; + +export const TRUEFORGE_SCHEMA = 'trueforge'; + +const TABLES_TO_MOVE = [ + 'kysely_migration', + 'kysely_migration_lock', + 'session', + 'turn', + 'turn_thread', + 'session_event', + 'thread_context_log', + 'thread_capability_state', + 'model_provider', + 'skill', + 'sandbox_provider', + 'agent', + 'schedule', + 'schedule_run', + 'mcp_server', + 'oauth_token', + 'oauth_pending_authorization', +] as const; + +export async function ensureTrueforgeSchema(db: Kysely): Promise { + await db.transaction().execute(async txn => { + await sql`SET LOCAL lock_timeout = '5s'`.execute(txn); + await sql`SELECT pg_advisory_xact_lock(hashtext('trueforge_schema_bootstrap'))`.execute(txn); + + const result = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 + FROM pg_namespace + WHERE nspname = ${TRUEFORGE_SCHEMA} + ) AS exists + `.execute(txn); + if (result.rows[0]?.exists === true) { + return; + } + + await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(TRUEFORGE_SCHEMA)}`.execute(txn); + for (const tableName of TABLES_TO_MOVE) { + await sql` + ALTER TABLE IF EXISTS ${sql.id('public', tableName)} SET SCHEMA ${sql.id(TRUEFORGE_SCHEMA)} + `.execute(txn); + } + }); +} diff --git a/packages/trueforge/tests/db/postgres/client.test.ts b/packages/trueforge/tests/db/postgres/client.test.ts index d440e2061..006418dc8 100644 --- a/packages/trueforge/tests/db/postgres/client.test.ts +++ b/packages/trueforge/tests/db/postgres/client.test.ts @@ -1,6 +1,7 @@ import { sql } from 'kysely'; import { createDb } from '../../../src/db/postgres/client'; +import { TRUEFORGE_SCHEMA } from '../../../src/db/postgres/schema'; const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip; @@ -24,12 +25,13 @@ describePg('createDb postgres session timeouts', () => { const { rows } = await sql<{ name: string; setting: string }>` SELECT name, setting FROM pg_settings - WHERE name IN ('statement_timeout', 'idle_in_transaction_session_timeout') + WHERE name IN ('statement_timeout', 'idle_in_transaction_session_timeout', 'search_path') `.execute(db); const byName = new Map(rows.map(row => [row.name, row.setting])); expect(byName.get('statement_timeout')).toBe(String(statementTimeoutMs)); expect(byName.get('idle_in_transaction_session_timeout')).toBe(String(idleInTransactionSessionTimeoutMs)); + expect(byName.get('search_path')).toBe(TRUEFORGE_SCHEMA); } finally { await db.destroy(); }