Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trueforge-postgres-schema.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/trueforge/src/db/migratePostgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Database>): Migrator {
Expand All @@ -14,10 +15,13 @@ function createMigrator(db: Kysely<Database>): Migrator {
path,
migrationFolder: path.join(import.meta.dirname, 'postgres', 'migrations'),
}),
migrationTableSchema: TRUEFORGE_SCHEMA,
});
}

async function runMigrations(input: { db: Kysely<Database>; targetMigrationName: string | undefined }): Promise<void> {
await ensureTrueforgeSchema(input.db);

const migrator = createMigrator(input.db);

const { error, results } =
Expand Down Expand Up @@ -46,6 +50,9 @@ async function runMigrations(input: { db: Kysely<Database>; targetMigrationName:
*
* This module lives at `src/db/` (bundled into `dist/main.js`) so the folder is
* always `…/postgres/migrations` — source or production.
*
* Before migrating, ensures the `trueforge` schema exists and moves any legacy
* `public` app/Kysely tables into it so existing installs keep their history.
*/
export async function migrateToLatest(db: Kysely<Database>): Promise<void> {
await runMigrations({ db, targetMigrationName: undefined });
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge/src/db/postgres/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 2 additions & 0 deletions packages/trueforge/src/db/postgres/client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}`,
Comment thread
thesujai marked this conversation as resolved.
}),
}),
});
Expand Down
44 changes: 44 additions & 0 deletions packages/trueforge/src/db/postgres/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<Database>): Promise<void> {
await db.connection().execute(async conn => {
await sql`SELECT pg_advisory_lock(hashtext('trueforge_schema_bootstrap'))`.execute(conn);
try {
await conn.transaction().execute(async trx => {
await sql`SET LOCAL lock_timeout = '5s'`.execute(trx);
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(TRUEFORGE_SCHEMA)}`.execute(trx);
for (const tableName of TABLES_TO_MOVE) {
await sql`
ALTER TABLE IF EXISTS ${sql.id('public', tableName)} SET SCHEMA ${sql.id(TRUEFORGE_SCHEMA)}
`.execute(trx);
}
});
} finally {
await sql`SELECT pg_advisory_unlock(hashtext('trueforge_schema_bootstrap'))`.execute(conn);
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
4 changes: 3 additions & 1 deletion packages/trueforge/tests/db/postgres/client.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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();
}
Expand Down
148 changes: 148 additions & 0 deletions packages/trueforge/tests/db/postgres/schemaBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { sql } from 'kysely';
import { Pool } from 'pg';
import { ulid } from 'ulid';

import { migrateToLatest } from '../../../src/db/migratePostgres';
import { createDb } from '../../../src/db/postgres/client';
import { TRUEFORGE_SCHEMA } from '../../../src/db/postgres/schema';
import { createPostgresTestDatabase, type PostgresTestDatabase } from './testDatabase';

const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip;

const TABLES_TO_CHECK = ['session', 'kysely_migration', 'kysely_migration_lock'] as const;

const APP_AND_KYSELY_TABLES = [
'kysely_migration_lock',
'kysely_migration',
'oauth_pending_authorization',
'oauth_token',
'mcp_server',
'schedule_run',
'schedule',
'agent',
'sandbox_provider',
'skill',
'model_provider',
'thread_capability_state',
'thread_context_log',
'session_event',
'turn_thread',
'turn',
'session',
] as const;

async function tableSchema(db: PostgresTestDatabase['db'], tableName: string): Promise<string | undefined> {
const { rows } = await sql<{ table_schema: string }>`
SELECT table_schema
FROM information_schema.tables
WHERE table_name = ${tableName}
AND table_schema IN (${TRUEFORGE_SCHEMA}, 'public')
`.execute(db);
return rows[0]?.table_schema;
}

async function migrationNames(db: PostgresTestDatabase['db']): Promise<string[]> {
const { rows } = await sql<{ name: string }>`
SELECT name FROM kysely_migration ORDER BY name
`.execute(db);
return rows.map(row => row.name);
}

function withDatabase(connectionString: string, database: string): string {
const parsed = new URL(connectionString.replace(/^postgres:/, 'http:'));
parsed.pathname = `/${database}`;
return parsed.toString().replace(/^http:/, 'postgres:');
}

describePg('trueforge Postgres schema bootstrap', () => {
it('places app and Kysely tables in the trueforge schema on a greenfield database', async () => {
const env = await createPostgresTestDatabase();
if (env === undefined) {
throw new Error('Postgres test environment unavailable despite globalSetup probe');
}
try {
for (const tableName of TABLES_TO_CHECK) {
expect(await tableSchema(env.db, tableName)).toBe(TRUEFORGE_SCHEMA);
}

const { rows: searchPathRows } = await sql<{ search_path: string }>`
SHOW search_path
`.execute(env.db);
expect(searchPathRows[0]?.search_path).toBe(TRUEFORGE_SCHEMA);

const names = await migrationNames(env.db);
expect(names.length).toBeGreaterThan(0);

await migrateToLatest(env.db);
expect(await migrationNames(env.db)).toEqual(names);
} finally {
await env.teardown();
}
}, 120_000);

it('moves legacy public tables into trueforge and preserves migration history', async () => {
const adminUrl = process.env['PG_STORE_TESTS_ADMIN_URL'];
if (adminUrl === undefined || adminUrl === '') {
throw new Error('PG_STORE_TESTS_ADMIN_URL unset despite globalSetup probe');
}

const databaseName = `test_${ulid().toLowerCase()}`;
if (!/^[a-z0-9_]+$/.test(databaseName)) {
throw new Error(`invalid database name: ${databaseName}`);
}

const adminPool = new Pool({ connectionString: adminUrl });
try {
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
} finally {
await adminPool.end();
}

const databaseUrl = withDatabase(adminUrl, databaseName);
const db = createDb({
connectionString: databaseUrl,
poolMax: 5,
statementTimeoutMs: 60_000,
idleInTransactionSessionTimeoutMs: 60_000,
});

try {
await migrateToLatest(db);
const beforeNames = await migrationNames(db);

// Simulate a pre-upgrade install: objects in public, trueforge schema gone.
// Use a search_path-free pool so we can address trueforge.* after createDb pinned search_path.
const publicPool = new Pool({ connectionString: databaseUrl });
try {
for (const tableName of APP_AND_KYSELY_TABLES) {
await publicPool.query(`ALTER TABLE IF EXISTS trueforge.${tableName} SET SCHEMA public`);
}
await publicPool.query('DROP SCHEMA IF EXISTS trueforge CASCADE');
} finally {
await publicPool.end();
}

await migrateToLatest(db);

for (const tableName of TABLES_TO_CHECK) {
expect(await tableSchema(db, tableName)).toBe(TRUEFORGE_SCHEMA);
}
expect(await migrationNames(db)).toEqual(beforeNames);

await migrateToLatest(db);
expect(await migrationNames(db)).toEqual(beforeNames);
} finally {
await db.destroy();
const dropPool = new Pool({ connectionString: adminUrl });
try {
await dropPool.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
[databaseName],
);
await dropPool.query(`DROP DATABASE IF EXISTS "${databaseName}"`);
} finally {
await dropPool.end();
}
}
}, 120_000);
});