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) ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/core/utils.ts
Normal file
64
src/core/utils.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { Fact, FactTopic, Topic } from '../types/api';
|
||||||
|
import type { FactRecord, TopicRecord } from '../types/domain';
|
||||||
|
|
||||||
|
export function normalizeTopicName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeTopicName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowIsoString(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeMetadata(metadata: unknown): string | null {
|
||||||
|
if (metadata === undefined || metadata === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeMetadata(metadata: string | null): unknown | null {
|
||||||
|
if (metadata === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTopicRow(record: TopicRecord): Topic {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
normalizedName: record.normalized_name,
|
||||||
|
category: record.category,
|
||||||
|
granularity: record.granularity,
|
||||||
|
description: record.description,
|
||||||
|
metadata: deserializeMetadata(record.metadata) as Topic['metadata'],
|
||||||
|
createdAt: record.created_at,
|
||||||
|
updatedAt: record.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
statement: record.statement,
|
||||||
|
summary: record.summary,
|
||||||
|
source: record.source,
|
||||||
|
confidence: record.confidence,
|
||||||
|
metadata: deserializeMetadata(record.metadata) as Fact['metadata'],
|
||||||
|
createdAt: record.created_at,
|
||||||
|
updatedAt: record.updated_at,
|
||||||
|
topics,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
export {};
|
export * from './adapters';
|
||||||
|
export * from './core/identity-db';
|
||||||
|
export * from './core/migrations';
|
||||||
|
export * from './types/api';
|
||||||
|
export * from './types/database';
|
||||||
|
export * from './types/domain';
|
||||||
|
|||||||
66
src/queries/facts.ts
Normal file
66
src/queries/facts.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Kysely, Transaction } from 'kysely';
|
||||||
|
|
||||||
|
import type { IdentityDatabaseSchema } from '../types/database';
|
||||||
|
import type { FactRecord, TopicRecord } from '../types/domain';
|
||||||
|
|
||||||
|
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
|
||||||
|
|
||||||
|
export interface FactTopicJoinRow extends TopicRecord {
|
||||||
|
fact_id: string;
|
||||||
|
role: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFactRowsForTopicId(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
topicId: string,
|
||||||
|
): Promise<FactRecord[]> {
|
||||||
|
return executor
|
||||||
|
.selectFrom('facts')
|
||||||
|
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
|
||||||
|
.selectAll('facts')
|
||||||
|
.where('fact_topics.topic_id', '=', topicId)
|
||||||
|
.orderBy('facts.created_at', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFactRowsConnectingTopicIds(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
topicIds: string[],
|
||||||
|
): Promise<FactRecord[]> {
|
||||||
|
if (topicIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return executor
|
||||||
|
.selectFrom('facts')
|
||||||
|
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
|
||||||
|
.selectAll('facts')
|
||||||
|
.where('fact_topics.topic_id', 'in', topicIds)
|
||||||
|
.groupBy('facts.id')
|
||||||
|
.having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length)
|
||||||
|
.orderBy('facts.created_at', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTopicLinksForFactIds(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
factIds: string[],
|
||||||
|
): Promise<FactTopicJoinRow[]> {
|
||||||
|
if (factIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return executor
|
||||||
|
.selectFrom('fact_topics')
|
||||||
|
.innerJoin('topics', 'topics.id', 'fact_topics.topic_id')
|
||||||
|
.selectAll('topics')
|
||||||
|
.select([
|
||||||
|
'fact_topics.fact_id as fact_id',
|
||||||
|
'fact_topics.role as role',
|
||||||
|
'fact_topics.position as position',
|
||||||
|
])
|
||||||
|
.where('fact_topics.fact_id', 'in', factIds)
|
||||||
|
.orderBy('fact_topics.position', 'asc')
|
||||||
|
.execute() as Promise<FactTopicJoinRow[]>;
|
||||||
|
}
|
||||||
55
src/queries/topics.ts
Normal file
55
src/queries/topics.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Kysely, Transaction } from 'kysely';
|
||||||
|
|
||||||
|
import type { IdentityDatabaseSchema } from '../types/database';
|
||||||
|
import type { TopicRecord } from '../types/domain';
|
||||||
|
|
||||||
|
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
|
||||||
|
|
||||||
|
export interface ConnectedTopicRow extends TopicRecord {
|
||||||
|
shared_fact_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTopicRowByNormalizedName(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
normalizedName: string,
|
||||||
|
): Promise<TopicRecord | undefined> {
|
||||||
|
return executor
|
||||||
|
.selectFrom('topics')
|
||||||
|
.selectAll()
|
||||||
|
.where('normalized_name', '=', normalizedName)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTopicRows(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<TopicRecord[]> {
|
||||||
|
let query = executor
|
||||||
|
.selectFrom('topics')
|
||||||
|
.selectAll()
|
||||||
|
.orderBy('normalized_name', 'asc');
|
||||||
|
|
||||||
|
if (limit !== undefined) {
|
||||||
|
query = query.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findConnectedTopicRows(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
topicId: string,
|
||||||
|
): Promise<ConnectedTopicRow[]> {
|
||||||
|
return executor
|
||||||
|
.selectFrom('fact_topics as source_link')
|
||||||
|
.innerJoin('fact_topics as related_link', 'related_link.fact_id', 'source_link.fact_id')
|
||||||
|
.innerJoin('topics', 'topics.id', 'related_link.topic_id')
|
||||||
|
.selectAll('topics')
|
||||||
|
.select((eb) => eb.fn.count<number>('related_link.fact_id').as('shared_fact_count'))
|
||||||
|
.where('source_link.topic_id', '=', topicId)
|
||||||
|
.whereRef('related_link.topic_id', '!=', 'source_link.topic_id')
|
||||||
|
.groupBy('topics.id')
|
||||||
|
.orderBy('shared_fact_count', 'desc')
|
||||||
|
.orderBy('topics.normalized_name', 'asc')
|
||||||
|
.execute() as Promise<ConnectedTopicRow[]>;
|
||||||
|
}
|
||||||
@@ -20,3 +20,49 @@ export interface AddFactInput {
|
|||||||
metadata?: JsonValue | null;
|
metadata?: JsonValue | null;
|
||||||
topics: TopicLinkInput[];
|
topics: TopicLinkInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Topic {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
normalizedName: string;
|
||||||
|
category: TopicCategory;
|
||||||
|
granularity: TopicGranularity;
|
||||||
|
description: string | null;
|
||||||
|
metadata: JsonValue | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FactTopic extends Topic {
|
||||||
|
role: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fact {
|
||||||
|
id: string;
|
||||||
|
statement: string;
|
||||||
|
summary: string | null;
|
||||||
|
source: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
metadata: JsonValue | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
topics: FactTopic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicWithFacts extends Topic {
|
||||||
|
facts: Fact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedTopic extends Topic {
|
||||||
|
sharedFactCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicLookupOptions {
|
||||||
|
includeFacts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListTopicsOptions {
|
||||||
|
includeFacts?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user