feat: add topic alias resolution APIs

This commit is contained in:
2026-05-11 11:53:56 +09:00
parent ba03ecb85b
commit 428f5021e8
9 changed files with 252 additions and 31 deletions

View File

@@ -34,7 +34,10 @@ import {
} from '../queries/facts'; } from '../queries/facts';
import { import {
findConnectedTopicRows, findConnectedTopicRows,
findTopicRowByNameOrAlias,
findTopicRowByNormalizedAlias,
findTopicRowByNormalizedName, findTopicRowByNormalizedName,
listTopicAliasRowsForTopicId,
listTopicRows, listTopicRows,
findChildTopicRows, findChildTopicRows,
findParentTopicRows, findParentTopicRows,
@@ -198,6 +201,65 @@ export class IdentityDB {
}); });
} }
async addTopicAlias(canonicalName: string, alias: string): Promise<void> {
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<Topic | null> {
const topicRow = await this.getRequiredTopicRow(name);
return topicRow ? mapTopicRow(topicRow) : null;
}
async getTopicAliases(name: string): Promise<string[]> {
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<Topic[]> { async getTopicChildren(name: string): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name);
@@ -365,29 +427,12 @@ export class IdentityDB {
const now = nowIsoString(); const now = nowIsoString();
if (existing) { if (existing) {
await executor return this.updateTopicRowInExecutor(executor, existing, input, now, true);
.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();
const updated = await executor const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName);
.selectFrom('topics') if (aliasedTopic) {
.selectAll() return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false);
.where('id', '=', existing.id)
.executeTakeFirstOrThrow();
return mapTopicRow(updated);
} }
const createdRow: TopicRecord = { const createdRow: TopicRecord = {
@@ -407,8 +452,43 @@ export class IdentityDB {
return mapTopicRow(createdRow); return mapTopicRow(createdRow);
} }
private async updateTopicRowInExecutor(
executor: DatabaseExecutor,
existing: TopicRecord,
input: UpsertTopicInput,
now: string,
shouldRename: boolean,
): Promise<Topic> {
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<TopicRecord | undefined> { private async getRequiredTopicRow(name: string): Promise<TopicRecord | undefined> {
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<Fact[]> { private async hydrateFacts(factRows: FactRecord[]): Promise<Fact[]> {

View File

@@ -3,6 +3,7 @@ import type { Kysely } from 'kysely';
import { import {
FACTS_TABLE, FACTS_TABLE,
FACT_TOPICS_TABLE, FACT_TOPICS_TABLE,
TOPIC_ALIASES_TABLE,
TOPIC_RELATIONS_TABLE, TOPIC_RELATIONS_TABLE,
TOPICS_TABLE, TOPICS_TABLE,
} from './schema'; } from './schema';
@@ -67,6 +68,20 @@ export async function initializeSchema(
.addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation']) .addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation'])
.execute(); .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 await db.schema
.createIndex('fact_topics_topic_id_idx') .createIndex('fact_topics_topic_id_idx')
.ifNotExists() .ifNotExists()
@@ -94,4 +109,11 @@ export async function initializeSchema(
.on(TOPIC_RELATIONS_TABLE) .on(TOPIC_RELATIONS_TABLE)
.column('child_topic_id') .column('child_topic_id')
.execute(); .execute();
await db.schema
.createIndex('topic_aliases_topic_id_idx')
.ifNotExists()
.on(TOPIC_ALIASES_TABLE)
.column('topic_id')
.execute();
} }

View File

@@ -2,6 +2,7 @@ export const TOPICS_TABLE = 'topics';
export const FACTS_TABLE = 'facts'; export const FACTS_TABLE = 'facts';
export const FACT_TOPICS_TABLE = 'fact_topics'; export const FACT_TOPICS_TABLE = 'fact_topics';
export const TOPIC_RELATIONS_TABLE = 'topic_relations'; export const TOPIC_RELATIONS_TABLE = 'topic_relations';
export const TOPIC_ALIASES_TABLE = 'topic_aliases';
export const TOPIC_COLUMNS = [ export const TOPIC_COLUMNS = [
'id', 'id',
@@ -40,3 +41,13 @@ export const TOPIC_RELATION_COLUMNS = [
'relation', 'relation',
'created_at', 'created_at',
] as const; ] as const;
export const TOPIC_ALIAS_COLUMNS = [
'id',
'topic_id',
'alias',
'normalized_alias',
'is_primary',
'created_at',
'updated_at',
] as const;

View File

@@ -1,7 +1,7 @@
import type { Kysely, Transaction } from 'kysely'; import type { Kysely, Transaction } from 'kysely';
import type { IdentityDatabaseSchema } from '../types/database'; import type { IdentityDatabaseSchema } from '../types/database';
import type { TopicRecord } from '../types/domain'; import type { TopicAliasRecord, TopicRecord } from '../types/domain';
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>; export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
@@ -20,14 +20,48 @@ export async function findTopicRowByNormalizedName(
.executeTakeFirst(); .executeTakeFirst();
} }
export async function findTopicRowByNormalizedAlias(
executor: DatabaseExecutor,
normalizedAlias: string,
): Promise<TopicRecord | undefined> {
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<TopicRecord | undefined> {
const directMatch = await findTopicRowByNormalizedName(executor, normalizedName);
if (directMatch) {
return directMatch;
}
return findTopicRowByNormalizedAlias(executor, normalizedName);
}
export async function listTopicAliasRowsForTopicId(
executor: DatabaseExecutor,
topicId: string,
): Promise<TopicAliasRecord[]> {
return executor
.selectFrom('topic_aliases')
.selectAll()
.where('topic_id', '=', topicId)
.orderBy('is_primary', 'desc')
.orderBy('normalized_alias', 'asc')
.execute();
}
export async function listTopicRows( export async function listTopicRows(
executor: DatabaseExecutor, executor: DatabaseExecutor,
limit?: number, limit?: number,
): Promise<TopicRecord[]> { ): Promise<TopicRecord[]> {
let query = executor let query = executor.selectFrom('topics').selectAll().orderBy('normalized_name', 'asc');
.selectFrom('topics')
.selectAll()
.orderBy('normalized_name', 'asc');
if (limit !== undefined) { if (limit !== undefined) {
query = query.limit(limit); query = query.limit(limit);

View File

@@ -1,8 +1,15 @@
import type { FactRecord, FactTopicRecord, TopicRecord, TopicRelationRecord } from './domain'; import type {
FactRecord,
FactTopicRecord,
TopicAliasRecord,
TopicRecord,
TopicRelationRecord,
} from './domain';
export interface IdentityDatabaseSchema { export interface IdentityDatabaseSchema {
topics: TopicRecord; topics: TopicRecord;
facts: FactRecord; facts: FactRecord;
fact_topics: FactTopicRecord; fact_topics: FactTopicRecord;
topic_relations: TopicRelationRecord; topic_relations: TopicRelationRecord;
topic_aliases: TopicAliasRecord;
} }

View File

@@ -42,3 +42,13 @@ export interface TopicRelationRecord {
relation: string; relation: string;
created_at: 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;
}

View File

@@ -51,4 +51,41 @@ describe('IdentityDB topic and fact writes', () => {
expect(typeScriptFacts).toHaveLength(1); expect(typeScriptFacts).toHaveLength(1);
expect(typeScriptFacts[0]?.statement).toBe('I have worked with TypeScript since 2025.'); 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.']);
});
}); });

View File

@@ -16,7 +16,7 @@ afterEach(async () => {
}); });
describe('initializeSchema', () => { 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:' }); const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
openConnections.push(connection.destroy); openConnections.push(connection.destroy);
@@ -35,6 +35,7 @@ describe('initializeSchema', () => {
expect(tableNames).toContain('facts'); expect(tableNames).toContain('facts');
expect(tableNames).toContain('fact_topics'); expect(tableNames).toContain('fact_topics');
expect(tableNames).toContain('topic_relations'); expect(tableNames).toContain('topic_relations');
expect(tableNames).toContain('topic_aliases');
}); });
it('creates the expected columns for each table', async () => { 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 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 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 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([ expect(topicsColumns.rows.map((row) => row.name)).toEqual([
'id', 'id',
@@ -85,6 +87,16 @@ describe('initializeSchema', () => {
'relation', 'relation',
'created_at', '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 () => { it('is idempotent when called more than once', async () => {

View File

@@ -113,4 +113,12 @@ describe('IdentityDB queries', () => {
'software technology', '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');
});
}); });