diff --git a/src/core/identity-db.ts b/src/core/identity-db.ts index c802a19..1500c7d 100644 --- a/src/core/identity-db.ts +++ b/src/core/identity-db.ts @@ -1,39 +1,27 @@ import { + type AddFactInput, type ConnectedTopic, type Fact, type FactTopic, type FindSimilarFactsInput, type IndexFactEmbeddingsInput, + type LinkTopicsInput, type ListTopicsOptions, type ScoredFact, type SearchFactsInput, + type Space, + type SpaceScopedInput, type Topic, type TopicLookupOptions, type TopicWithFacts, + type UpsertSpaceInput, type UpsertTopicInput, - type AddFactInput, - type LinkTopicsInput, } from '../types/api'; import type { IngestStatementOptions } from '../ingestion/types'; import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect'; import type { IdentityDatabaseSchema } from '../types/database'; -import type { FactRecord, TopicRecord } from '../types/domain'; +import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain'; import { createDatabase } from '../adapters/dialect'; -import { IdentityDBError } from './errors'; -import { initializeSchema } from './migrations'; -import { - canonicalizeTopicName, - cosineSimilarity, - createContentHash, - createId, - deserializeEmbedding, - mapFactRow, - mapTopicRow, - normalizeTopicName, - nowIsoString, - serializeEmbedding, - serializeMetadata, -} from './utils'; import { extractFact } from '../ingestion/extractor'; import { findFactRowsConnectingTopicIds, @@ -41,16 +29,37 @@ import { findTopicLinksForFactIds, } from '../queries/facts'; import { + type DatabaseExecutor, + findChildTopicRows, findConnectedTopicRows, + findParentTopicRows, + findSpaceRowByNormalizedName, findTopicRowByNameOrAlias, findTopicRowByNormalizedAlias, findTopicRowByNormalizedName, listTopicAliasRowsForTopicId, listTopicRows, - findChildTopicRows, - findParentTopicRows, - type DatabaseExecutor, } from '../queries/topics'; +import { IdentityDBError } from './errors'; +import { initializeSchema } from './migrations'; +import { + canonicalizeSpaceName, + canonicalizeTopicName, + cosineSimilarity, + createContentHash, + createId, + deserializeEmbedding, + mapFactRow, + mapSpaceRow, + mapTopicRow, + normalizeSpaceName, + normalizeTopicName, + nowIsoString, + serializeEmbedding, + serializeMetadata, +} from './utils'; + +const DEFAULT_SPACE_NAME = 'default'; export class IdentityDB { private constructor(private readonly connection: DatabaseConnection) {} @@ -68,6 +77,72 @@ export class IdentityDB { await this.connection.destroy(); } + async upsertSpace(input: UpsertSpaceInput): Promise { + return this.connection.db.transaction().execute(async (trx) => { + const normalizedName = normalizeSpaceName(input.name); + if (normalizedName.length === 0) { + throw new IdentityDBError('Space name cannot be empty.'); + } + + const now = nowIsoString(); + const existing = await findSpaceRowByNormalizedName(trx, normalizedName); + + if (existing) { + await trx + .updateTable('spaces') + .set({ + name: canonicalizeSpaceName(input.name), + 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 trx + .selectFrom('spaces') + .selectAll() + .where('id', '=', existing.id) + .executeTakeFirstOrThrow(); + + return mapSpaceRow(updated); + } + + const createdRow: SpaceRecord = { + id: createId(), + name: canonicalizeSpaceName(input.name), + normalized_name: normalizedName, + description: input.description ?? null, + metadata: serializeMetadata(input.metadata), + created_at: now, + updated_at: now, + }; + + await trx.insertInto('spaces').values(createdRow).execute(); + return mapSpaceRow(createdRow); + }); + } + + async getSpaceByName(name: string): Promise { + const normalizedName = normalizeSpaceName(name); + if (normalizedName.length === 0) { + return null; + } + + const row = await findSpaceRowByNormalizedName(this.connection.db, normalizedName); + return row ? mapSpaceRow(row) : null; + } + + async listSpaces(): Promise { + const rows = await this.connection.db + .selectFrom('spaces') + .selectAll() + .orderBy('normalized_name', 'asc') + .execute(); + + return rows.map(mapSpaceRow); + } + async upsertTopic(input: UpsertTopicInput): Promise { return this.upsertTopicInExecutor(this.connection.db, input); } @@ -82,6 +157,7 @@ export class IdentityDB { } return this.connection.db.transaction().execute(async (trx) => { + const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName); const createdAt = nowIsoString(); const factId = createId(); @@ -89,6 +165,7 @@ export class IdentityDB { .insertInto('facts') .values({ id: factId, + space_id: space.id, statement: input.statement.trim(), summary: input.summary ?? null, source: input.source ?? null, @@ -103,7 +180,11 @@ export class IdentityDB { for (let index = 0; index < input.topics.length; index += 1) { const topicInput = input.topics[index]!; - const topic = await this.upsertTopicInExecutor(trx, topicInput); + this.assertScopedTopicInput(space, topicInput.spaceName); + const topic = await this.upsertTopicInExecutor(trx, { + ...topicInput, + spaceName: space.name, + }); await trx .insertInto('fact_topics') @@ -125,6 +206,7 @@ export class IdentityDB { return { id: factId, + spaceId: space.id, statement: input.statement.trim(), summary: input.summary ?? null, source: input.source ?? null, @@ -137,14 +219,12 @@ export class IdentityDB { }); } - async ingestStatement( - statement: string, - options: IngestStatementOptions, - ): Promise { + async ingestStatement(statement: string, options: IngestStatementOptions): Promise { const extracted = await extractFact(statement, options.extractor); const factInput: AddFactInput = { statement: extracted.statement ?? statement, topics: extracted.topics, + spaceName: options.spaceName, }; if (extracted.summary !== undefined) { @@ -170,6 +250,7 @@ export class IdentityDB { topicNames: factInput.topics.map((topic) => topic.name), limit: 1, minimumScore: options.duplicateThreshold ?? 0.97, + spaceName: options.spaceName, }); if (similarFacts[0]) { @@ -180,15 +261,27 @@ export class IdentityDB { const fact = await this.addFact(factInput); if (options.embeddingProvider) { - await this.indexFactEmbedding(fact.id, { provider: options.embeddingProvider }); + await this.indexFactEmbedding(fact.id, { + provider: options.embeddingProvider, + spaceName: options.spaceName, + }); } return fact; } async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise { - const factRows = await this.connection.db.selectFrom('facts').selectAll().orderBy('created_at', 'asc').execute(); + const space = await this.getSpaceForRead(input.spaceName); + if (input.spaceName && !space) { + return; + } + let factQuery = this.connection.db.selectFrom('facts').selectAll().orderBy('created_at', 'asc'); + if (space) { + factQuery = factQuery.where('space_id', '=', space.id); + } + + const factRows = await factQuery.execute(); if (factRows.length === 0) { return; } @@ -222,6 +315,13 @@ export class IdentityDB { throw new IdentityDBError(`Fact not found: ${factId}`); } + if (input.spaceName) { + const space = await this.getSpaceForRead(input.spaceName); + if (!space || space.id !== factRow.space_id) { + throw new IdentityDBError(`Fact ${factId} does not belong to space ${canonicalizeSpaceName(input.spaceName)}.`); + } + } + const embedding = await input.provider.embed(factRow.statement); this.assertEmbeddingShape(embedding, input.provider.dimensions); @@ -236,6 +336,11 @@ export class IdentityDB { return []; } + const space = await this.getSpaceForRead(input.spaceName); + if (input.spaceName && !space) { + return []; + } + const queryEmbedding = await input.provider.embed(queryText); this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions); @@ -245,6 +350,7 @@ export class IdentityDB { topicNames: input.topicNames, limit: input.limit, minimumScore: input.minimumScore, + spaceId: space?.id, }); } @@ -254,6 +360,11 @@ export class IdentityDB { return []; } + const space = await this.getSpaceForRead(input.spaceName); + if (input.spaceName && !space) { + return []; + } + const queryEmbedding = await input.provider.embed(statement); this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions); @@ -263,6 +374,7 @@ export class IdentityDB { topicNames: input.topicNames, limit: input.limit, minimumScore: input.minimumScore, + spaceId: space?.id, }); } @@ -279,12 +391,15 @@ export class IdentityDB { } await this.connection.db.transaction().execute(async (trx) => { + const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName); const parentTopic = await this.upsertTopicInExecutor(trx, { name: input.parentName, granularity: 'abstract', + spaceName: space.name, }); const childTopic = await this.upsertTopicInExecutor(trx, { name: input.childName, + spaceName: space.name, }); const existing = await trx @@ -309,7 +424,7 @@ export class IdentityDB { }); } - async addTopicAlias(canonicalName: string, alias: string): Promise { + async addTopicAlias(canonicalName: string, alias: string, options?: SpaceScopedInput): Promise { const normalizedAlias = normalizeTopicName(alias); if (normalizedAlias.length === 0) { @@ -317,18 +432,22 @@ export class IdentityDB { } await this.connection.db.transaction().execute(async (trx) => { - const canonicalTopic = await this.upsertTopicInExecutor(trx, { name: canonicalName }); + const space = await this.getOrCreateSpaceInExecutor(trx, options?.spaceName); + const canonicalTopic = await this.upsertTopicInExecutor(trx, { + name: canonicalName, + spaceName: space.name, + }); if (normalizedAlias === canonicalTopic.normalizedName) { return; } - const exactTopicMatch = await findTopicRowByNormalizedName(trx, normalizedAlias); + const exactTopicMatch = await findTopicRowByNormalizedName(trx, space.id, 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); + const aliasMatch = await findTopicRowByNormalizedAlias(trx, space.id, normalizedAlias); if (aliasMatch) { if (aliasMatch.id !== canonicalTopic.id) { throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.'); @@ -341,6 +460,7 @@ export class IdentityDB { .insertInto('topic_aliases') .values({ id: createId(), + space_id: space.id, topic_id: canonicalTopic.id, alias: canonicalizeTopicName(alias), normalized_alias: normalizedAlias, @@ -352,47 +472,43 @@ export class IdentityDB { }); } - async resolveTopic(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); + async resolveTopic(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); return topicRow ? mapTopicRow(topicRow) : null; } - async getTopicAliases(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicAliases(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } - const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.id); + const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id); return aliasRows.map((aliasRow) => aliasRow.alias); } - async getTopicChildren(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicChildren(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } - const childRows = await findChildTopicRows(this.connection.db, topicRow.id); + const childRows = await findChildTopicRows(this.connection.db, topicRow.space_id, topicRow.id); return childRows.map(mapTopicRow); } - async getTopicParents(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicParents(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } - const parentRows = await findParentTopicRows(this.connection.db, topicRow.id); + const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, topicRow.id); return parentRows.map(mapTopicRow); } - async getTopicLineage(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicLineage(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } @@ -405,8 +521,7 @@ export class IdentityDB { const nextLevelIds: string[] = []; for (const currentId of currentLevelIds) { - const parentRows = await findParentTopicRows(this.connection.db, currentId); - + const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, currentId); for (const parentRow of parentRows) { if (visitedTopicIds.has(parentRow.id)) { continue; @@ -424,97 +539,97 @@ export class IdentityDB { return lineage; } - async getTopicFacts(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicFacts(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } - const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.id); - return this.hydrateFacts(factRows); + const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id); + return this.hydrateFacts(factRows, topicRow.space_id); } - async getTopicFactsLinkedTo(name: string, linkedTopicName: string): Promise { - return this.findFactsConnectingTopics([name, linkedTopicName]); + async getTopicFactsLinkedTo(name: string, linkedTopicName: string, options?: SpaceScopedInput): Promise { + return this.findFactsConnectingTopics([name, linkedTopicName], options); } - async findFactsConnectingTopics(names: string[]): Promise { + async findFactsConnectingTopics(names: string[], options?: SpaceScopedInput): Promise { if (names.length === 0) { return []; } - const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name))); + const space = await this.getSpaceForRead(options?.spaceName); + if (options?.spaceName && !space) { + return []; + } + const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name, options?.spaceName))); if (topicRows.some((topicRow) => topicRow === undefined)) { return []; } const topicIds = topicRows.map((topicRow) => topicRow!.id); - const factRows = await findFactRowsConnectingTopicIds(this.connection.db, topicIds); + const spaceId = topicRows[0]!.space_id ?? space?.id; + const factRows = await findFactRowsConnectingTopicIds(this.connection.db, spaceId, topicIds); - return this.hydrateFacts(factRows); + return this.hydrateFacts(factRows, spaceId); } - async getTopicByName( - name: string, - options: { includeFacts: true }, - ): Promise; + async getTopicByName(name: string, options: { includeFacts: true; spaceName?: string }): Promise; async getTopicByName(name: string, options?: TopicLookupOptions): Promise; - async getTopicByName( - name: string, - options?: TopicLookupOptions, - ): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async getTopicByName(name: string, options?: TopicLookupOptions): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return null; } const topic = mapTopicRow(topicRow); - if (options?.includeFacts) { return { ...topic, - facts: await this.getTopicFacts(name), + facts: await this.getTopicFacts(name, { spaceName: options.spaceName }), }; } return topic; } - async listTopics(options: { includeFacts: true; limit?: number }): Promise; + async listTopics(options: { includeFacts: true; limit?: number; spaceName?: string }): Promise; async listTopics(options?: ListTopicsOptions): Promise; - async listTopics( - options?: ListTopicsOptions, - ): Promise { - const rows = await listTopicRows(this.connection.db, options?.limit); + async listTopics(options?: ListTopicsOptions): Promise { + const space = await this.getSpaceForRead(options?.spaceName); + if (options?.spaceName && !space) { + return []; + } + const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead(); + if (!spaceId) { + return []; + } + + const rows = await listTopicRows(this.connection.db, spaceId, 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), + facts: await this.getTopicFacts(row.name, { spaceName: options?.spaceName }), }); } return topicsWithFacts; } - async findConnectedTopics(name: string): Promise { - const topicRow = await this.getRequiredTopicRow(name); - + async findConnectedTopics(name: string, options?: SpaceScopedInput): Promise { + const topicRow = await this.getRequiredTopicRow(name, options?.spaceName); if (!topicRow) { return []; } - const rows = await findConnectedTopicRows(this.connection.db, topicRow.id); - + const rows = await findConnectedTopicRows(this.connection.db, topicRow.space_id, topicRow.id); return rows.map((row) => ({ ...mapTopicRow(row), sharedFactCount: row.shared_fact_count, @@ -527,18 +642,25 @@ export class IdentityDB { topicNames?: string[] | undefined; limit?: number | undefined; minimumScore?: number | undefined; + spaceId?: string | undefined; }): Promise { - const topicIds = await this.resolveTopicIds(input.topicNames); + const effectiveSpaceId = input.spaceId ?? await this.getDefaultSpaceIdForRead(); + if (!effectiveSpaceId) { + return []; + } + + const topicIds = await this.resolveTopicIds(input.topicNames, effectiveSpaceId); if (topicIds === null) { return []; } const factRows = topicIds.length > 0 - ? await findFactRowsConnectingTopicIds(this.connection.db, topicIds) + ? await findFactRowsConnectingTopicIds(this.connection.db, effectiveSpaceId, topicIds) : await this.connection.db .selectFrom('facts') .innerJoin('fact_embeddings', 'fact_embeddings.fact_id', 'facts.id') .selectAll('facts') + .where('facts.space_id', '=', effectiveSpaceId) .where('fact_embeddings.model', '=', input.providerModel) .orderBy('facts.created_at', 'asc') .execute(); @@ -547,14 +669,14 @@ export class IdentityDB { return []; } - const embeddingRowsQuery = this.connection.db + const embeddingRows = await this.connection.db .selectFrom('fact_embeddings') - .selectAll() - .where('model', '=', input.providerModel); - - const embeddingRows = factRows.length > 0 - ? await embeddingRowsQuery.where('fact_id', 'in', factRows.map((factRow) => factRow.id)).execute() - : []; + .innerJoin('facts', 'facts.id', 'fact_embeddings.fact_id') + .selectAll('fact_embeddings') + .where('facts.space_id', '=', effectiveSpaceId) + .where('fact_embeddings.model', '=', input.providerModel) + .where('fact_embeddings.fact_id', 'in', factRows.map((factRow) => factRow.id)) + .execute(); const embeddingsByFactId = new Map( embeddingRows.map((embeddingRow) => [embeddingRow.fact_id, deserializeEmbedding(embeddingRow.embedding)]), @@ -578,7 +700,7 @@ export class IdentityDB { return []; } - const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow)); + const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow), effectiveSpaceId); const factsById = new Map(hydratedFacts.map((fact) => [fact.id, fact])); return scoredRows.map((entry) => ({ @@ -587,12 +709,12 @@ export class IdentityDB { })); } - private async resolveTopicIds(topicNames?: string[]): Promise { + private async resolveTopicIds(topicNames: string[] | undefined, spaceId: string): Promise { if (!topicNames || topicNames.length === 0) { return []; } - const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRow(topicName))); + const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRowInSpaceId(topicName, spaceId))); if (topicRows.some((topicRow) => !topicRow)) { return null; } @@ -637,30 +759,28 @@ export class IdentityDB { } } - private async upsertTopicInExecutor( - executor: DatabaseExecutor, - input: UpsertTopicInput, - ): Promise { + private async upsertTopicInExecutor(executor: DatabaseExecutor, input: UpsertTopicInput): Promise { const normalizedName = normalizeTopicName(input.name); - if (normalizedName.length === 0) { throw new IdentityDBError('Topic name cannot be empty.'); } - const existing = await findTopicRowByNormalizedName(executor, normalizedName); + const space = await this.getOrCreateSpaceInExecutor(executor, input.spaceName); + const existing = await findTopicRowByNormalizedName(executor, space.id, normalizedName); const now = nowIsoString(); if (existing) { return this.updateTopicRowInExecutor(executor, existing, input, now, true); } - const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName); + const aliasedTopic = await findTopicRowByNormalizedAlias(executor, space.id, normalizedName); if (aliasedTopic) { return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false); } const createdRow: TopicRecord = { id: createId(), + space_id: space.id, name: canonicalizeTopicName(input.name), normalized_name: normalizedName, category: input.category ?? 'custom', @@ -672,7 +792,6 @@ export class IdentityDB { }; await executor.insertInto('topics').values(createdRow).execute(); - return mapTopicRow(createdRow); } @@ -705,22 +824,39 @@ export class IdentityDB { return mapTopicRow(updated); } - private async getRequiredTopicRow(name: string): Promise { - const normalizedName = normalizeTopicName(name); + private async getRequiredTopicRow(name: string, spaceName?: string): Promise { + const space = await this.getSpaceForRead(spaceName); + if (spaceName && !space) { + return undefined; + } + const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead(); + if (!spaceId) { + return undefined; + } + + return this.getRequiredTopicRowInSpaceId(name, spaceId); + } + + private async getRequiredTopicRowInSpaceId(name: string, spaceId: string): Promise { + const normalizedName = normalizeTopicName(name); if (normalizedName.length === 0) { return undefined; } - return findTopicRowByNameOrAlias(this.connection.db, normalizedName); + return findTopicRowByNameOrAlias(this.connection.db, spaceId, normalizedName); } - private async hydrateFacts(factRows: FactRecord[]): Promise { + private async hydrateFacts(factRows: FactRecord[], spaceId?: string): Promise { + if (factRows.length === 0) { + return []; + } + + const effectiveSpaceId = spaceId ?? factRows[0]!.space_id; const factIds = factRows.map((fact) => fact.id); - const topicLinks = await findTopicLinksForFactIds(this.connection.db, factIds); + const topicLinks = await findTopicLinksForFactIds(this.connection.db, effectiveSpaceId, factIds); const topicsByFactId = new Map(); - for (const topicLink of topicLinks) { const topics = topicsByFactId.get(topicLink.fact_id) ?? []; topics.push({ @@ -733,4 +869,57 @@ export class IdentityDB { return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? [])); } + + private async getOrCreateSpaceInExecutor(executor: DatabaseExecutor, requestedSpaceName?: string): Promise { + const normalizedName = normalizeSpaceName(requestedSpaceName ?? DEFAULT_SPACE_NAME); + const canonicalName = canonicalizeSpaceName(requestedSpaceName ?? DEFAULT_SPACE_NAME); + const existing = await findSpaceRowByNormalizedName(executor, normalizedName); + if (existing) { + return existing; + } + + const now = nowIsoString(); + const createdRow: SpaceRecord = { + id: createId(), + name: canonicalName, + normalized_name: normalizedName, + description: null, + metadata: null, + created_at: now, + updated_at: now, + }; + + await executor.insertInto('spaces').values(createdRow).execute(); + return createdRow; + } + + private async getSpaceForRead(spaceName?: string): Promise { + if (!spaceName) { + return undefined; + } + + const normalizedName = normalizeSpaceName(spaceName); + if (normalizedName.length === 0) { + return undefined; + } + + return findSpaceRowByNormalizedName(this.connection.db, normalizedName); + } + + private async getDefaultSpaceIdForRead(): Promise { + const defaultSpace = await findSpaceRowByNormalizedName(this.connection.db, normalizeSpaceName(DEFAULT_SPACE_NAME)); + return defaultSpace?.id; + } + + private assertScopedTopicInput(space: SpaceRecord, topicSpaceName?: string): void { + if (!topicSpaceName) { + return; + } + + if (normalizeSpaceName(topicSpaceName) !== space.normalized_name) { + throw new IdentityDBError( + `Fact topics cannot point to a different space than the fact itself (${space.name}).`, + ); + } + } } diff --git a/src/core/migrations.ts b/src/core/migrations.ts index 2bc2398..d95629e 100644 --- a/src/core/migrations.ts +++ b/src/core/migrations.ts @@ -4,6 +4,7 @@ import { FACTS_TABLE, FACT_EMBEDDINGS_TABLE, FACT_TOPICS_TABLE, + SPACES_TABLE, TOPIC_ALIASES_TABLE, TOPIC_RELATIONS_TABLE, TOPICS_TABLE, @@ -14,23 +15,42 @@ export async function initializeSchema( db: Kysely, ): Promise { await db.schema - .createTable(TOPICS_TABLE) + .createTable(SPACES_TABLE) .ifNotExists() .addColumn('id', 'text', (column) => column.primaryKey()) .addColumn('name', 'text', (column) => column.notNull()) .addColumn('normalized_name', 'text', (column) => column.notNull().unique()) - .addColumn('category', 'text', (column) => column.notNull()) - .addColumn('granularity', 'text', (column) => column.notNull()) .addColumn('description', 'text') .addColumn('metadata', 'text') .addColumn('created_at', 'text', (column) => column.notNull()) .addColumn('updated_at', 'text', (column) => column.notNull()) .execute(); + await db.schema + .createTable(TOPICS_TABLE) + .ifNotExists() + .addColumn('id', 'text', (column) => column.primaryKey()) + .addColumn('space_id', 'text', (column) => + column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'), + ) + .addColumn('name', 'text', (column) => column.notNull()) + .addColumn('normalized_name', 'text', (column) => column.notNull()) + .addColumn('category', 'text', (column) => column.notNull()) + .addColumn('granularity', 'text', (column) => column.notNull()) + .addColumn('description', 'text') + .addColumn('metadata', 'text') + .addColumn('created_at', 'text', (column) => column.notNull()) + .addColumn('updated_at', 'text', (column) => column.notNull()) + .addUniqueConstraint('topics_space_normalized_name_key', ['space_id', 'normalized_name']) + .execute(); + await db.schema .createTable(FACTS_TABLE) .ifNotExists() .addColumn('id', 'text', (column) => column.primaryKey()) + .addColumn('space_id', 'text', (column) => + column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'), + ) .addColumn('statement', 'text', (column) => column.notNull()) .addColumn('summary', 'text') .addColumn('source', 'text') @@ -88,14 +108,32 @@ export async function initializeSchema( .createTable(TOPIC_ALIASES_TABLE) .ifNotExists() .addColumn('id', 'text', (column) => column.primaryKey()) + .addColumn('space_id', 'text', (column) => + column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'), + ) .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('normalized_alias', 'text', (column) => column.notNull()) .addColumn('is_primary', 'integer', (column) => column.notNull()) .addColumn('created_at', 'text', (column) => column.notNull()) .addColumn('updated_at', 'text', (column) => column.notNull()) + .addUniqueConstraint('topic_aliases_space_normalized_alias_key', ['space_id', 'normalized_alias']) + .execute(); + + await db.schema + .createIndex('topics_space_id_idx') + .ifNotExists() + .on(TOPICS_TABLE) + .column('space_id') + .execute(); + + await db.schema + .createIndex('facts_space_id_idx') + .ifNotExists() + .on(FACTS_TABLE) + .column('space_id') .execute(); await db.schema @@ -133,6 +171,13 @@ export async function initializeSchema( .column('child_topic_id') .execute(); + await db.schema + .createIndex('topic_aliases_space_id_idx') + .ifNotExists() + .on(TOPIC_ALIASES_TABLE) + .column('space_id') + .execute(); + await db.schema .createIndex('topic_aliases_topic_id_idx') .ifNotExists() diff --git a/src/core/schema.ts b/src/core/schema.ts index 5bbd72d..1dfa8f7 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -1,3 +1,4 @@ +export const SPACES_TABLE = 'spaces'; export const TOPICS_TABLE = 'topics'; export const FACTS_TABLE = 'facts'; export const FACT_TOPICS_TABLE = 'fact_topics'; @@ -5,8 +6,19 @@ export const TOPIC_RELATIONS_TABLE = 'topic_relations'; export const TOPIC_ALIASES_TABLE = 'topic_aliases'; export const FACT_EMBEDDINGS_TABLE = 'fact_embeddings'; +export const SPACE_COLUMNS = [ + 'id', + 'name', + 'normalized_name', + 'description', + 'metadata', + 'created_at', + 'updated_at', +] as const; + export const TOPIC_COLUMNS = [ 'id', + 'space_id', 'name', 'normalized_name', 'category', @@ -19,6 +31,7 @@ export const TOPIC_COLUMNS = [ export const FACT_COLUMNS = [ 'id', + 'space_id', 'statement', 'summary', 'source', @@ -45,6 +58,7 @@ export const TOPIC_RELATION_COLUMNS = [ export const TOPIC_ALIAS_COLUMNS = [ 'id', + 'space_id', 'topic_id', 'alias', 'normalized_alias', diff --git a/src/core/utils.ts b/src/core/utils.ts index 5cbc795..d33cd72 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; -import type { Fact, FactTopic, Topic } from '../types/api'; -import type { FactRecord, TopicRecord } from '../types/domain'; +import type { Fact, FactTopic, Space, Topic } from '../types/api'; +import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain'; export function normalizeTopicName(name: string): string { return name.trim().replace(/\s+/g, ' ').toLowerCase(); @@ -11,6 +11,14 @@ export function canonicalizeTopicName(name: string): string { return name.trim().replace(/\s+/g, ' '); } +export function normalizeSpaceName(name: string): string { + return normalizeTopicName(name); +} + +export function canonicalizeSpaceName(name: string): string { + return canonicalizeTopicName(name); +} + export function nowIsoString(): string { return new Date().toISOString(); } @@ -71,9 +79,22 @@ export function cosineSimilarity(left: number[], right: number[]): number { return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude)); } +export function mapSpaceRow(record: SpaceRecord): Space { + return { + id: record.id, + name: record.name, + normalizedName: record.normalized_name, + description: record.description, + metadata: deserializeMetadata(record.metadata) as Space['metadata'], + createdAt: record.created_at, + updatedAt: record.updated_at, + }; +} + export function mapTopicRow(record: TopicRecord): Topic { return { id: record.id, + spaceId: record.space_id, name: record.name, normalizedName: record.normalized_name, category: record.category, @@ -88,6 +109,7 @@ export function mapTopicRow(record: TopicRecord): Topic { export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact { return { id: record.id, + spaceId: record.space_id, statement: record.statement, summary: record.summary, source: record.source, diff --git a/src/ingestion/types.ts b/src/ingestion/types.ts index 78327fc..50d82a4 100644 --- a/src/ingestion/types.ts +++ b/src/ingestion/types.ts @@ -31,4 +31,5 @@ export interface IngestStatementOptions { extractor: FactExtractor; embeddingProvider?: EmbeddingProvider; duplicateThreshold?: number; + spaceName?: string; } diff --git a/src/queries/facts.ts b/src/queries/facts.ts index d516b27..8e9ee33 100644 --- a/src/queries/facts.ts +++ b/src/queries/facts.ts @@ -13,12 +13,14 @@ export interface FactTopicJoinRow extends TopicRecord { export async function findFactRowsForTopicId( executor: DatabaseExecutor, + spaceId: string, topicId: string, ): Promise { return executor .selectFrom('facts') .innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id') .selectAll('facts') + .where('facts.space_id', '=', spaceId) .where('fact_topics.topic_id', '=', topicId) .orderBy('facts.created_at', 'asc') .execute(); @@ -26,6 +28,7 @@ export async function findFactRowsForTopicId( export async function findFactRowsConnectingTopicIds( executor: DatabaseExecutor, + spaceId: string, topicIds: string[], ): Promise { if (topicIds.length === 0) { @@ -36,6 +39,7 @@ export async function findFactRowsConnectingTopicIds( .selectFrom('facts') .innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id') .selectAll('facts') + .where('facts.space_id', '=', spaceId) .where('fact_topics.topic_id', 'in', topicIds) .groupBy('facts.id') .having((eb) => eb.fn.count('fact_topics.topic_id'), '=', topicIds.length) @@ -45,6 +49,7 @@ export async function findFactRowsConnectingTopicIds( export async function findTopicLinksForFactIds( executor: DatabaseExecutor, + spaceId: string, factIds: string[], ): Promise { if (factIds.length === 0) { @@ -60,6 +65,7 @@ export async function findTopicLinksForFactIds( 'fact_topics.role as role', 'fact_topics.position as position', ]) + .where('topics.space_id', '=', spaceId) .where('fact_topics.fact_id', 'in', factIds) .orderBy('fact_topics.position', 'asc') .execute() as Promise; diff --git a/src/queries/topics.ts b/src/queries/topics.ts index d3c29de..2ade704 100644 --- a/src/queries/topics.ts +++ b/src/queries/topics.ts @@ -1,7 +1,7 @@ import type { Kysely, Transaction } from 'kysely'; import type { IdentityDatabaseSchema } from '../types/database'; -import type { TopicAliasRecord, TopicRecord } from '../types/domain'; +import type { SpaceRecord, TopicAliasRecord, TopicRecord } from '../types/domain'; export type DatabaseExecutor = Kysely | Transaction; @@ -9,48 +9,66 @@ export interface ConnectedTopicRow extends TopicRecord { shared_fact_count: number; } +export async function findSpaceRowByNormalizedName( + executor: DatabaseExecutor, + normalizedName: string, +): Promise { + return executor + .selectFrom('spaces') + .selectAll() + .where('normalized_name', '=', normalizedName) + .executeTakeFirst(); +} + export async function findTopicRowByNormalizedName( executor: DatabaseExecutor, + spaceId: string, normalizedName: string, ): Promise { return executor .selectFrom('topics') .selectAll() + .where('space_id', '=', spaceId) .where('normalized_name', '=', normalizedName) .executeTakeFirst(); } export async function findTopicRowByNormalizedAlias( executor: DatabaseExecutor, + spaceId: string, normalizedAlias: string, ): Promise { return executor .selectFrom('topic_aliases') .innerJoin('topics', 'topics.id', 'topic_aliases.topic_id') .selectAll('topics') + .where('topic_aliases.space_id', '=', spaceId) .where('topic_aliases.normalized_alias', '=', normalizedAlias) .executeTakeFirst(); } export async function findTopicRowByNameOrAlias( executor: DatabaseExecutor, + spaceId: string, normalizedName: string, ): Promise { - const directMatch = await findTopicRowByNormalizedName(executor, normalizedName); + const directMatch = await findTopicRowByNormalizedName(executor, spaceId, normalizedName); if (directMatch) { return directMatch; } - return findTopicRowByNormalizedAlias(executor, normalizedName); + return findTopicRowByNormalizedAlias(executor, spaceId, normalizedName); } export async function listTopicAliasRowsForTopicId( executor: DatabaseExecutor, + spaceId: string, topicId: string, ): Promise { return executor .selectFrom('topic_aliases') .selectAll() + .where('space_id', '=', spaceId) .where('topic_id', '=', topicId) .orderBy('is_primary', 'desc') .orderBy('normalized_alias', 'asc') @@ -59,9 +77,14 @@ export async function listTopicAliasRowsForTopicId( export async function listTopicRows( executor: DatabaseExecutor, + spaceId: string, limit?: number, ): Promise { - let query = executor.selectFrom('topics').selectAll().orderBy('normalized_name', 'asc'); + let query = executor + .selectFrom('topics') + .selectAll() + .where('space_id', '=', spaceId) + .orderBy('normalized_name', 'asc'); if (limit !== undefined) { query = query.limit(limit); @@ -72,14 +95,18 @@ export async function listTopicRows( export async function findConnectedTopicRows( executor: DatabaseExecutor, + spaceId: string, topicId: string, ): Promise { return executor .selectFrom('fact_topics as source_link') + .innerJoin('facts', 'facts.id', 'source_link.fact_id') .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('related_link.fact_id').as('shared_fact_count')) + .where('facts.space_id', '=', spaceId) + .where('topics.space_id', '=', spaceId) .where('source_link.topic_id', '=', topicId) .whereRef('related_link.topic_id', '!=', 'source_link.topic_id') .groupBy('topics.id') @@ -90,12 +117,14 @@ export async function findConnectedTopicRows( export async function findChildTopicRows( executor: DatabaseExecutor, + spaceId: string, parentTopicId: string, ): Promise { return executor .selectFrom('topic_relations') .innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id') .selectAll('topics') + .where('topics.space_id', '=', spaceId) .where('topic_relations.parent_topic_id', '=', parentTopicId) .where('topic_relations.relation', '=', 'parent_of') .orderBy('topics.normalized_name', 'asc') @@ -104,12 +133,14 @@ export async function findChildTopicRows( export async function findParentTopicRows( executor: DatabaseExecutor, + spaceId: string, childTopicId: string, ): Promise { return executor .selectFrom('topic_relations') .innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id') .selectAll('topics') + .where('topics.space_id', '=', spaceId) .where('topic_relations.child_topic_id', '=', childTopicId) .where('topic_relations.relation', '=', 'parent_of') .orderBy('topics.normalized_name', 'asc') diff --git a/src/types/api.ts b/src/types/api.ts index 20cee77..5257f65 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,6 +1,26 @@ import type { JsonValue, TopicCategory, TopicGranularity } from './domain'; -export interface UpsertTopicInput { +export interface SpaceScopedInput { + spaceName?: string | undefined; +} + +export interface UpsertSpaceInput { + name: string; + description?: string | null; + metadata?: JsonValue | null; +} + +export interface Space { + id: string; + name: string; + normalizedName: string; + description: string | null; + metadata: JsonValue | null; + createdAt: string; + updatedAt: string; +} + +export interface UpsertTopicInput extends SpaceScopedInput { name: string; category?: TopicCategory; granularity?: TopicGranularity; @@ -12,7 +32,7 @@ export interface TopicLinkInput extends UpsertTopicInput { role?: string | null; } -export interface AddFactInput { +export interface AddFactInput extends SpaceScopedInput { statement: string; summary?: string | null; source?: string | null; @@ -21,13 +41,14 @@ export interface AddFactInput { topics: TopicLinkInput[]; } -export interface LinkTopicsInput { +export interface LinkTopicsInput extends SpaceScopedInput { parentName: string; childName: string; } export interface Topic { id: string; + spaceId: string; name: string; normalizedName: string; category: TopicCategory; @@ -45,6 +66,7 @@ export interface FactTopic extends Topic { export interface Fact { id: string; + spaceId: string; statement: string; summary: string | null; source: string | null; @@ -63,11 +85,11 @@ export interface ConnectedTopic extends Topic { sharedFactCount: number; } -export interface TopicLookupOptions { +export interface TopicLookupOptions extends SpaceScopedInput { includeFacts?: boolean; } -export interface ListTopicsOptions { +export interface ListTopicsOptions extends SpaceScopedInput { includeFacts?: boolean; limit?: number; } @@ -79,11 +101,11 @@ export interface EmbeddingProvider { embedMany?(inputs: string[]): Promise; } -export interface IndexFactEmbeddingsInput { +export interface IndexFactEmbeddingsInput extends SpaceScopedInput { provider: EmbeddingProvider; } -export interface SearchFactsInput { +export interface SearchFactsInput extends SpaceScopedInput { query: string; provider: EmbeddingProvider; topicNames?: string[]; @@ -91,7 +113,7 @@ export interface SearchFactsInput { minimumScore?: number; } -export interface FindSimilarFactsInput { +export interface FindSimilarFactsInput extends SpaceScopedInput { statement: string; provider: EmbeddingProvider; topicNames?: string[]; diff --git a/src/types/database.ts b/src/types/database.ts index 8c9e8ae..f845484 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -2,12 +2,14 @@ import type { FactEmbeddingRecord, FactRecord, FactTopicRecord, + SpaceRecord, TopicAliasRecord, TopicRecord, TopicRelationRecord, } from './domain'; export interface IdentityDatabaseSchema { + spaces: SpaceRecord; topics: TopicRecord; facts: FactRecord; fact_topics: FactTopicRecord; diff --git a/src/types/domain.ts b/src/types/domain.ts index ad94f4b..3d90d40 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -5,8 +5,19 @@ export type TopicGranularity = 'abstract' | 'concrete' | 'mixed'; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export interface SpaceRecord { + id: string; + name: string; + normalized_name: string; + description: string | null; + metadata: string | null; + created_at: string; + updated_at: string; +} + export interface TopicRecord { id: string; + space_id: string; name: string; normalized_name: string; category: TopicCategory; @@ -19,6 +30,7 @@ export interface TopicRecord { export interface FactRecord { id: string; + space_id: string; statement: string; summary: string | null; source: string | null; @@ -45,6 +57,7 @@ export interface TopicRelationRecord { export interface TopicAliasRecord { id: string; + space_id: string; topic_id: string; alias: string; normalized_alias: string; diff --git a/tests/identity-db.test.ts b/tests/identity-db.test.ts index 8f37872..302b6c4 100644 --- a/tests/identity-db.test.ts +++ b/tests/identity-db.test.ts @@ -34,6 +34,58 @@ describe('IdentityDB topic and fact writes', () => { expect(topics).toHaveLength(1); }); + it('keeps same normalized topic names isolated across spaces', async () => { + const alpha = await db.upsertTopic({ + name: 'TypeScript', + category: 'entity', + granularity: 'concrete', + spaceName: 'A', + }); + + const beta = await db.upsertTopic({ + name: 'TypeScript', + category: 'entity', + granularity: 'concrete', + spaceName: 'B', + }); + + expect(beta.id).not.toBe(alpha.id); + + const alphaTopics = await db.listTopics({ includeFacts: false, spaceName: 'A' }); + const betaTopics = await db.listTopics({ includeFacts: false, spaceName: 'B' }); + const defaultTopics = await db.listTopics({ includeFacts: false }); + + expect(alphaTopics.map((topic) => topic.name)).toEqual(['TypeScript']); + expect(betaTopics.map((topic) => topic.name)).toEqual(['TypeScript']); + expect(defaultTopics).toHaveLength(0); + }); + + it('keeps alias resolution scoped to the requested space', async () => { + await db.upsertTopic({ + name: 'TypeScript', + category: 'entity', + granularity: 'concrete', + spaceName: 'A', + }); + await db.upsertTopic({ + name: 'TeamSpeak', + category: 'entity', + granularity: 'concrete', + spaceName: 'B', + }); + + await db.addTopicAlias('TypeScript', 'TS', { spaceName: 'A' }); + await db.addTopicAlias('TeamSpeak', 'TS', { spaceName: 'B' }); + + const alphaResolved = await db.resolveTopic('ts', { spaceName: 'A' }); + const betaResolved = await db.resolveTopic('ts', { spaceName: 'B' }); + const defaultResolved = await db.resolveTopic('ts'); + + expect(alphaResolved?.name).toBe('TypeScript'); + expect(betaResolved?.name).toBe('TeamSpeak'); + expect(defaultResolved).toBeNull(); + }); + it('adds one fact that links multiple topics', async () => { const fact = await db.addFact({ statement: 'I have worked with TypeScript since 2025.', diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 20b072e..d12042e 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { }); describe('initializeSchema', () => { - it('creates the topics, facts, fact_embeddings, fact_topics, topic_relations, and topic_aliases tables', async () => { + it('creates the spaces, topics, facts, fact_embeddings, fact_topics, topic_relations, and topic_aliases tables', async () => { const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' }); openConnections.push(connection.destroy); @@ -31,6 +31,7 @@ describe('initializeSchema', () => { const tableNames = tables.rows.map((row) => row.name); + expect(tableNames).toContain('spaces'); expect(tableNames).toContain('topics'); expect(tableNames).toContain('facts'); expect(tableNames).toContain('fact_embeddings'); @@ -45,6 +46,7 @@ describe('initializeSchema', () => { await initializeSchema(connection.db); + const spaceColumns = await sql<{ name: string }>`PRAGMA table_info(spaces)`.execute(connection.db); const topicsColumns = await sql<{ name: string }>`PRAGMA table_info(topics)`.execute(connection.db); const factsColumns = await sql<{ name: string }>`PRAGMA table_info(facts)`.execute(connection.db); const factEmbeddingsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_embeddings)`.execute(connection.db); @@ -52,8 +54,19 @@ describe('initializeSchema', () => { 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(spaceColumns.rows.map((row) => row.name)).toEqual([ + 'id', + 'name', + 'normalized_name', + 'description', + 'metadata', + 'created_at', + 'updated_at', + ]); + expect(topicsColumns.rows.map((row) => row.name)).toEqual([ 'id', + 'space_id', 'name', 'normalized_name', 'category', @@ -66,6 +79,7 @@ describe('initializeSchema', () => { expect(factsColumns.rows.map((row) => row.name)).toEqual([ 'id', + 'space_id', 'statement', 'summary', 'source', @@ -102,6 +116,7 @@ describe('initializeSchema', () => { expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([ 'id', + 'space_id', 'topic_id', 'alias', 'normalized_alias', diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 25dbcbd..f32cd4b 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IdentityDB } from '../src/core/identity-db'; -async function seedMemoryGraph(db: IdentityDB): Promise { +async function seedMemoryGraph(db: IdentityDB, spaceName?: string): Promise { await db.addFact({ statement: 'I have worked with TypeScript since 2025.', + spaceName, topics: [ { name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' }, { name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' }, @@ -14,6 +15,7 @@ async function seedMemoryGraph(db: IdentityDB): Promise { await db.addFact({ statement: 'TypeScript is a programming language.', + spaceName, topics: [ { name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' }, { name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' }, @@ -23,11 +25,13 @@ async function seedMemoryGraph(db: IdentityDB): Promise { await db.linkTopics({ parentName: 'software technology', childName: 'programming language', + spaceName, }); await db.linkTopics({ parentName: 'programming language', childName: 'TypeScript', + spaceName, }); } @@ -114,6 +118,56 @@ describe('IdentityDB queries', () => { ]); }); + it('keeps hierarchy and fact queries isolated per space', async () => { + const isolatedDb = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' }); + try { + await isolatedDb.initialize(); + await seedMemoryGraph(isolatedDb, 'A'); + + await isolatedDb.addFact({ + statement: 'TypeScript is a typed superset.', + spaceName: 'B', + topics: [ + { name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' }, + { name: 'superset', category: 'concept', granularity: 'abstract', role: 'classification' }, + ], + }); + + await isolatedDb.linkTopics({ + parentName: 'language family', + childName: 'TypeScript', + spaceName: 'B', + }); + + const alphaTopic = await isolatedDb.getTopicByName('TypeScript', { + includeFacts: true, + spaceName: 'A', + }); + const betaTopic = await isolatedDb.getTopicByName('TypeScript', { + includeFacts: true, + spaceName: 'B', + }); + const alphaParents = await isolatedDb.getTopicParents('TypeScript', { spaceName: 'A' }); + const betaParents = await isolatedDb.getTopicParents('TypeScript', { spaceName: 'B' }); + const alphaConnected = await isolatedDb.findConnectedTopics('TypeScript', { spaceName: 'A' }); + const betaConnected = await isolatedDb.findConnectedTopics('TypeScript', { spaceName: 'B' }); + + expect(alphaTopic?.facts.map((fact) => fact.statement)).toEqual([ + 'I have worked with TypeScript since 2025.', + 'TypeScript is a programming language.', + ]); + expect(betaTopic?.facts.map((fact) => fact.statement)).toEqual([ + 'TypeScript is a typed superset.', + ]); + expect(alphaParents.map((topic) => topic.name)).toEqual(['programming language']); + expect(betaParents.map((topic) => topic.name)).toEqual(['language family']); + expect(alphaConnected.map((topic) => topic.name)).toEqual(['2025', 'I', 'programming language']); + expect(betaConnected.map((topic) => topic.name)).toEqual(['superset']); + } finally { + await isolatedDb.close(); + } + }); + it('resolves alias names in topic lookups', async () => { await db.addTopicAlias('TypeScript', 'TS'); diff --git a/tests/semantic-search.test.ts b/tests/semantic-search.test.ts index 131324d..a51b8e6 100644 --- a/tests/semantic-search.test.ts +++ b/tests/semantic-search.test.ts @@ -120,6 +120,53 @@ describe('IdentityDB semantic search', () => { expect(matches[0]?.statement).toBe('Bun runs TypeScript tooling quickly.'); expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score); }); + + it('keeps semantic search isolated per space', async () => { + const isolatedDb = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' }); + try { + await isolatedDb.initialize(); + + await isolatedDb.addFact({ + statement: 'Bun runs TypeScript tooling quickly.', + spaceName: 'A', + topics: [ + { name: 'Bun', category: 'entity', granularity: 'concrete' }, + { name: 'TypeScript', category: 'entity', granularity: 'concrete' }, + ], + }); + + await isolatedDb.addFact({ + statement: 'TypeScript runtime tooling belongs to another tenant.', + spaceName: 'B', + topics: [ + { name: 'TypeScript', category: 'entity', granularity: 'concrete' }, + ], + }); + + await isolatedDb.indexFactEmbeddings({ provider, spaceName: 'A' }); + await isolatedDb.indexFactEmbeddings({ provider, spaceName: 'B' }); + + const alphaMatches = await isolatedDb.searchFacts({ + query: 'TypeScript runtime tooling', + provider, + spaceName: 'A', + }); + const betaMatches = await isolatedDb.searchFacts({ + query: 'TypeScript runtime tooling', + provider, + spaceName: 'B', + }); + + expect(alphaMatches.map((match) => match.statement)).toEqual([ + 'Bun runs TypeScript tooling quickly.', + ]); + expect(betaMatches.map((match) => match.statement)).toEqual([ + 'TypeScript runtime tooling belongs to another tenant.', + ]); + } finally { + await isolatedDb.close(); + } + }); }); describe('IdentityDB dedup-aware ingestion', () => { @@ -167,4 +214,26 @@ describe('IdentityDB dedup-aware ingestion', () => { expect(facts).toHaveLength(1); expect(facts[0]?.statement).toBe('Bun runs TypeScript tooling quickly.'); }); + + it('does not reuse a semantic duplicate from another space', async () => { + const first = await db.ingestStatement('Bun runs TypeScript tooling quickly.', { + extractor, + embeddingProvider: provider, + spaceName: 'A', + }); + + const second = await db.ingestStatement('Bun makes TypeScript tooling fast.', { + extractor, + embeddingProvider: provider, + duplicateThreshold: 0.95, + spaceName: 'B', + }); + + const alphaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'A' }); + const betaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'B' }); + + expect(second.id).not.toBe(first.id); + expect(alphaFacts).toHaveLength(1); + expect(betaFacts).toHaveLength(1); + }); });