feat: add versioned migrations and kysely query layer

This commit is contained in:
Bastian Wagner
2026-08-17 14:33:28 +02:00
parent acfbb3ab22
commit ec95a8e527
17 changed files with 513 additions and 50 deletions

View File

@@ -1,8 +1,41 @@
export function runMigrations(): Promise<void> {
console.log('No migrations configured in Phase 01');
return Promise.resolve();
import { join, sep } from 'node:path';
import { runner } from 'node-pg-migrate';
import { loadEnvironment } from '../../../libs/configuration/src';
/**
* Resolves the `backend/migrations` directory relative to this file's own
* location rather than `process.cwd()`, so migrations run identically from
* the compiled `dist/apps/api/src/migration.js` (production/deploy) and from
* the TypeScript source at `apps/api/src/migration.ts` (ts-jest integration
* tests), even though those two locations sit at different directory depths
* relative to the `backend/` package root.
*/
function resolveMigrationsDir(): string {
const segments = __dirname.split(sep);
const distIndex = segments.lastIndexOf('dist');
const backendRoot =
distIndex !== -1
? segments.slice(0, distIndex).join(sep)
: segments.slice(0, -3).join(sep);
return join(backendRoot, 'migrations');
}
export async function runMigrations(): Promise<void> {
const env = loadEnvironment(process.env);
await runner({
databaseUrl: env.databaseUrl,
dir: resolveMigrationsDir(),
direction: 'up',
count: Infinity,
migrationsTable: 'pgmigrations',
checkOrder: true,
log: (message: string) => console.log(message),
});
}
if (require.main === module) {
void runMigrations();
void runMigrations().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,10 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".*\\.integration-spec\\.ts$",
"testPathIgnorePatterns": ["<rootDir>/node_modules/", "<rootDir>/dist/"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,2 @@
export * from './schema';
export * from './kysely.module';

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { Kysely, PostgresDialect } from 'kysely';
import type { Pool } from 'pg';
import { PostgresModule } from '../../infrastructure/src';
import { POSTGRES_POOL } from '../../infrastructure/src';
import type { Database } from './schema';
export const KYSELY_DB = Symbol('KYSELY_DB');
export const kyselyDbProvider = {
provide: KYSELY_DB,
inject: [POSTGRES_POOL],
useFactory: (pool: Pool) =>
new Kysely<Database>({ dialect: new PostgresDialect({ pool }) }),
};
@Module({
imports: [PostgresModule],
providers: [kyselyDbProvider],
exports: [kyselyDbProvider],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,105 @@
import type { ColumnType, Generated } from 'kysely';
export interface UsersTable {
id: Generated<string>;
external_subject_id: string;
display_name: string;
email: string;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface UserPreferencesTable {
user_id: string;
preferred_pace: string | null;
preferred_budget_level: string | null;
max_walking_distance_km: number | null;
preferred_start_time: string | null;
child_friendly_preferred: boolean;
interests: string[];
notes: string | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripsTable {
id: Generated<string>;
name: string;
description: string | null;
owner_id: string;
start_date: string | null;
end_date: string | null;
status: string;
planning_stage: string | null;
currency: string;
version: Generated<number>;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripSettingsTable {
trip_id: string;
web_research_enabled: boolean;
periodic_agent_review_enabled: boolean;
notification_email_enabled: boolean;
notification_push_enabled: boolean;
default_research_depth: string | null;
default_planning_style: string | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripMembersTable {
id: Generated<string>;
trip_id: string;
user_id: string;
role: string;
status: string;
joined_at: ColumnType<Date, string | undefined, string> | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripInvitationsTable {
id: Generated<string>;
trip_id: string;
email: string;
invited_by_user_id: string;
token_hash: string;
expires_at: ColumnType<Date, string, string>;
accepted_at: ColumnType<Date, string | undefined, string> | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TravelersTable {
id: Generated<string>;
trip_id: string;
linked_user_id: string | null;
display_name: string;
traveler_type: string;
created_by_user_id: string;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripPreferenceOverridesTable {
id: Generated<string>;
trip_id: string;
user_id: string | null;
traveler_id: string | null;
overrides: unknown;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface Database {
users: UsersTable;
user_preferences: UserPreferencesTable;
trips: TripsTable;
trip_settings: TripSettingsTable;
trip_members: TripMembersTable;
trip_invitations: TripInvitationsTable;
travelers: TravelersTable;
trip_preference_overrides: TripPreferenceOverridesTable;
}

View File

@@ -0,0 +1,31 @@
import { Pool } from 'pg';
import { runMigrations } from '../../../apps/api/src/migration';
describe('runMigrations (integration)', () => {
it('applies all pending migrations idempotently against a real database', async () => {
await runMigrations();
await runMigrations(); // must be safe to run twice
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const tables = await pool.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema = 'public'`,
);
const names = tables.rows.map((r) => r.table_name);
for (const expected of [
'users',
'user_preferences',
'trips',
'trip_settings',
'trip_members',
'trip_invitations',
'travelers',
'trip_preference_overrides',
]) {
expect(names).toContain(expected);
}
} finally {
await pool.end();
}
});
});

View File

@@ -0,0 +1,16 @@
exports.up = (pgm) => {
pgm.createExtension('pgcrypto', { ifNotExists: true });
pgm.createTable('users', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
external_subject_id: { type: 'text', notNull: true },
display_name: { type: 'text', notNull: true },
email: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('users', 'users_external_subject_id_key', 'UNIQUE(external_subject_id)');
};
exports.down = (pgm) => {
pgm.dropTable('users');
};

View File

@@ -0,0 +1,24 @@
exports.up = (pgm) => {
pgm.createTable('user_preferences', {
user_id: {
type: 'uuid',
primaryKey: true,
notNull: true,
references: 'users(id)',
onDelete: 'CASCADE',
},
preferred_pace: { type: 'text' },
preferred_budget_level: { type: 'text' },
max_walking_distance_km: { type: 'numeric' },
preferred_start_time: { type: 'text' },
child_friendly_preferred: { type: 'boolean', notNull: true, default: false },
interests: { type: 'text[]', notNull: true, default: '{}' },
notes: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
};
exports.down = (pgm) => {
pgm.dropTable('user_preferences');
};

View File

@@ -0,0 +1,26 @@
exports.up = (pgm) => {
pgm.createTable('trips', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
name: { type: 'text', notNull: true },
description: { type: 'text' },
owner_id: { type: 'uuid', notNull: true, references: 'users(id)' },
start_date: { type: 'date' },
end_date: { type: 'date' },
status: { type: 'text', notNull: true, default: 'DRAFT' },
planning_stage: { type: 'text' },
currency: { type: 'text', notNull: true, default: 'EUR' },
version: { type: 'integer', notNull: true, default: 1 },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'trips',
'trips_status_check',
"CHECK (status IN ('DRAFT','PLANNING','BOOKING','UPCOMING','ACTIVE','COMPLETED','ARCHIVED'))",
);
pgm.createIndex('trips', 'owner_id');
};
exports.down = (pgm) => {
pgm.dropTable('trips');
};

View File

@@ -0,0 +1,23 @@
exports.up = (pgm) => {
pgm.createTable('trip_settings', {
trip_id: {
type: 'uuid',
primaryKey: true,
notNull: true,
references: 'trips(id)',
onDelete: 'CASCADE',
},
web_research_enabled: { type: 'boolean', notNull: true, default: false },
periodic_agent_review_enabled: { type: 'boolean', notNull: true, default: false },
notification_email_enabled: { type: 'boolean', notNull: true, default: true },
notification_push_enabled: { type: 'boolean', notNull: true, default: true },
default_research_depth: { type: 'text' },
default_planning_style: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
};
exports.down = (pgm) => {
pgm.dropTable('trip_settings');
};

View File

@@ -0,0 +1,25 @@
exports.up = (pgm) => {
pgm.createTable('trip_members', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
role: { type: 'text', notNull: true },
status: { type: 'text', notNull: true },
joined_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('trip_members', 'trip_members_trip_user_key', 'UNIQUE(trip_id, user_id)');
pgm.addConstraint('trip_members', 'trip_members_role_check', "CHECK (role IN ('OWNER','MEMBER'))");
pgm.addConstraint(
'trip_members',
'trip_members_status_check',
"CHECK (status IN ('INVITED','ACTIVE','DECLINED'))",
);
pgm.createIndex('trip_members', 'trip_id');
pgm.createIndex('trip_members', 'user_id');
};
exports.down = (pgm) => {
pgm.dropTable('trip_members');
};

View File

@@ -0,0 +1,20 @@
exports.up = (pgm) => {
pgm.createTable('trip_invitations', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
email: { type: 'text', notNull: true },
invited_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
token_hash: { type: 'text', notNull: true },
expires_at: { type: 'timestamptz', notNull: true },
accepted_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('trip_invitations', 'trip_invitations_token_hash_key', 'UNIQUE(token_hash)');
pgm.createIndex('trip_invitations', 'trip_id');
pgm.sql('CREATE INDEX trip_invitations_lower_email_idx ON trip_invitations (lower(email))');
};
exports.down = (pgm) => {
pgm.dropTable('trip_invitations');
};

View File

@@ -0,0 +1,22 @@
exports.up = (pgm) => {
pgm.createTable('travelers', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
linked_user_id: { type: 'uuid', references: 'users(id)' },
display_name: { type: 'text', notNull: true },
traveler_type: { type: 'text', notNull: true },
created_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'travelers',
'travelers_traveler_type_check',
"CHECK (traveler_type IN ('ADULT','CHILD','INFANT'))",
);
pgm.createIndex('travelers', 'trip_id');
};
exports.down = (pgm) => {
pgm.dropTable('travelers');
};

View File

@@ -0,0 +1,30 @@
exports.up = (pgm) => {
pgm.createTable('trip_preference_overrides', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
user_id: { type: 'uuid', references: 'users(id)' },
traveler_id: { type: 'uuid', references: 'travelers(id)', onDelete: 'CASCADE' },
overrides: { type: 'jsonb', notNull: true, default: '{}' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'trip_preference_overrides',
'trip_preference_overrides_subject_check',
'CHECK (num_nonnulls(user_id, traveler_id) = 1)',
);
pgm.createIndex('trip_preference_overrides', ['trip_id', 'user_id'], {
unique: true,
where: 'user_id IS NOT NULL',
name: 'trip_pref_overrides_trip_user_uq',
});
pgm.createIndex('trip_preference_overrides', ['trip_id', 'traveler_id'], {
unique: true,
where: 'traveler_id IS NOT NULL',
name: 'trip_pref_overrides_trip_traveler_uq',
});
};
exports.down = (pgm) => {
pgm.dropTable('trip_preference_overrides');
};

View File

@@ -20,13 +20,16 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./apps/api/test/jest-e2e.json"
"test:e2e": "jest --config ./apps/api/test/jest-e2e.json",
"test:integration": "jest --config jest-integration.json --runInBand"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"ioredis": "^6.0.0",
"kysely": "^0.29.5",
"node-pg-migrate": "^7.9.1",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"