import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { type DatabaseIntrospector, type Kysely, sql, type TableMetadata } from "kysely"; import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely/migration"; /** 迁移目录:packages/db/migrations,文件名 NNNN_name.ts,导出 up/down */ export const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url)); export interface MigratorOptions { /** * kysely_migration 表所在 schema。默认不指定(走 search_path)。 * 在非 public schema 里迁移(如测试隔离 schema)时必须显式指定, * 否则 Kysely 会把 public 下已存在的迁移表当成当前库的迁移记录。 */ migrationTableSchema?: string; } /** * 只看一个 schema 的内省器。Kysely 迁移器判断迁移表是否存在时会调用 introspection.getTables(), * 默认实现反射全库所有 schema,并对每个 schema 调 has_schema_privilege / pg_get_serial_sequence; * 同一个库里别的进程恰好 drop schema 时,这两个函数会报 "schema … does not exist"(并行测试共用开发库时的偶发失败)。 * 迁移器只需要知道迁移表 / 锁表在不在目标 schema,这里只查目标 schema(未指定时取 current_schema())的表名。 */ // biome-ignore lint/suspicious/noExplicitAny: 同上 function scopedIntrospector(db: Kysely, schema: string | undefined): DatabaseIntrospector { const getTables = async (): Promise => { const rows = await sql<{ name: string; schema: string; kind: string }>` select c.relname as name, n.nspname as schema, c.relkind as kind from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = coalesce(${schema ?? null}::text, current_schema()) and c.relkind in ('r', 'v', 'p', 'f')`.execute(db); return rows.rows.map((r) => ({ name: r.name, schema: r.schema, isView: r.kind === "v", isForeign: r.kind === "f", columns: [], })); }; return { async getSchemas() { const rows = await sql<{ name: string }>` select nspname as name from pg_catalog.pg_namespace where nspname = coalesce(${schema ?? null}::text, current_schema())`.execute(db); return rows.rows; }, getTables, }; } /** 把 db.introspection 换成只看目标 schema 的实现,其余成员原样转发(方法绑回原实例,Kysely 用了私有字段) */ // biome-ignore lint/suspicious/noExplicitAny: 同上 function withScopedIntrospection(db: Kysely, schema: string | undefined): Kysely { const introspection = scopedIntrospector(db, schema); return new Proxy(db, { get(target, prop) { if (prop === "introspection") return introspection; const v = Reflect.get(target, prop, target); return typeof v === "function" ? v.bind(target) : v; }, }); } // biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型 export function createMigrator(db: Kysely, opts: MigratorOptions = {}): Migrator { return new Migrator({ db: withScopedIntrospection(db, opts.migrationTableSchema), provider: new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR }), ...(opts.migrationTableSchema ? { migrationTableSchema: opts.migrationTableSchema } : {}), }); } /** 迁移到最新;失败时抛出 */ // biome-ignore lint/suspicious/noExplicitAny: 同上 export async function migrateToLatest(db: Kysely, opts: MigratorOptions = {}): Promise { const result = await createMigrator(db, opts).migrateToLatest(); if (result.error) throw result.error; return result; } /** 清空 public schema(仅开发/测试用) */ // biome-ignore lint/suspicious/noExplicitAny: 同上 export async function resetSchema(db: Kysely): Promise { await sql`drop schema if exists public cascade`.execute(db); await sql`create schema public`.execute(db); }