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

View File

@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4", "vitest": "^3.2.4",
@@ -140,6 +141,8 @@
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="], "@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],

View File

@@ -44,6 +44,7 @@
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"

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();
}