feat: add versioned migrations and kysely query layer
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
10
backend/jest-integration.json
Normal file
10
backend/jest-integration.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
backend/libs/database/src/index.ts
Normal file
2
backend/libs/database/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './kysely.module';
|
||||
22
backend/libs/database/src/kysely.module.ts
Normal file
22
backend/libs/database/src/kysely.module.ts
Normal 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 {}
|
||||
105
backend/libs/database/src/schema.ts
Normal file
105
backend/libs/database/src/schema.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
16
backend/migrations/1700000001000_create-users.cjs
Normal file
16
backend/migrations/1700000001000_create-users.cjs
Normal 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');
|
||||
};
|
||||
24
backend/migrations/1700000002000_create-user-preferences.cjs
Normal file
24
backend/migrations/1700000002000_create-user-preferences.cjs
Normal 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');
|
||||
};
|
||||
26
backend/migrations/1700000003000_create-trips.cjs
Normal file
26
backend/migrations/1700000003000_create-trips.cjs
Normal 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');
|
||||
};
|
||||
23
backend/migrations/1700000004000_create-trip-settings.cjs
Normal file
23
backend/migrations/1700000004000_create-trip-settings.cjs
Normal 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');
|
||||
};
|
||||
25
backend/migrations/1700000005000_create-trip-members.cjs
Normal file
25
backend/migrations/1700000005000_create-trip-members.cjs
Normal 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');
|
||||
};
|
||||
20
backend/migrations/1700000006000_create-trip-invitations.cjs
Normal file
20
backend/migrations/1700000006000_create-trip-invitations.cjs
Normal 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');
|
||||
};
|
||||
22
backend/migrations/1700000007000_create-travelers.cjs
Normal file
22
backend/migrations/1700000007000_create-travelers.cjs
Normal 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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user