feat: add IdentityDB core memory graph APIs
This commit is contained in:
298
src/core/identity-db.ts
Normal file
298
src/core/identity-db.ts
Normal 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) ?? []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user