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';
import {
findConnectedTopicRows,
findTopicRowByNameOrAlias,
findTopicRowByNormalizedAlias,
findTopicRowByNormalizedName,
listTopicAliasRowsForTopicId,
listTopicRows,
findChildTopicRows,
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[]> {
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<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> {
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[]> {

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
@@ -20,14 +20,48 @@ export async function findTopicRowByNormalizedName(
.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(
executor: DatabaseExecutor,
limit?: number,
): Promise<TopicRecord[]> {
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);

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 {
topics: TopicRecord;
facts: FactRecord;
fact_topics: FactTopicRecord;
topic_relations: TopicRelationRecord;
topic_aliases: TopicAliasRecord;
}

View File

@@ -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;
}