feat: add multi-dialect schema initialization

This commit is contained in:
2026-05-11 10:45:39 +09:00
parent fb140d7a50
commit 2f8712e1df
6 changed files with 249 additions and 0 deletions

163
src/adapters/dialect.ts Normal file
View File

@@ -0,0 +1,163 @@
import Database from 'better-sqlite3';
import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from 'kysely';
import { createPool as createMysqlPool } from 'mysql2';
import { Pool as PostgresPool } from 'pg';
import type { IdentityDatabaseSchema } from '../types/database';
import { IdentityDBConfigurationError } from '../core/errors';
export interface SqliteConnectionConfig {
client: 'sqlite';
filename: string;
readonly?: boolean;
}
export interface PostgresConnectionConfig {
client: 'postgres';
connectionString?: string;
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
ssl?: boolean;
}
export interface MysqlConnectionConfig {
client: 'mysql' | 'mariadb';
uri?: string;
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
}
export type IdentityDBConnectionConfig =
| SqliteConnectionConfig
| PostgresConnectionConfig
| MysqlConnectionConfig;
export interface DatabaseConnection {
client: IdentityDBConnectionConfig['client'];
db: Kysely<IdentityDatabaseSchema>;
destroy: () => Promise<void>;
}
export async function createDatabase(
config: IdentityDBConnectionConfig,
): Promise<DatabaseConnection> {
switch (config.client) {
case 'sqlite': {
const sqlite = new Database(config.filename, {
readonly: config.readonly ?? false,
});
sqlite.pragma('foreign_keys = ON');
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new SqliteDialect({
database: sqlite,
}),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
sqlite.close();
},
};
}
case 'postgres': {
const pool = new PostgresPool({
connectionString: config.connectionString,
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : undefined,
});
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new PostgresDialect({ pool }),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
await pool.end();
},
};
}
case 'mysql':
case 'mariadb': {
const mysqlOptions: {
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
} = {};
if (config.host !== undefined) {
mysqlOptions.host = config.host;
}
if (config.port !== undefined) {
mysqlOptions.port = config.port;
}
if (config.database !== undefined) {
mysqlOptions.database = config.database;
}
if (config.user !== undefined) {
mysqlOptions.user = config.user;
}
if (config.password !== undefined) {
mysqlOptions.password = config.password;
}
const pool = config.uri
? createMysqlPool(config.uri)
: createMysqlPool(mysqlOptions);
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new MysqlDialect({ pool }),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
await new Promise<void>((resolve, reject) => {
pool.end((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
default: {
const neverClient: never = config;
throw new IdentityDBConfigurationError(
`Unsupported database client: ${JSON.stringify(neverClient)}`,
);
}
}
}

1
src/adapters/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './dialect';

13
src/core/errors.ts Normal file
View File

@@ -0,0 +1,13 @@
export class IdentityDBError extends Error {
constructor(message: string) {
super(message);
this.name = 'IdentityDBError';
}
}
export class IdentityDBConfigurationError extends IdentityDBError {
constructor(message: string) {
super(message);
this.name = 'IdentityDBConfigurationError';
}
}

68
src/core/migrations.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { Kysely } from 'kysely';
import {
FACTS_TABLE,
FACT_TOPICS_TABLE,
TOPICS_TABLE,
} from './schema';
import type { IdentityDatabaseSchema } from '../types/database';
export async function initializeSchema(
db: Kysely<IdentityDatabaseSchema>,
): Promise<void> {
await db.schema
.createTable(TOPICS_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('name', 'text', (column) => column.notNull())
.addColumn('normalized_name', 'text', (column) => column.notNull().unique())
.addColumn('category', 'text', (column) => column.notNull())
.addColumn('granularity', 'text', (column) => column.notNull())
.addColumn('description', 'text')
.addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.execute();
await db.schema
.createTable(FACTS_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('statement', 'text', (column) => column.notNull())
.addColumn('summary', 'text')
.addColumn('source', 'text')
.addColumn('confidence', 'real')
.addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.execute();
await db.schema
.createTable(FACT_TOPICS_TABLE)
.ifNotExists()
.addColumn('fact_id', 'text', (column) =>
column.notNull().references(`${FACTS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('role', 'text')
.addColumn('position', 'integer', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull())
.addPrimaryKeyConstraint('fact_topics_pk', ['fact_id', 'topic_id', 'position'])
.execute();
await db.schema
.createIndex('fact_topics_topic_id_idx')
.ifNotExists()
.on(FACT_TOPICS_TABLE)
.column('topic_id')
.execute();
await db.schema
.createIndex('fact_topics_fact_id_idx')
.ifNotExists()
.on(FACT_TOPICS_TABLE)
.column('fact_id')
.execute();
}