diff --git a/src/core/schema.ts b/src/core/schema.ts new file mode 100644 index 0000000..a211652 --- /dev/null +++ b/src/core/schema.ts @@ -0,0 +1,34 @@ +export const TOPICS_TABLE = 'topics'; +export const FACTS_TABLE = 'facts'; +export const FACT_TOPICS_TABLE = 'fact_topics'; + +export const TOPIC_COLUMNS = [ + 'id', + 'name', + 'normalized_name', + 'category', + 'granularity', + 'description', + 'metadata', + 'created_at', + 'updated_at', +] as const; + +export const FACT_COLUMNS = [ + 'id', + 'statement', + 'summary', + 'source', + 'confidence', + 'metadata', + 'created_at', + 'updated_at', +] as const; + +export const FACT_TOPIC_COLUMNS = [ + 'fact_id', + 'topic_id', + 'role', + 'position', + 'created_at', +] as const; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..06ea540 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,22 @@ +import type { JsonValue, TopicCategory, TopicGranularity } from './domain'; + +export interface UpsertTopicInput { + name: string; + category?: TopicCategory; + granularity?: TopicGranularity; + description?: string | null; + metadata?: JsonValue | null; +} + +export interface TopicLinkInput extends UpsertTopicInput { + role?: string | null; +} + +export interface AddFactInput { + statement: string; + summary?: string | null; + source?: string | null; + confidence?: number | null; + metadata?: JsonValue | null; + topics: TopicLinkInput[]; +} diff --git a/src/types/database.ts b/src/types/database.ts new file mode 100644 index 0000000..2ce0ea2 --- /dev/null +++ b/src/types/database.ts @@ -0,0 +1,7 @@ +import type { FactRecord, FactTopicRecord, TopicRecord } from './domain'; + +export interface IdentityDatabaseSchema { + topics: TopicRecord; + facts: FactRecord; + fact_topics: FactTopicRecord; +} diff --git a/src/types/domain.ts b/src/types/domain.ts new file mode 100644 index 0000000..8ffcc13 --- /dev/null +++ b/src/types/domain.ts @@ -0,0 +1,37 @@ +export type TopicCategory = 'entity' | 'concept' | 'temporal' | 'custom'; + +export type TopicGranularity = 'abstract' | 'concrete' | 'mixed'; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export interface TopicRecord { + id: string; + name: string; + normalized_name: string; + category: TopicCategory; + granularity: TopicGranularity; + description: string | null; + metadata: string | null; + created_at: string; + updated_at: string; +} + +export interface FactRecord { + id: string; + statement: string; + summary: string | null; + source: string | null; + confidence: number | null; + metadata: string | null; + created_at: string; + updated_at: string; +} + +export interface FactTopicRecord { + fact_id: string; + topic_id: string; + role: string | null; + position: number; + created_at: string; +} diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts new file mode 100644 index 0000000..cafb721 --- /dev/null +++ b/tests/migrations.test.ts @@ -0,0 +1,88 @@ +import { sql } from 'kysely'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createDatabase } from '../src/adapters/dialect'; +import { initializeSchema } from '../src/core/migrations'; + +const openConnections: Array<() => Promise> = []; + +afterEach(async () => { + while (openConnections.length > 0) { + const close = openConnections.pop(); + if (close) { + await close(); + } + } +}); + +describe('initializeSchema', () => { + it('creates the topics, facts, and fact_topics tables', async () => { + const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); + openConnections.push(connection.destroy); + + await initializeSchema(connection.db); + + const tables = await sql<{ name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + ORDER BY name + `.execute(connection.db); + + const tableNames = tables.rows.map((row) => row.name); + + expect(tableNames).toContain('topics'); + expect(tableNames).toContain('facts'); + expect(tableNames).toContain('fact_topics'); + }); + + it('creates the expected columns for each table', async () => { + const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); + openConnections.push(connection.destroy); + + await initializeSchema(connection.db); + + const topicsColumns = await sql<{ name: string }>`PRAGMA table_info(topics)`.execute(connection.db); + const factsColumns = await sql<{ name: string }>`PRAGMA table_info(facts)`.execute(connection.db); + const factTopicsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_topics)`.execute(connection.db); + + expect(topicsColumns.rows.map((row) => row.name)).toEqual([ + 'id', + 'name', + 'normalized_name', + 'category', + 'granularity', + 'description', + 'metadata', + 'created_at', + 'updated_at', + ]); + + expect(factsColumns.rows.map((row) => row.name)).toEqual([ + 'id', + 'statement', + 'summary', + 'source', + 'confidence', + 'metadata', + 'created_at', + 'updated_at', + ]); + + expect(factTopicsColumns.rows.map((row) => row.name)).toEqual([ + 'fact_id', + 'topic_id', + 'role', + 'position', + 'created_at', + ]); + }); + + it('is idempotent when called more than once', async () => { + const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); + openConnections.push(connection.destroy); + + await initializeSchema(connection.db); + await expect(initializeSchema(connection.db)).resolves.toBeUndefined(); + }); +});