diff --git a/src/core/identity-db.ts b/src/core/identity-db.ts index 40b7305..6179daa 100644 --- a/src/core/identity-db.ts +++ b/src/core/identity-db.ts @@ -34,7 +34,10 @@ import { } from '../queries/facts'; import { findConnectedTopicRows, + findTopicRowByNameOrAlias, + findTopicRowByNormalizedAlias, findTopicRowByNormalizedName, + listTopicAliasRowsForTopicId, listTopicRows, findChildTopicRows, findParentTopicRows, @@ -198,6 +201,65 @@ export class IdentityDB { }); } + async addTopicAlias(canonicalName: string, alias: string): Promise { + const normalizedAlias = normalizeTopicName(alias); + + if (normalizedAlias.length === 0) { + throw new IdentityDBError('Topic alias cannot be empty.'); + } + + await this.connection.db.transaction().execute(async (trx) => { + const canonicalTopic = await this.upsertTopicInExecutor(trx, { name: canonicalName }); + + if (normalizedAlias === canonicalTopic.normalizedName) { + return; + } + + const exactTopicMatch = await findTopicRowByNormalizedName(trx, normalizedAlias); + if (exactTopicMatch && exactTopicMatch.id !== canonicalTopic.id) { + throw new IdentityDBError('Cannot assign an alias that already belongs to another canonical topic.'); + } + + const aliasMatch = await findTopicRowByNormalizedAlias(trx, normalizedAlias); + if (aliasMatch) { + if (aliasMatch.id !== canonicalTopic.id) { + throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.'); + } + return; + } + + const createdAt = nowIsoString(); + await trx + .insertInto('topic_aliases') + .values({ + id: createId(), + topic_id: canonicalTopic.id, + alias: canonicalizeTopicName(alias), + normalized_alias: normalizedAlias, + is_primary: 0, + created_at: createdAt, + updated_at: createdAt, + }) + .execute(); + }); + } + + async resolveTopic(name: string): Promise { + const topicRow = await this.getRequiredTopicRow(name); + return topicRow ? mapTopicRow(topicRow) : null; + } + + async getTopicAliases(name: string): Promise { + const topicRow = await this.getRequiredTopicRow(name); + + if (!topicRow) { + return []; + } + + const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.id); + return aliasRows.map((aliasRow) => aliasRow.alias); + } + async getTopicChildren(name: string): Promise { const topicRow = await this.getRequiredTopicRow(name); @@ -365,29 +427,12 @@ export class IdentityDB { const now = nowIsoString(); if (existing) { - await executor - .updateTable('topics') - .set({ - name: canonicalizeTopicName(input.name), - category: input.category ?? existing.category, - granularity: input.granularity ?? existing.granularity, - description: input.description !== undefined ? input.description : existing.description, - metadata: - input.metadata !== undefined - ? serializeMetadata(input.metadata) - : existing.metadata, - updated_at: now, - }) - .where('id', '=', existing.id) - .execute(); + return this.updateTopicRowInExecutor(executor, existing, input, now, true); + } - const updated = await executor - .selectFrom('topics') - .selectAll() - .where('id', '=', existing.id) - .executeTakeFirstOrThrow(); - - return mapTopicRow(updated); + const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName); + if (aliasedTopic) { + return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false); } const createdRow: TopicRecord = { @@ -407,8 +452,43 @@ export class IdentityDB { return mapTopicRow(createdRow); } + private async updateTopicRowInExecutor( + executor: DatabaseExecutor, + existing: TopicRecord, + input: UpsertTopicInput, + now: string, + shouldRename: boolean, + ): Promise { + await executor + .updateTable('topics') + .set({ + name: shouldRename ? canonicalizeTopicName(input.name) : existing.name, + category: input.category ?? existing.category, + granularity: input.granularity ?? existing.granularity, + description: input.description !== undefined ? input.description : existing.description, + metadata: input.metadata !== undefined ? serializeMetadata(input.metadata) : existing.metadata, + updated_at: now, + }) + .where('id', '=', existing.id) + .execute(); + + const updated = await executor + .selectFrom('topics') + .selectAll() + .where('id', '=', existing.id) + .executeTakeFirstOrThrow(); + + return mapTopicRow(updated); + } + private async getRequiredTopicRow(name: string): Promise { - return findTopicRowByNormalizedName(this.connection.db, normalizeTopicName(name)); + const normalizedName = normalizeTopicName(name); + + if (normalizedName.length === 0) { + return undefined; + } + + return findTopicRowByNameOrAlias(this.connection.db, normalizedName); } private async hydrateFacts(factRows: FactRecord[]): Promise { diff --git a/src/core/migrations.ts b/src/core/migrations.ts index 645dd99..9c6b95a 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_ALIASES_TABLE, TOPIC_RELATIONS_TABLE, TOPICS_TABLE, } from './schema'; @@ -67,6 +68,20 @@ export async function initializeSchema( .addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation']) .execute(); + await db.schema + .createTable(TOPIC_ALIASES_TABLE) + .ifNotExists() + .addColumn('id', 'text', (column) => column.primaryKey()) + .addColumn('topic_id', 'text', (column) => + column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'), + ) + .addColumn('alias', 'text', (column) => column.notNull()) + .addColumn('normalized_alias', 'text', (column) => column.notNull().unique()) + .addColumn('is_primary', 'integer', (column) => column.notNull()) + .addColumn('created_at', 'text', (column) => column.notNull()) + .addColumn('updated_at', 'text', (column) => column.notNull()) + .execute(); + await db.schema .createIndex('fact_topics_topic_id_idx') .ifNotExists() @@ -94,4 +109,11 @@ export async function initializeSchema( .on(TOPIC_RELATIONS_TABLE) .column('child_topic_id') .execute(); + + await db.schema + .createIndex('topic_aliases_topic_id_idx') + .ifNotExists() + .on(TOPIC_ALIASES_TABLE) + .column('topic_id') + .execute(); } diff --git a/src/core/schema.ts b/src/core/schema.ts index c2dcc24..0200234 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -2,6 +2,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_ALIASES_TABLE = 'topic_aliases'; export const TOPIC_COLUMNS = [ 'id', @@ -40,3 +41,13 @@ export const TOPIC_RELATION_COLUMNS = [ 'relation', 'created_at', ] as const; + +export const TOPIC_ALIAS_COLUMNS = [ + 'id', + 'topic_id', + 'alias', + 'normalized_alias', + 'is_primary', + 'created_at', + 'updated_at', +] as const; diff --git a/src/queries/topics.ts b/src/queries/topics.ts index e2cff60..d3c29de 100644 --- a/src/queries/topics.ts +++ b/src/queries/topics.ts @@ -1,7 +1,7 @@ import type { Kysely, Transaction } from 'kysely'; import type { IdentityDatabaseSchema } from '../types/database'; -import type { TopicRecord } from '../types/domain'; +import type { TopicAliasRecord, TopicRecord } from '../types/domain'; export type DatabaseExecutor = Kysely | Transaction; @@ -20,14 +20,48 @@ export async function findTopicRowByNormalizedName( .executeTakeFirst(); } +export async function findTopicRowByNormalizedAlias( + executor: DatabaseExecutor, + normalizedAlias: string, +): Promise { + return executor + .selectFrom('topic_aliases') + .innerJoin('topics', 'topics.id', 'topic_aliases.topic_id') + .selectAll('topics') + .where('topic_aliases.normalized_alias', '=', normalizedAlias) + .executeTakeFirst(); +} + +export async function findTopicRowByNameOrAlias( + executor: DatabaseExecutor, + normalizedName: string, +): Promise { + const directMatch = await findTopicRowByNormalizedName(executor, normalizedName); + if (directMatch) { + return directMatch; + } + + return findTopicRowByNormalizedAlias(executor, normalizedName); +} + +export async function listTopicAliasRowsForTopicId( + executor: DatabaseExecutor, + topicId: string, +): Promise { + return executor + .selectFrom('topic_aliases') + .selectAll() + .where('topic_id', '=', topicId) + .orderBy('is_primary', 'desc') + .orderBy('normalized_alias', 'asc') + .execute(); +} + export async function listTopicRows( executor: DatabaseExecutor, limit?: number, ): Promise { - let query = executor - .selectFrom('topics') - .selectAll() - .orderBy('normalized_name', 'asc'); + let query = executor.selectFrom('topics').selectAll().orderBy('normalized_name', 'asc'); if (limit !== undefined) { query = query.limit(limit); diff --git a/src/types/database.ts b/src/types/database.ts index 76868e7..c9feeeb 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,8 +1,15 @@ -import type { FactRecord, FactTopicRecord, TopicRecord, TopicRelationRecord } from './domain'; +import type { + FactRecord, + FactTopicRecord, + TopicAliasRecord, + TopicRecord, + TopicRelationRecord, +} from './domain'; export interface IdentityDatabaseSchema { topics: TopicRecord; facts: FactRecord; fact_topics: FactTopicRecord; topic_relations: TopicRelationRecord; + topic_aliases: TopicAliasRecord; } diff --git a/src/types/domain.ts b/src/types/domain.ts index d5603d3..4be2cdb 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -42,3 +42,13 @@ export interface TopicRelationRecord { relation: string; created_at: string; } + +export interface TopicAliasRecord { + id: string; + topic_id: string; + alias: string; + normalized_alias: string; + is_primary: number; + created_at: string; + updated_at: string; +} diff --git a/tests/identity-db.test.ts b/tests/identity-db.test.ts index 52a592e..8f37872 100644 --- a/tests/identity-db.test.ts +++ b/tests/identity-db.test.ts @@ -51,4 +51,41 @@ describe('IdentityDB topic and fact writes', () => { expect(typeScriptFacts).toHaveLength(1); expect(typeScriptFacts[0]?.statement).toBe('I have worked with TypeScript since 2025.'); }); + + it('resolves alias names to a canonical topic', async () => { + await db.upsertTopic({ + name: 'TypeScript', + category: 'entity', + granularity: 'concrete', + }); + + await db.addTopicAlias('TypeScript', 'TS'); + + const resolved = await db.resolveTopic('ts'); + const aliases = await db.getTopicAliases('TypeScript'); + + expect(resolved?.name).toBe('TypeScript'); + expect(aliases).toEqual(['TS']); + }); + + it('reuses the canonical topic when a fact is added through an alias', async () => { + await db.upsertTopic({ + name: 'TypeScript', + category: 'entity', + granularity: 'concrete', + }); + + await db.addTopicAlias('TypeScript', 'TS'); + + await db.addFact({ + statement: 'TS compiles to JavaScript.', + topics: [{ name: 'TS', category: 'entity', granularity: 'concrete' }], + }); + + const topics = await db.listTopics({ includeFacts: false }); + const facts = await db.getTopicFacts('TypeScript'); + + expect(topics.map((topic) => topic.name)).toEqual(['TypeScript']); + expect(facts.map((fact) => fact.statement)).toEqual(['TS compiles to JavaScript.']); + }); }); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 7fce057..e119ef1 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { }); describe('initializeSchema', () => { - it('creates the topics, facts, fact_topics, and topic_relations tables', async () => { + it('creates the topics, facts, fact_topics, topic_relations, and topic_aliases tables', async () => { const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); openConnections.push(connection.destroy); @@ -35,6 +35,7 @@ describe('initializeSchema', () => { expect(tableNames).toContain('facts'); expect(tableNames).toContain('fact_topics'); expect(tableNames).toContain('topic_relations'); + expect(tableNames).toContain('topic_aliases'); }); it('creates the expected columns for each table', async () => { @@ -47,6 +48,7 @@ describe('initializeSchema', () => { 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); + const topicAliasesColumns = await sql<{ name: string }>`PRAGMA table_info(topic_aliases)`.execute(connection.db); expect(topicsColumns.rows.map((row) => row.name)).toEqual([ 'id', @@ -85,6 +87,16 @@ describe('initializeSchema', () => { 'relation', 'created_at', ]); + + expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([ + 'id', + 'topic_id', + 'alias', + 'normalized_alias', + 'is_primary', + 'created_at', + 'updated_at', + ]); }); it('is idempotent when called more than once', async () => { diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 7131882..25dbcbd 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -113,4 +113,12 @@ describe('IdentityDB queries', () => { 'software technology', ]); }); -}); + + it('resolves alias names in topic lookups', async () => { + await db.addTopicAlias('TypeScript', 'TS'); + + const topic = await db.getTopicByName('ts'); + + expect(topic?.name).toBe('TypeScript'); + }); + });