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