feat: add topic alias resolution APIs
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user