diff --git a/bun.lock b/bun.lock index 27a6dc5..3edeca0 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.0.0", + "@types/pg": "^8.20.0", "tsup": "^8.5.0", "typescript": "^5.8.3", "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/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/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=="], diff --git a/package.json b/package.json index 5fab63b..692d5ca 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.0.0", + "@types/pg": "^8.20.0", "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" diff --git a/src/adapters/dialect.ts b/src/adapters/dialect.ts new file mode 100644 index 0000000..4c1089b --- /dev/null +++ b/src/adapters/dialect.ts @@ -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; + destroy: () => Promise; +} + +export async function createDatabase( + config: IdentityDBConnectionConfig, +): Promise { + switch (config.client) { + case 'sqlite': { + const sqlite = new Database(config.filename, { + readonly: config.readonly ?? false, + }); + + sqlite.pragma('foreign_keys = ON'); + + const db = new Kysely({ + 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({ + 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({ + dialect: new MysqlDialect({ pool }), + }); + + return { + client: config.client, + db, + destroy: async () => { + await db.destroy(); + await new Promise((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)}`, + ); + } + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..77ea054 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1 @@ +export * from './dialect'; diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..1ad064a --- /dev/null +++ b/src/core/errors.ts @@ -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'; + } +} diff --git a/src/core/migrations.ts b/src/core/migrations.ts new file mode 100644 index 0000000..af0bbdd --- /dev/null +++ b/src/core/migrations.ts @@ -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, +): Promise { + 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(); +}