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[]> {