feat: add IdentityDB core memory graph APIs

This commit is contained in:
2026-05-11 10:50:11 +09:00
parent f4b6548054
commit 9dc529af04
6 changed files with 535 additions and 1 deletions

298
src/core/identity-db.ts Normal file
View File

@@ -0,0 +1,298 @@
import {
type ConnectedTopic,
type Fact,
type FactTopic,
type ListTopicsOptions,
type Topic,
type TopicLookupOptions,
type TopicWithFacts,
type UpsertTopicInput,
type AddFactInput,
} from '../types/api';
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
import type { IdentityDatabaseSchema } from '../types/database';
import type { FactRecord, TopicRecord } from '../types/domain';
import { createDatabase } from '../adapters/dialect';
import { IdentityDBError } from './errors';
import { initializeSchema } from './migrations';
import {
canonicalizeTopicName,
createId,
mapFactRow,
mapTopicRow,
normalizeTopicName,
nowIsoString,
serializeMetadata,
} from './utils';
import {
findFactRowsConnectingTopicIds,
findFactRowsForTopicId,
findTopicLinksForFactIds,
} from '../queries/facts';
import {
findConnectedTopicRows,
findTopicRowByNormalizedName,
listTopicRows,
type DatabaseExecutor,
} from '../queries/topics';
export class IdentityDB {
private constructor(private readonly connection: DatabaseConnection) {}
static async connect(config: IdentityDBConnectionConfig): Promise<IdentityDB> {
const connection = await createDatabase(config);
return new IdentityDB(connection);
}
async initialize(): Promise<void> {
await initializeSchema(this.connection.db);
}
async close(): Promise<void> {
await this.connection.destroy();
}
async upsertTopic(input: UpsertTopicInput): Promise<Topic> {
return this.upsertTopicInExecutor(this.connection.db, input);
}
async addFact(input: AddFactInput): Promise<Fact> {
if (input.statement.trim().length === 0) {
throw new IdentityDBError('Fact statement cannot be empty.');
}
if (input.topics.length === 0) {
throw new IdentityDBError('A fact must be linked to at least one topic.');
}
return this.connection.db.transaction().execute(async (trx) => {
const createdAt = nowIsoString();
const factId = createId();
await trx
.insertInto('facts')
.values({
id: factId,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
confidence: input.confidence ?? null,
metadata: serializeMetadata(input.metadata),
created_at: createdAt,
updated_at: createdAt,
})
.execute();
const topics: FactTopic[] = [];
for (const [index, topicInput] of input.topics.entries()) {
const topic = await this.upsertTopicInExecutor(trx, topicInput);
await trx
.insertInto('fact_topics')
.values({
fact_id: factId,
topic_id: topic.id,
role: topicInput.role ?? null,
position: index,
created_at: createdAt,
})
.execute();
topics.push({
...topic,
role: topicInput.role ?? null,
position: index,
});
}
return {
id: factId,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
confidence: input.confidence ?? null,
metadata: input.metadata ?? null,
createdAt,
updatedAt: createdAt,
topics,
};
});
}
async getTopicFacts(name: string): Promise<Fact[]> {
const topicRow = await this.getRequiredTopicRow(name);
if (!topicRow) {
return [];
}
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.id);
return this.hydrateFacts(factRows);
}
async getTopicFactsLinkedTo(name: string, linkedTopicName: string): Promise<Fact[]> {
return this.findFactsConnectingTopics([name, linkedTopicName]);
}
async findFactsConnectingTopics(names: string[]): Promise<Fact[]> {
if (names.length === 0) {
return [];
}
const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name)));
if (topicRows.some((topicRow) => topicRow === undefined)) {
return [];
}
const topicIds = topicRows.map((topicRow) => topicRow!.id);
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, topicIds);
return this.hydrateFacts(factRows);
}
async getTopicByName(
name: string,
options: { includeFacts: true },
): Promise<TopicWithFacts | null>;
async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | null>;
async getTopicByName(
name: string,
options?: TopicLookupOptions,
): Promise<Topic | TopicWithFacts | null> {
const topicRow = await this.getRequiredTopicRow(name);
if (!topicRow) {
return null;
}
const topic = mapTopicRow(topicRow);
if (options?.includeFacts) {
return {
...topic,
facts: await this.getTopicFacts(name),
};
}
return topic;
}
async listTopics(options: { includeFacts: true; limit?: number }): Promise<TopicWithFacts[]>;
async listTopics(options?: ListTopicsOptions): Promise<Topic[]>;
async listTopics(
options?: ListTopicsOptions,
): Promise<Topic[] | TopicWithFacts[]> {
const rows = await listTopicRows(this.connection.db, options?.limit);
if (!options?.includeFacts) {
return rows.map(mapTopicRow);
}
const topicsWithFacts: TopicWithFacts[] = [];
for (const row of rows) {
topicsWithFacts.push({
...mapTopicRow(row),
facts: await this.getTopicFacts(row.name),
});
}
return topicsWithFacts;
}
async findConnectedTopics(name: string): Promise<ConnectedTopic[]> {
const topicRow = await this.getRequiredTopicRow(name);
if (!topicRow) {
return [];
}
const rows = await findConnectedTopicRows(this.connection.db, topicRow.id);
return rows.map((row) => ({
...mapTopicRow(row),
sharedFactCount: row.shared_fact_count,
}));
}
private async upsertTopicInExecutor(
executor: DatabaseExecutor,
input: UpsertTopicInput,
): Promise<Topic> {
const normalizedName = normalizeTopicName(input.name);
if (normalizedName.length === 0) {
throw new IdentityDBError('Topic name cannot be empty.');
}
const existing = await findTopicRowByNormalizedName(executor, normalizedName);
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();
const updated = await executor
.selectFrom('topics')
.selectAll()
.where('id', '=', existing.id)
.executeTakeFirstOrThrow();
return mapTopicRow(updated);
}
const createdRow: TopicRecord = {
id: createId(),
name: canonicalizeTopicName(input.name),
normalized_name: normalizedName,
category: input.category ?? 'custom',
granularity: input.granularity ?? 'mixed',
description: input.description ?? null,
metadata: serializeMetadata(input.metadata),
created_at: now,
updated_at: now,
};
await executor.insertInto('topics').values(createdRow).execute();
return mapTopicRow(createdRow);
}
private async getRequiredTopicRow(name: string): Promise<TopicRecord | undefined> {
return findTopicRowByNormalizedName(this.connection.db, normalizeTopicName(name));
}
private async hydrateFacts(factRows: FactRecord[]): Promise<Fact[]> {
const factIds = factRows.map((fact) => fact.id);
const topicLinks = await findTopicLinksForFactIds(this.connection.db, factIds);
const topicsByFactId = new Map<string, FactTopic[]>();
for (const topicLink of topicLinks) {
const topics = topicsByFactId.get(topicLink.fact_id) ?? [];
topics.push({
...mapTopicRow(topicLink),
role: topicLink.role,
position: topicLink.position,
});
topicsByFactId.set(topicLink.fact_id, topics);
}
return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? []));
}
}