feat: add isolated memory spaces

This commit is contained in:
2026-05-11 14:45:28 +09:00
parent b908bc0bd9
commit d83fc31c59
14 changed files with 667 additions and 132 deletions

View File

@@ -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.',

View File

@@ -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',

View File

@@ -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<void> {
async function seedMemoryGraph(db: IdentityDB, spaceName?: string): Promise<void> {
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<void> {
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<void> {
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');

View File

@@ -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);
});
});