diff --git a/src/core/identity-db.ts b/src/core/identity-db.ts index cb2828a..40b7305 100644 --- a/src/core/identity-db.ts +++ b/src/core/identity-db.ts @@ -8,6 +8,7 @@ import { type TopicWithFacts, type UpsertTopicInput, type AddFactInput, + type LinkTopicsInput, } from '../types/api'; import type { IngestStatementOptions } from '../ingestion/types'; import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect'; @@ -35,6 +36,8 @@ import { findConnectedTopicRows, findTopicRowByNormalizedName, listTopicRows, + findChildTopicRows, + findParentTopicRows, type DatabaseExecutor, } from '../queries/topics'; @@ -152,6 +155,105 @@ export class IdentityDB { return this.addFact(factInput); } + async linkTopics(input: LinkTopicsInput): Promise { + const parentNormalizedName = normalizeTopicName(input.parentName); + const childNormalizedName = normalizeTopicName(input.childName); + + if (parentNormalizedName.length === 0 || childNormalizedName.length === 0) { + throw new IdentityDBError('Topic hierarchy links require both a parent and child topic name.'); + } + + if (parentNormalizedName === childNormalizedName) { + throw new IdentityDBError('A topic cannot be linked as its own parent.'); + } + + await this.connection.db.transaction().execute(async (trx) => { + const parentTopic = await this.upsertTopicInExecutor(trx, { + name: input.parentName, + granularity: 'abstract', + }); + const childTopic = await this.upsertTopicInExecutor(trx, { + name: input.childName, + }); + + const existing = await trx + .selectFrom('topic_relations') + .select(['parent_topic_id']) + .where('parent_topic_id', '=', parentTopic.id) + .where('child_topic_id', '=', childTopic.id) + .where('relation', '=', 'parent_of') + .executeTakeFirst(); + + if (!existing) { + await trx + .insertInto('topic_relations') + .values({ + parent_topic_id: parentTopic.id, + child_topic_id: childTopic.id, + relation: 'parent_of', + created_at: nowIsoString(), + }) + .execute(); + } + }); + } + + async getTopicChildren(name: string): Promise { + const topicRow = await this.getRequiredTopicRow(name); + + if (!topicRow) { + return []; + } + + const childRows = await findChildTopicRows(this.connection.db, topicRow.id); + return childRows.map(mapTopicRow); + } + + async getTopicParents(name: string): Promise { + const topicRow = await this.getRequiredTopicRow(name); + + if (!topicRow) { + return []; + } + + const parentRows = await findParentTopicRows(this.connection.db, topicRow.id); + return parentRows.map(mapTopicRow); + } + + async getTopicLineage(name: string): Promise { + const topicRow = await this.getRequiredTopicRow(name); + + if (!topicRow) { + return []; + } + + const lineage: Topic[] = []; + const visitedTopicIds = new Set([topicRow.id]); + let currentLevelIds = [topicRow.id]; + + while (currentLevelIds.length > 0) { + const nextLevelIds: string[] = []; + + for (const currentId of currentLevelIds) { + const parentRows = await findParentTopicRows(this.connection.db, currentId); + + for (const parentRow of parentRows) { + if (visitedTopicIds.has(parentRow.id)) { + continue; + } + + visitedTopicIds.add(parentRow.id); + nextLevelIds.push(parentRow.id); + lineage.push(mapTopicRow(parentRow)); + } + } + + currentLevelIds = nextLevelIds; + } + + return lineage; + } + async getTopicFacts(name: string): Promise { const topicRow = await this.getRequiredTopicRow(name); diff --git a/src/core/migrations.ts b/src/core/migrations.ts index af0bbdd..645dd99 100644 --- a/src/core/migrations.ts +++ b/src/core/migrations.ts @@ -3,6 +3,7 @@ import type { Kysely } from 'kysely'; import { FACTS_TABLE, FACT_TOPICS_TABLE, + TOPIC_RELATIONS_TABLE, TOPICS_TABLE, } from './schema'; import type { IdentityDatabaseSchema } from '../types/database'; @@ -52,6 +53,20 @@ export async function initializeSchema( .addPrimaryKeyConstraint('fact_topics_pk', ['fact_id', 'topic_id', 'position']) .execute(); + await db.schema + .createTable(TOPIC_RELATIONS_TABLE) + .ifNotExists() + .addColumn('parent_topic_id', 'text', (column) => + column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'), + ) + .addColumn('child_topic_id', 'text', (column) => + column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'), + ) + .addColumn('relation', 'text', (column) => column.notNull()) + .addColumn('created_at', 'text', (column) => column.notNull()) + .addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation']) + .execute(); + await db.schema .createIndex('fact_topics_topic_id_idx') .ifNotExists() @@ -65,4 +80,18 @@ export async function initializeSchema( .on(FACT_TOPICS_TABLE) .column('fact_id') .execute(); + + await db.schema + .createIndex('topic_relations_parent_topic_id_idx') + .ifNotExists() + .on(TOPIC_RELATIONS_TABLE) + .column('parent_topic_id') + .execute(); + + await db.schema + .createIndex('topic_relations_child_topic_id_idx') + .ifNotExists() + .on(TOPIC_RELATIONS_TABLE) + .column('child_topic_id') + .execute(); } diff --git a/src/core/schema.ts b/src/core/schema.ts index a211652..c2dcc24 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -1,6 +1,7 @@ export const TOPICS_TABLE = 'topics'; export const FACTS_TABLE = 'facts'; export const FACT_TOPICS_TABLE = 'fact_topics'; +export const TOPIC_RELATIONS_TABLE = 'topic_relations'; export const TOPIC_COLUMNS = [ 'id', @@ -32,3 +33,10 @@ export const FACT_TOPIC_COLUMNS = [ 'position', 'created_at', ] as const; + +export const TOPIC_RELATION_COLUMNS = [ + 'parent_topic_id', + 'child_topic_id', + 'relation', + 'created_at', +] as const; diff --git a/src/queries/topics.ts b/src/queries/topics.ts index fb71093..e2cff60 100644 --- a/src/queries/topics.ts +++ b/src/queries/topics.ts @@ -53,3 +53,31 @@ export async function findConnectedTopicRows( .orderBy('topics.normalized_name', 'asc') .execute() as Promise; } + +export async function findChildTopicRows( + executor: DatabaseExecutor, + parentTopicId: string, +): Promise { + return executor + .selectFrom('topic_relations') + .innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id') + .selectAll('topics') + .where('topic_relations.parent_topic_id', '=', parentTopicId) + .where('topic_relations.relation', '=', 'parent_of') + .orderBy('topics.normalized_name', 'asc') + .execute(); +} + +export async function findParentTopicRows( + executor: DatabaseExecutor, + childTopicId: string, +): Promise { + return executor + .selectFrom('topic_relations') + .innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id') + .selectAll('topics') + .where('topic_relations.child_topic_id', '=', childTopicId) + .where('topic_relations.relation', '=', 'parent_of') + .orderBy('topics.normalized_name', 'asc') + .execute(); +} diff --git a/src/types/api.ts b/src/types/api.ts index 10c1346..8ed5329 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -21,6 +21,11 @@ export interface AddFactInput { topics: TopicLinkInput[]; } +export interface LinkTopicsInput { + parentName: string; + childName: string; +} + export interface Topic { id: string; name: string; diff --git a/src/types/database.ts b/src/types/database.ts index 2ce0ea2..76868e7 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,7 +1,8 @@ -import type { FactRecord, FactTopicRecord, TopicRecord } from './domain'; +import type { FactRecord, FactTopicRecord, TopicRecord, TopicRelationRecord } from './domain'; export interface IdentityDatabaseSchema { topics: TopicRecord; facts: FactRecord; fact_topics: FactTopicRecord; + topic_relations: TopicRelationRecord; } diff --git a/src/types/domain.ts b/src/types/domain.ts index 8ffcc13..d5603d3 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -35,3 +35,10 @@ export interface FactTopicRecord { position: number; created_at: string; } + +export interface TopicRelationRecord { + parent_topic_id: string; + child_topic_id: string; + relation: string; + created_at: string; +} diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index cafb721..7fce057 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { }); describe('initializeSchema', () => { - it('creates the topics, facts, and fact_topics tables', async () => { + it('creates the topics, facts, fact_topics, and topic_relations tables', async () => { const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); openConnections.push(connection.destroy); @@ -34,6 +34,7 @@ describe('initializeSchema', () => { expect(tableNames).toContain('topics'); expect(tableNames).toContain('facts'); expect(tableNames).toContain('fact_topics'); + expect(tableNames).toContain('topic_relations'); }); it('creates the expected columns for each table', async () => { @@ -45,6 +46,7 @@ describe('initializeSchema', () => { 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); + const topicRelationsColumns = await sql<{ name: string }>`PRAGMA table_info(topic_relations)`.execute(connection.db); expect(topicsColumns.rows.map((row) => row.name)).toEqual([ 'id', @@ -76,6 +78,13 @@ describe('initializeSchema', () => { 'position', 'created_at', ]); + + expect(topicRelationsColumns.rows.map((row) => row.name)).toEqual([ + 'parent_topic_id', + 'child_topic_id', + 'relation', + 'created_at', + ]); }); it('is idempotent when called more than once', async () => { diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 97a9d02..7131882 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -19,6 +19,16 @@ async function seedMemoryGraph(db: IdentityDB): Promise { { name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' }, ], }); + + await db.linkTopics({ + parentName: 'software technology', + childName: 'programming language', + }); + + await db.linkTopics({ + parentName: 'programming language', + childName: 'TypeScript', + }); } describe('IdentityDB queries', () => { @@ -60,6 +70,7 @@ describe('IdentityDB queries', () => { '2025', 'I', 'programming language', + 'software technology', 'TypeScript', ]); expect('facts' in topics[0]!).toBe(false); @@ -81,4 +92,25 @@ describe('IdentityDB queries', () => { expect(facts).toHaveLength(1); expect(facts[0]?.statement).toBe('I have worked with TypeScript since 2025.'); }); + + it('lists direct child topics for a parent topic', async () => { + const children = await db.getTopicChildren('programming language'); + + expect(children.map((topic) => topic.name)).toEqual(['TypeScript']); + }); + + it('lists direct parent topics for a child topic', async () => { + const parents = await db.getTopicParents('TypeScript'); + + expect(parents.map((topic) => topic.name)).toEqual(['programming language']); + }); + + it('returns lineage from nearest parent outward', async () => { + const lineage = await db.getTopicLineage('TypeScript'); + + expect(lineage.map((topic) => topic.name)).toEqual([ + 'programming language', + 'software technology', + ]); + }); });