Compare commits

...

8 Commits

Author SHA1 Message Date
b77e8eea40 chore: release 0.2.0
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 11s
2026-05-11 19:13:52 +09:00
cea45a552a docs: document space isolation usage 2026-05-11 14:45:35 +09:00
d83fc31c59 feat: add isolated memory spaces 2026-05-11 14:45:28 +09:00
b908bc0bd9 docs: add IdentityDB space isolation plan 2026-05-11 14:28:08 +09:00
283f91ed91 ci: configure npm auth for release publish
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 12s
2026-05-11 13:59:29 +09:00
5991e4f1f0 ci: run Gitea release steps with bash
Some checks failed
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Failing after 11s
2026-05-11 13:57:45 +09:00
0dc657c97b ci: make Gitea release workflow self-contained
Some checks failed
npm release / verify (push) Failing after 10s
npm release / publish to npm (push) Has been skipped
2026-05-11 13:56:39 +09:00
96d0568197 ci: set writable HOME for Gitea release workflow
Some checks failed
npm release / verify (push) Failing after 2s
npm release / publish to npm (push) Has been skipped
2026-05-11 13:53:04 +09:00
18 changed files with 918 additions and 168 deletions

View File

@@ -6,37 +6,47 @@ on:
- 'v*' - 'v*'
- '[0-9]*' - '[0-9]*'
permissions:
contents: read
defaults:
run:
shell: bash
jobs: jobs:
verify: verify:
name: verify name: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: node:20-bookworm
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: Check out repository - name: Install release tools
uses: actions/checkout@v4 run: |
with: set -euo pipefail
fetch-depth: 0 apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Set up Node.js - name: Clone tagged source
uses: actions/setup-node@v4 run: |
with: set -euo pipefail
node-version: '20' REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
registry-url: 'https://registry.npmjs.org' AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
- name: Set up Bun git -C repo rev-parse HEAD
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.13'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Verify release tag matches package version - name: Verify release tag matches package version
working-directory: repo
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG_NAME="${GITHUB_REF##refs/tags/}" TAG_NAME="${{ gitea.ref_name }}"
PACKAGE_VERSION="$(node -p "require('./package.json').version")" PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_NAME" = "v$PACKAGE_VERSION" ] || [ "$TAG_NAME" = "$PACKAGE_VERSION" ]; then if [ "$TAG_NAME" = "v$PACKAGE_VERSION" ] || [ "$TAG_NAME" = "$PACKAGE_VERSION" ]; then
@@ -48,7 +58,10 @@ jobs:
exit 1 exit 1
- name: Run verify pipeline - name: Run verify pipeline
working-directory: repo
run: | run: |
set -euo pipefail
bun install --frozen-lockfile
bun run test bun run test
bun run check bun run check
bun run build bun run build
@@ -56,32 +69,49 @@ jobs:
release: release:
name: publish to npm name: publish to npm
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: node:20-bookworm
timeout-minutes: 20 timeout-minutes: 20
needs: needs:
- verify - verify
steps: steps:
- name: Check out repository - name: Install release tools
uses: actions/checkout@v4 run: |
set -euo pipefail
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Set up Node.js - name: Clone tagged source
uses: actions/setup-node@v4 run: |
with: set -euo pipefail
node-version: '20' REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
registry-url: 'https://registry.npmjs.org' AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
- name: Set up Bun git -C repo rev-parse HEAD
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.13'
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile working-directory: repo
run: |
set -euo pipefail
bun install --frozen-lockfile
- name: Build package - name: Build package
run: bun run build working-directory: repo
run: |
set -euo pipefail
bun run build
- name: Publish package to npm - name: Publish package to npm
working-directory: repo
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish run: |
set -euo pipefail
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > ~/.npmrc
npm publish

View File

@@ -15,9 +15,9 @@ A single fact like `I have worked with TypeScript since 2025.` can connect the t
## Current capabilities ## Current capabilities
- SQLite, PostgreSQL, MySQL, and MariaDB connection adapters - SQLite, PostgreSQL, MySQL, and MariaDB connection adapters
- Automatic schema initialization for `topics`, `facts`, `fact_topics`, `topic_relations`, `topic_aliases`, and `fact_embeddings` - Automatic schema initialization for `spaces`, `topics`, `facts`, `fact_topics`, `topic_relations`, `topic_aliases`, and `fact_embeddings`
- High-level APIs for adding topics and facts - High-level APIs for adding topics and facts
- Topic hierarchy APIs for parent/child traversal and lineage lookup - Hard space isolation so independent memory graphs can coexist without cross-linking
- Topic alias and canonical resolution APIs so facts and queries can resolve alternate names - Topic alias and canonical resolution APIs so facts and queries can resolve alternate names
- Semantic fact indexing and search APIs built around provider-agnostic embeddings - Semantic fact indexing and search APIs built around provider-agnostic embeddings
- Dedup-aware ingestion hooks that can reuse an existing fact when a semantic near-duplicate is detected - Dedup-aware ingestion hooks that can reuse an existing fact when a semantic near-duplicate is detected
@@ -101,6 +101,34 @@ console.log(matches.map((entry) => [entry.statement, entry.score]));
await db.close(); await db.close();
``` ```
## Memory spaces
IdentityDB now supports hard isolation via spaces. If you write facts into `spaceName: 'A'` and `spaceName: 'B'`, they behave like separate dimensions:
- the same topic name can exist in both spaces
- aliases resolve only inside the requested space
- hierarchy, connected-topic traversal, semantic search, and duplicate detection stay inside the same space
```ts
await db.upsertSpace({ name: 'A' });
await db.upsertSpace({ name: 'B' });
await db.addFact({
spaceName: 'A',
statement: 'TypeScript belongs to A.',
topics: [{ name: 'TypeScript', category: 'entity', granularity: 'concrete' }],
});
await db.addFact({
spaceName: 'B',
statement: 'TypeScript belongs to B.',
topics: [{ name: 'TypeScript', category: 'entity', granularity: 'concrete' }],
});
const alphaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'A' });
const betaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'B' });
```
## Semantic ingestion and duplicate detection ## Semantic ingestion and duplicate detection
If you provide an embedding provider during ingestion, IdentityDB can index the new fact automatically and reuse an existing fact when a semantic near-duplicate is already present. If you provide an embedding provider during ingestion, IdentityDB can index the new fact automatically and reuse an existing fact when a semantic near-duplicate is already present.

View File

@@ -0,0 +1,157 @@
# IdentityDB Space Isolation Implementation Plan
> **For Hermes:** Use the `writing-plans` and `test-driven-development` skills. Implement this feature in small TDD steps with meaningful commits.
**Goal:** Add first-class memory spaces so callers can keep unrelated topic/fact graphs isolated from each other while still using one physical database.
**Architecture:** Introduce a `spaces` table plus `space_id` scoping on the canonical topic/fact records that define graph ownership. Treat the unnamed/default behavior as a built-in `default` space so existing API usage keeps working, while allowing any write/query path to target a named space explicitly.
**Tech Stack:** TypeScript, Bun, Vitest, Kysely, SQLite/PostgreSQL/MySQL/MariaDB-compatible schema primitives.
---
## Requirements
1. A caller must be able to write memory into independent spaces such as `A` and `B`.
2. Topic lookups, alias resolution, hierarchy traversal, connected-topic queries, fact queries, ingestion, and semantic search must only see data from the requested space.
3. Existing callers that do not specify a space must continue to work inside a built-in `default` space.
4. Space names must be normalized/canonicalized similarly to topics.
5. Documentation must explain both the isolation model and the default-space compatibility behavior.
---
## Task 1: Add failing tests for schema-level space support
**Objective:** Define the storage contract for spaces and per-space uniqueness before implementation.
**Files:**
- Modify: `tests/migrations.test.ts`
**Step 1: Write failing tests**
- Assert that initialization creates a `spaces` table.
- Assert that `topics`, `facts`, and `topic_aliases` now include `space_id`.
- Assert that `spaces` includes `id`, `name`, `normalized_name`, `description`, `metadata`, `created_at`, `updated_at`.
**Step 2: Run red test**
- Run: `bun run test tests/migrations.test.ts`
- Expect: failure because the schema does not yet contain space support.
**Step 3: Commit later with implementation**
- Do not commit yet; continue only after implementation turns the test green.
---
## Task 2: Add failing behavioral tests for isolated spaces
**Objective:** Lock in the public API behavior for separate memory spaces.
**Files:**
- Modify: `tests/identity-db.test.ts`
- Modify: `tests/queries.test.ts`
- Modify: `tests/semantic-search.test.ts`
- Optionally create: `tests/spaces.test.ts` if separation makes the scenarios clearer.
**Step 1: Write failing tests**
- Verify the same topic name can exist in two spaces without deduplicating together.
- Verify facts added in `spaceName: 'A'` are invisible from `spaceName: 'B'`.
- Verify alias resolution only resolves inside the same space.
- Verify hierarchy parent/child traversal only stays within the same space.
- Verify semantic search and duplicate-aware ingestion only search within the same space.
- Verify callers that omit `spaceName` still operate in the `default` space.
**Step 2: Run red tests**
- Run the most targeted files first, then the whole suite slice.
- Example: `bun run test tests/identity-db.test.ts tests/queries.test.ts tests/semantic-search.test.ts`
- Expect: failure due to missing API fields and missing isolation logic.
---
## Task 3: Implement schema and type support
**Objective:** Add the underlying data model required for spaces.
**Files:**
- Modify: `src/core/schema.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
- Modify: `src/core/migrations.ts`
**Implementation notes:**
- Add `SPACES_TABLE` and `SPACE_COLUMNS` constants.
- Add `SpaceRecord` domain type.
- Add `spaces` to `IdentityDatabaseSchema`.
- Create the `spaces` table.
- Add `space_id` columns to `topics`, `facts`, and `topic_aliases`.
- Make topic uniqueness per-space, not global.
- Make alias uniqueness per-space, not global.
- Seed or upsert a built-in `default` space during initialization via normal application flow, not hard-coded SQL assumptions.
**Verification:**
- Re-run: `bun run test tests/migrations.test.ts`
- Expect: green.
---
## Task 4: Implement space-aware API contracts and query helpers
**Objective:** Thread `spaceName` through the high-level API and low-level query layer.
**Files:**
- Modify: `src/types/api.ts`
- Modify: `src/ingestion/types.ts`
- Modify: `src/queries/topics.ts`
- Modify: `src/queries/facts.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/index.ts` if new exported types are added
**Implementation notes:**
- Add public `Space` and `UpsertSpaceInput` types if needed.
- Add optional `spaceName` on write/query inputs where the caller targets a graph.
- Add helpers to resolve or create the requested space inside transactions.
- Ensure all existing topic lookup helpers filter by `space_id`.
- Ensure semantic search candidates are restricted to facts in the requested space.
- Preserve existing no-space API calls by mapping them to `default`.
**Verification:**
- Re-run the failing behavior tests.
- Expect: green for the new targeted tests.
---
## Task 5: Refine ergonomics and update documentation
**Objective:** Make the feature understandable and safe to use.
**Files:**
- Modify: `README.md`
- Optionally modify: wiki docs later if requested
**Implementation notes:**
- Document the default space behavior.
- Add examples for `spaceName: 'A'` and `spaceName: 'B'`.
- Explain that spaces are hard isolation boundaries for topic/fact traversal and semantic retrieval.
**Verification:**
- Run: `bun run test && bun run check && bun run build`
- Confirm docs/examples align with the final public API.
---
## Suggested commit boundaries
1. `docs: add IdentityDB space isolation plan`
2. `test: specify isolated memory spaces`
3. `feat: add space-aware memory graph isolation`
4. `docs: document space-scoped memory usage`
---
## Acceptance checklist
- [ ] `spaces` table exists.
- [ ] Topics with the same normalized name can exist in different spaces.
- [ ] Facts from one space do not appear in another spaces queries.
- [ ] Alias and hierarchy traversal are space-aware.
- [ ] Semantic search and duplicate detection are space-aware.
- [ ] Existing callers still work via the `default` space.
- [ ] Full test/build/typecheck suite passes.

View File

@@ -1,6 +1,6 @@
{ {
"name": "identitydb", "name": "identitydb",
"version": "0.1.0", "version": "0.2.0",
"description": "TypeScript memory graph database wrapper for topics, facts, and AI-assisted ingestion.", "description": "TypeScript memory graph database wrapper for topics, facts, and AI-assisted ingestion.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -1,39 +1,27 @@
import { import {
type AddFactInput,
type ConnectedTopic, type ConnectedTopic,
type Fact, type Fact,
type FactTopic, type FactTopic,
type FindSimilarFactsInput, type FindSimilarFactsInput,
type IndexFactEmbeddingsInput, type IndexFactEmbeddingsInput,
type LinkTopicsInput,
type ListTopicsOptions, type ListTopicsOptions,
type ScoredFact, type ScoredFact,
type SearchFactsInput, type SearchFactsInput,
type Space,
type SpaceScopedInput,
type Topic, type Topic,
type TopicLookupOptions, type TopicLookupOptions,
type TopicWithFacts, type TopicWithFacts,
type UpsertSpaceInput,
type UpsertTopicInput, type UpsertTopicInput,
type AddFactInput,
type LinkTopicsInput,
} from '../types/api'; } from '../types/api';
import type { IngestStatementOptions } from '../ingestion/types'; import type { IngestStatementOptions } from '../ingestion/types';
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect'; import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
import type { IdentityDatabaseSchema } from '../types/database'; 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 { 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 { extractFact } from '../ingestion/extractor';
import { import {
findFactRowsConnectingTopicIds, findFactRowsConnectingTopicIds,
@@ -41,16 +29,37 @@ import {
findTopicLinksForFactIds, findTopicLinksForFactIds,
} from '../queries/facts'; } from '../queries/facts';
import { import {
type DatabaseExecutor,
findChildTopicRows,
findConnectedTopicRows, findConnectedTopicRows,
findParentTopicRows,
findSpaceRowByNormalizedName,
findTopicRowByNameOrAlias, findTopicRowByNameOrAlias,
findTopicRowByNormalizedAlias, findTopicRowByNormalizedAlias,
findTopicRowByNormalizedName, findTopicRowByNormalizedName,
listTopicAliasRowsForTopicId, listTopicAliasRowsForTopicId,
listTopicRows, listTopicRows,
findChildTopicRows,
findParentTopicRows,
type DatabaseExecutor,
} from '../queries/topics'; } 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 { export class IdentityDB {
private constructor(private readonly connection: DatabaseConnection) {} private constructor(private readonly connection: DatabaseConnection) {}
@@ -68,6 +77,72 @@ export class IdentityDB {
await this.connection.destroy(); await this.connection.destroy();
} }
async upsertSpace(input: UpsertSpaceInput): Promise<Space> {
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<Space | null> {
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<Space[]> {
const rows = await this.connection.db
.selectFrom('spaces')
.selectAll()
.orderBy('normalized_name', 'asc')
.execute();
return rows.map(mapSpaceRow);
}
async upsertTopic(input: UpsertTopicInput): Promise<Topic> { async upsertTopic(input: UpsertTopicInput): Promise<Topic> {
return this.upsertTopicInExecutor(this.connection.db, input); return this.upsertTopicInExecutor(this.connection.db, input);
} }
@@ -82,6 +157,7 @@ export class IdentityDB {
} }
return this.connection.db.transaction().execute(async (trx) => { return this.connection.db.transaction().execute(async (trx) => {
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
const createdAt = nowIsoString(); const createdAt = nowIsoString();
const factId = createId(); const factId = createId();
@@ -89,6 +165,7 @@ export class IdentityDB {
.insertInto('facts') .insertInto('facts')
.values({ .values({
id: factId, id: factId,
space_id: space.id,
statement: input.statement.trim(), statement: input.statement.trim(),
summary: input.summary ?? null, summary: input.summary ?? null,
source: input.source ?? null, source: input.source ?? null,
@@ -103,7 +180,11 @@ export class IdentityDB {
for (let index = 0; index < input.topics.length; index += 1) { for (let index = 0; index < input.topics.length; index += 1) {
const topicInput = input.topics[index]!; 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 await trx
.insertInto('fact_topics') .insertInto('fact_topics')
@@ -125,6 +206,7 @@ export class IdentityDB {
return { return {
id: factId, id: factId,
spaceId: space.id,
statement: input.statement.trim(), statement: input.statement.trim(),
summary: input.summary ?? null, summary: input.summary ?? null,
source: input.source ?? null, source: input.source ?? null,
@@ -137,14 +219,12 @@ export class IdentityDB {
}); });
} }
async ingestStatement( async ingestStatement(statement: string, options: IngestStatementOptions): Promise<Fact> {
statement: string,
options: IngestStatementOptions,
): Promise<Fact> {
const extracted = await extractFact(statement, options.extractor); const extracted = await extractFact(statement, options.extractor);
const factInput: AddFactInput = { const factInput: AddFactInput = {
statement: extracted.statement ?? statement, statement: extracted.statement ?? statement,
topics: extracted.topics, topics: extracted.topics,
spaceName: options.spaceName,
}; };
if (extracted.summary !== undefined) { if (extracted.summary !== undefined) {
@@ -170,6 +250,7 @@ export class IdentityDB {
topicNames: factInput.topics.map((topic) => topic.name), topicNames: factInput.topics.map((topic) => topic.name),
limit: 1, limit: 1,
minimumScore: options.duplicateThreshold ?? 0.97, minimumScore: options.duplicateThreshold ?? 0.97,
spaceName: options.spaceName,
}); });
if (similarFacts[0]) { if (similarFacts[0]) {
@@ -180,15 +261,27 @@ export class IdentityDB {
const fact = await this.addFact(factInput); const fact = await this.addFact(factInput);
if (options.embeddingProvider) { if (options.embeddingProvider) {
await this.indexFactEmbedding(fact.id, { provider: options.embeddingProvider }); await this.indexFactEmbedding(fact.id, {
provider: options.embeddingProvider,
spaceName: options.spaceName,
});
} }
return fact; return fact;
} }
async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise<void> { async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise<void> {
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) { if (factRows.length === 0) {
return; return;
} }
@@ -222,6 +315,13 @@ export class IdentityDB {
throw new IdentityDBError(`Fact not found: ${factId}`); 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); const embedding = await input.provider.embed(factRow.statement);
this.assertEmbeddingShape(embedding, input.provider.dimensions); this.assertEmbeddingShape(embedding, input.provider.dimensions);
@@ -236,6 +336,11 @@ export class IdentityDB {
return []; return [];
} }
const space = await this.getSpaceForRead(input.spaceName);
if (input.spaceName && !space) {
return [];
}
const queryEmbedding = await input.provider.embed(queryText); const queryEmbedding = await input.provider.embed(queryText);
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions); this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
@@ -245,6 +350,7 @@ export class IdentityDB {
topicNames: input.topicNames, topicNames: input.topicNames,
limit: input.limit, limit: input.limit,
minimumScore: input.minimumScore, minimumScore: input.minimumScore,
spaceId: space?.id,
}); });
} }
@@ -254,6 +360,11 @@ export class IdentityDB {
return []; return [];
} }
const space = await this.getSpaceForRead(input.spaceName);
if (input.spaceName && !space) {
return [];
}
const queryEmbedding = await input.provider.embed(statement); const queryEmbedding = await input.provider.embed(statement);
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions); this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
@@ -263,6 +374,7 @@ export class IdentityDB {
topicNames: input.topicNames, topicNames: input.topicNames,
limit: input.limit, limit: input.limit,
minimumScore: input.minimumScore, minimumScore: input.minimumScore,
spaceId: space?.id,
}); });
} }
@@ -279,12 +391,15 @@ export class IdentityDB {
} }
await this.connection.db.transaction().execute(async (trx) => { await this.connection.db.transaction().execute(async (trx) => {
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
const parentTopic = await this.upsertTopicInExecutor(trx, { const parentTopic = await this.upsertTopicInExecutor(trx, {
name: input.parentName, name: input.parentName,
granularity: 'abstract', granularity: 'abstract',
spaceName: space.name,
}); });
const childTopic = await this.upsertTopicInExecutor(trx, { const childTopic = await this.upsertTopicInExecutor(trx, {
name: input.childName, name: input.childName,
spaceName: space.name,
}); });
const existing = await trx const existing = await trx
@@ -309,7 +424,7 @@ export class IdentityDB {
}); });
} }
async addTopicAlias(canonicalName: string, alias: string): Promise<void> { async addTopicAlias(canonicalName: string, alias: string, options?: SpaceScopedInput): Promise<void> {
const normalizedAlias = normalizeTopicName(alias); const normalizedAlias = normalizeTopicName(alias);
if (normalizedAlias.length === 0) { if (normalizedAlias.length === 0) {
@@ -317,18 +432,22 @@ export class IdentityDB {
} }
await this.connection.db.transaction().execute(async (trx) => { 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) { if (normalizedAlias === canonicalTopic.normalizedName) {
return; return;
} }
const exactTopicMatch = await findTopicRowByNormalizedName(trx, normalizedAlias); const exactTopicMatch = await findTopicRowByNormalizedName(trx, space.id, normalizedAlias);
if (exactTopicMatch && exactTopicMatch.id !== canonicalTopic.id) { if (exactTopicMatch && exactTopicMatch.id !== canonicalTopic.id) {
throw new IdentityDBError('Cannot assign an alias that already belongs to another canonical topic.'); 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) {
if (aliasMatch.id !== canonicalTopic.id) { if (aliasMatch.id !== canonicalTopic.id) {
throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.'); throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.');
@@ -341,6 +460,7 @@ export class IdentityDB {
.insertInto('topic_aliases') .insertInto('topic_aliases')
.values({ .values({
id: createId(), id: createId(),
space_id: space.id,
topic_id: canonicalTopic.id, topic_id: canonicalTopic.id,
alias: canonicalizeTopicName(alias), alias: canonicalizeTopicName(alias),
normalized_alias: normalizedAlias, normalized_alias: normalizedAlias,
@@ -352,47 +472,43 @@ export class IdentityDB {
}); });
} }
async resolveTopic(name: string): Promise<Topic | null> { async resolveTopic(name: string, options?: SpaceScopedInput): Promise<Topic | null> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
return topicRow ? mapTopicRow(topicRow) : null; return topicRow ? mapTopicRow(topicRow) : null;
} }
async getTopicAliases(name: string): Promise<string[]> { async getTopicAliases(name: string, options?: SpaceScopedInput): Promise<string[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; 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); return aliasRows.map((aliasRow) => aliasRow.alias);
} }
async getTopicChildren(name: string): Promise<Topic[]> { async getTopicChildren(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; 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); return childRows.map(mapTopicRow);
} }
async getTopicParents(name: string): Promise<Topic[]> { async getTopicParents(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; 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); return parentRows.map(mapTopicRow);
} }
async getTopicLineage(name: string): Promise<Topic[]> { async getTopicLineage(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; return [];
} }
@@ -405,8 +521,7 @@ export class IdentityDB {
const nextLevelIds: string[] = []; const nextLevelIds: string[] = [];
for (const currentId of currentLevelIds) { 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) { for (const parentRow of parentRows) {
if (visitedTopicIds.has(parentRow.id)) { if (visitedTopicIds.has(parentRow.id)) {
continue; continue;
@@ -424,97 +539,97 @@ export class IdentityDB {
return lineage; return lineage;
} }
async getTopicFacts(name: string): Promise<Fact[]> { async getTopicFacts(name: string, options?: SpaceScopedInput): Promise<Fact[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; return [];
} }
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.id); const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
return this.hydrateFacts(factRows); return this.hydrateFacts(factRows, topicRow.space_id);
} }
async getTopicFactsLinkedTo(name: string, linkedTopicName: string): Promise<Fact[]> { async getTopicFactsLinkedTo(name: string, linkedTopicName: string, options?: SpaceScopedInput): Promise<Fact[]> {
return this.findFactsConnectingTopics([name, linkedTopicName]); return this.findFactsConnectingTopics([name, linkedTopicName], options);
} }
async findFactsConnectingTopics(names: string[]): Promise<Fact[]> { async findFactsConnectingTopics(names: string[], options?: SpaceScopedInput): Promise<Fact[]> {
if (names.length === 0) { if (names.length === 0) {
return []; 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)) { if (topicRows.some((topicRow) => topicRow === undefined)) {
return []; return [];
} }
const topicIds = topicRows.map((topicRow) => topicRow!.id); 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( async getTopicByName(name: string, options: { includeFacts: true; spaceName?: string }): Promise<TopicWithFacts | null>;
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 | null>;
async getTopicByName( async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | TopicWithFacts | null> {
name: string, const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
options?: TopicLookupOptions,
): Promise<Topic | TopicWithFacts | null> {
const topicRow = await this.getRequiredTopicRow(name);
if (!topicRow) { if (!topicRow) {
return null; return null;
} }
const topic = mapTopicRow(topicRow); const topic = mapTopicRow(topicRow);
if (options?.includeFacts) { if (options?.includeFacts) {
return { return {
...topic, ...topic,
facts: await this.getTopicFacts(name), facts: await this.getTopicFacts(name, { spaceName: options.spaceName }),
}; };
} }
return topic; return topic;
} }
async listTopics(options: { includeFacts: true; limit?: number }): Promise<TopicWithFacts[]>; async listTopics(options: { includeFacts: true; limit?: number; spaceName?: string }): Promise<TopicWithFacts[]>;
async listTopics(options?: ListTopicsOptions): Promise<Topic[]>; async listTopics(options?: ListTopicsOptions): Promise<Topic[]>;
async listTopics( async listTopics(options?: ListTopicsOptions): Promise<Topic[] | TopicWithFacts[]> {
options?: ListTopicsOptions, const space = await this.getSpaceForRead(options?.spaceName);
): Promise<Topic[] | TopicWithFacts[]> { if (options?.spaceName && !space) {
const rows = await listTopicRows(this.connection.db, options?.limit); return [];
}
const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead();
if (!spaceId) {
return [];
}
const rows = await listTopicRows(this.connection.db, spaceId, options?.limit);
if (!options?.includeFacts) { if (!options?.includeFacts) {
return rows.map(mapTopicRow); return rows.map(mapTopicRow);
} }
const topicsWithFacts: TopicWithFacts[] = []; const topicsWithFacts: TopicWithFacts[] = [];
for (const row of rows) { for (const row of rows) {
topicsWithFacts.push({ topicsWithFacts.push({
...mapTopicRow(row), ...mapTopicRow(row),
facts: await this.getTopicFacts(row.name), facts: await this.getTopicFacts(row.name, { spaceName: options?.spaceName }),
}); });
} }
return topicsWithFacts; return topicsWithFacts;
} }
async findConnectedTopics(name: string): Promise<ConnectedTopic[]> { async findConnectedTopics(name: string, options?: SpaceScopedInput): Promise<ConnectedTopic[]> {
const topicRow = await this.getRequiredTopicRow(name); const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) { if (!topicRow) {
return []; 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) => ({ return rows.map((row) => ({
...mapTopicRow(row), ...mapTopicRow(row),
sharedFactCount: row.shared_fact_count, sharedFactCount: row.shared_fact_count,
@@ -527,18 +642,25 @@ export class IdentityDB {
topicNames?: string[] | undefined; topicNames?: string[] | undefined;
limit?: number | undefined; limit?: number | undefined;
minimumScore?: number | undefined; minimumScore?: number | undefined;
spaceId?: string | undefined;
}): Promise<ScoredFact[]> { }): Promise<ScoredFact[]> {
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) { if (topicIds === null) {
return []; return [];
} }
const factRows = topicIds.length > 0 const factRows = topicIds.length > 0
? await findFactRowsConnectingTopicIds(this.connection.db, topicIds) ? await findFactRowsConnectingTopicIds(this.connection.db, effectiveSpaceId, topicIds)
: await this.connection.db : await this.connection.db
.selectFrom('facts') .selectFrom('facts')
.innerJoin('fact_embeddings', 'fact_embeddings.fact_id', 'facts.id') .innerJoin('fact_embeddings', 'fact_embeddings.fact_id', 'facts.id')
.selectAll('facts') .selectAll('facts')
.where('facts.space_id', '=', effectiveSpaceId)
.where('fact_embeddings.model', '=', input.providerModel) .where('fact_embeddings.model', '=', input.providerModel)
.orderBy('facts.created_at', 'asc') .orderBy('facts.created_at', 'asc')
.execute(); .execute();
@@ -547,14 +669,14 @@ export class IdentityDB {
return []; return [];
} }
const embeddingRowsQuery = this.connection.db const embeddingRows = await this.connection.db
.selectFrom('fact_embeddings') .selectFrom('fact_embeddings')
.selectAll() .innerJoin('facts', 'facts.id', 'fact_embeddings.fact_id')
.where('model', '=', input.providerModel); .selectAll('fact_embeddings')
.where('facts.space_id', '=', effectiveSpaceId)
const embeddingRows = factRows.length > 0 .where('fact_embeddings.model', '=', input.providerModel)
? await embeddingRowsQuery.where('fact_id', 'in', factRows.map((factRow) => factRow.id)).execute() .where('fact_embeddings.fact_id', 'in', factRows.map((factRow) => factRow.id))
: []; .execute();
const embeddingsByFactId = new Map( const embeddingsByFactId = new Map(
embeddingRows.map((embeddingRow) => [embeddingRow.fact_id, deserializeEmbedding(embeddingRow.embedding)]), embeddingRows.map((embeddingRow) => [embeddingRow.fact_id, deserializeEmbedding(embeddingRow.embedding)]),
@@ -578,7 +700,7 @@ export class IdentityDB {
return []; 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])); const factsById = new Map(hydratedFacts.map((fact) => [fact.id, fact]));
return scoredRows.map((entry) => ({ return scoredRows.map((entry) => ({
@@ -587,12 +709,12 @@ export class IdentityDB {
})); }));
} }
private async resolveTopicIds(topicNames?: string[]): Promise<string[] | null> { private async resolveTopicIds(topicNames: string[] | undefined, spaceId: string): Promise<string[] | null> {
if (!topicNames || topicNames.length === 0) { if (!topicNames || topicNames.length === 0) {
return []; 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)) { if (topicRows.some((topicRow) => !topicRow)) {
return null; return null;
} }
@@ -637,30 +759,28 @@ export class IdentityDB {
} }
} }
private async upsertTopicInExecutor( private async upsertTopicInExecutor(executor: DatabaseExecutor, input: UpsertTopicInput): Promise<Topic> {
executor: DatabaseExecutor,
input: UpsertTopicInput,
): Promise<Topic> {
const normalizedName = normalizeTopicName(input.name); const normalizedName = normalizeTopicName(input.name);
if (normalizedName.length === 0) { if (normalizedName.length === 0) {
throw new IdentityDBError('Topic name cannot be empty.'); 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(); const now = nowIsoString();
if (existing) { if (existing) {
return this.updateTopicRowInExecutor(executor, existing, input, now, true); return this.updateTopicRowInExecutor(executor, existing, input, now, true);
} }
const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName); const aliasedTopic = await findTopicRowByNormalizedAlias(executor, space.id, normalizedName);
if (aliasedTopic) { if (aliasedTopic) {
return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false); return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false);
} }
const createdRow: TopicRecord = { const createdRow: TopicRecord = {
id: createId(), id: createId(),
space_id: space.id,
name: canonicalizeTopicName(input.name), name: canonicalizeTopicName(input.name),
normalized_name: normalizedName, normalized_name: normalizedName,
category: input.category ?? 'custom', category: input.category ?? 'custom',
@@ -672,7 +792,6 @@ export class IdentityDB {
}; };
await executor.insertInto('topics').values(createdRow).execute(); await executor.insertInto('topics').values(createdRow).execute();
return mapTopicRow(createdRow); return mapTopicRow(createdRow);
} }
@@ -705,22 +824,39 @@ export class IdentityDB {
return mapTopicRow(updated); return mapTopicRow(updated);
} }
private async getRequiredTopicRow(name: string): Promise<TopicRecord | undefined> { private async getRequiredTopicRow(name: string, spaceName?: string): Promise<TopicRecord | undefined> {
const normalizedName = normalizeTopicName(name); 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<TopicRecord | undefined> {
const normalizedName = normalizeTopicName(name);
if (normalizedName.length === 0) { if (normalizedName.length === 0) {
return undefined; return undefined;
} }
return findTopicRowByNameOrAlias(this.connection.db, normalizedName); return findTopicRowByNameOrAlias(this.connection.db, spaceId, normalizedName);
} }
private async hydrateFacts(factRows: FactRecord[]): Promise<Fact[]> { private async hydrateFacts(factRows: FactRecord[], spaceId?: string): Promise<Fact[]> {
if (factRows.length === 0) {
return [];
}
const effectiveSpaceId = spaceId ?? factRows[0]!.space_id;
const factIds = factRows.map((fact) => fact.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<string, FactTopic[]>(); const topicsByFactId = new Map<string, FactTopic[]>();
for (const topicLink of topicLinks) { for (const topicLink of topicLinks) {
const topics = topicsByFactId.get(topicLink.fact_id) ?? []; const topics = topicsByFactId.get(topicLink.fact_id) ?? [];
topics.push({ topics.push({
@@ -733,4 +869,57 @@ export class IdentityDB {
return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? [])); return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? []));
} }
private async getOrCreateSpaceInExecutor(executor: DatabaseExecutor, requestedSpaceName?: string): Promise<SpaceRecord> {
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<SpaceRecord | undefined> {
if (!spaceName) {
return undefined;
}
const normalizedName = normalizeSpaceName(spaceName);
if (normalizedName.length === 0) {
return undefined;
}
return findSpaceRowByNormalizedName(this.connection.db, normalizedName);
}
private async getDefaultSpaceIdForRead(): Promise<string | undefined> {
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}).`,
);
}
}
} }

View File

@@ -4,6 +4,7 @@ import {
FACTS_TABLE, FACTS_TABLE,
FACT_EMBEDDINGS_TABLE, FACT_EMBEDDINGS_TABLE,
FACT_TOPICS_TABLE, FACT_TOPICS_TABLE,
SPACES_TABLE,
TOPIC_ALIASES_TABLE, TOPIC_ALIASES_TABLE,
TOPIC_RELATIONS_TABLE, TOPIC_RELATIONS_TABLE,
TOPICS_TABLE, TOPICS_TABLE,
@@ -14,23 +15,42 @@ export async function initializeSchema(
db: Kysely<IdentityDatabaseSchema>, db: Kysely<IdentityDatabaseSchema>,
): Promise<void> { ): Promise<void> {
await db.schema await db.schema
.createTable(TOPICS_TABLE) .createTable(SPACES_TABLE)
.ifNotExists() .ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey()) .addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('name', 'text', (column) => column.notNull()) .addColumn('name', 'text', (column) => column.notNull())
.addColumn('normalized_name', 'text', (column) => column.notNull().unique()) .addColumn('normalized_name', 'text', (column) => column.notNull().unique())
.addColumn('category', 'text', (column) => column.notNull())
.addColumn('granularity', 'text', (column) => column.notNull())
.addColumn('description', 'text') .addColumn('description', 'text')
.addColumn('metadata', 'text') .addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull()) .addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull()) .addColumn('updated_at', 'text', (column) => column.notNull())
.execute(); .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 await db.schema
.createTable(FACTS_TABLE) .createTable(FACTS_TABLE)
.ifNotExists() .ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey()) .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('statement', 'text', (column) => column.notNull())
.addColumn('summary', 'text') .addColumn('summary', 'text')
.addColumn('source', 'text') .addColumn('source', 'text')
@@ -88,14 +108,32 @@ export async function initializeSchema(
.createTable(TOPIC_ALIASES_TABLE) .createTable(TOPIC_ALIASES_TABLE)
.ifNotExists() .ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey()) .addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('space_id', 'text', (column) =>
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
)
.addColumn('topic_id', 'text', (column) => .addColumn('topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'), column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
) )
.addColumn('alias', 'text', (column) => column.notNull()) .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('is_primary', 'integer', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull()) .addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_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(); .execute();
await db.schema await db.schema
@@ -133,6 +171,13 @@ export async function initializeSchema(
.column('child_topic_id') .column('child_topic_id')
.execute(); .execute();
await db.schema
.createIndex('topic_aliases_space_id_idx')
.ifNotExists()
.on(TOPIC_ALIASES_TABLE)
.column('space_id')
.execute();
await db.schema await db.schema
.createIndex('topic_aliases_topic_id_idx') .createIndex('topic_aliases_topic_id_idx')
.ifNotExists() .ifNotExists()

View File

@@ -1,3 +1,4 @@
export const SPACES_TABLE = 'spaces';
export const TOPICS_TABLE = 'topics'; export const TOPICS_TABLE = 'topics';
export const FACTS_TABLE = 'facts'; export const FACTS_TABLE = 'facts';
export const FACT_TOPICS_TABLE = 'fact_topics'; 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 TOPIC_ALIASES_TABLE = 'topic_aliases';
export const FACT_EMBEDDINGS_TABLE = 'fact_embeddings'; 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 = [ export const TOPIC_COLUMNS = [
'id', 'id',
'space_id',
'name', 'name',
'normalized_name', 'normalized_name',
'category', 'category',
@@ -19,6 +31,7 @@ export const TOPIC_COLUMNS = [
export const FACT_COLUMNS = [ export const FACT_COLUMNS = [
'id', 'id',
'space_id',
'statement', 'statement',
'summary', 'summary',
'source', 'source',
@@ -45,6 +58,7 @@ export const TOPIC_RELATION_COLUMNS = [
export const TOPIC_ALIAS_COLUMNS = [ export const TOPIC_ALIAS_COLUMNS = [
'id', 'id',
'space_id',
'topic_id', 'topic_id',
'alias', 'alias',
'normalized_alias', 'normalized_alias',

View File

@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import type { Fact, FactTopic, Topic } from '../types/api'; import type { Fact, FactTopic, Space, Topic } from '../types/api';
import type { FactRecord, TopicRecord } from '../types/domain'; import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain';
export function normalizeTopicName(name: string): string { export function normalizeTopicName(name: string): string {
return name.trim().replace(/\s+/g, ' ').toLowerCase(); return name.trim().replace(/\s+/g, ' ').toLowerCase();
@@ -11,6 +11,14 @@ export function canonicalizeTopicName(name: string): string {
return name.trim().replace(/\s+/g, ' '); 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 { export function nowIsoString(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
@@ -71,9 +79,22 @@ export function cosineSimilarity(left: number[], right: number[]): number {
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude)); 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 { export function mapTopicRow(record: TopicRecord): Topic {
return { return {
id: record.id, id: record.id,
spaceId: record.space_id,
name: record.name, name: record.name,
normalizedName: record.normalized_name, normalizedName: record.normalized_name,
category: record.category, category: record.category,
@@ -88,6 +109,7 @@ export function mapTopicRow(record: TopicRecord): Topic {
export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact { export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact {
return { return {
id: record.id, id: record.id,
spaceId: record.space_id,
statement: record.statement, statement: record.statement,
summary: record.summary, summary: record.summary,
source: record.source, source: record.source,

View File

@@ -31,4 +31,5 @@ export interface IngestStatementOptions {
extractor: FactExtractor; extractor: FactExtractor;
embeddingProvider?: EmbeddingProvider; embeddingProvider?: EmbeddingProvider;
duplicateThreshold?: number; duplicateThreshold?: number;
spaceName?: string;
} }

View File

@@ -13,12 +13,14 @@ export interface FactTopicJoinRow extends TopicRecord {
export async function findFactRowsForTopicId( export async function findFactRowsForTopicId(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
topicId: string, topicId: string,
): Promise<FactRecord[]> { ): Promise<FactRecord[]> {
return executor return executor
.selectFrom('facts') .selectFrom('facts')
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id') .innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
.selectAll('facts') .selectAll('facts')
.where('facts.space_id', '=', spaceId)
.where('fact_topics.topic_id', '=', topicId) .where('fact_topics.topic_id', '=', topicId)
.orderBy('facts.created_at', 'asc') .orderBy('facts.created_at', 'asc')
.execute(); .execute();
@@ -26,6 +28,7 @@ export async function findFactRowsForTopicId(
export async function findFactRowsConnectingTopicIds( export async function findFactRowsConnectingTopicIds(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
topicIds: string[], topicIds: string[],
): Promise<FactRecord[]> { ): Promise<FactRecord[]> {
if (topicIds.length === 0) { if (topicIds.length === 0) {
@@ -36,6 +39,7 @@ export async function findFactRowsConnectingTopicIds(
.selectFrom('facts') .selectFrom('facts')
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id') .innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
.selectAll('facts') .selectAll('facts')
.where('facts.space_id', '=', spaceId)
.where('fact_topics.topic_id', 'in', topicIds) .where('fact_topics.topic_id', 'in', topicIds)
.groupBy('facts.id') .groupBy('facts.id')
.having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length) .having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length)
@@ -45,6 +49,7 @@ export async function findFactRowsConnectingTopicIds(
export async function findTopicLinksForFactIds( export async function findTopicLinksForFactIds(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
factIds: string[], factIds: string[],
): Promise<FactTopicJoinRow[]> { ): Promise<FactTopicJoinRow[]> {
if (factIds.length === 0) { if (factIds.length === 0) {
@@ -60,6 +65,7 @@ export async function findTopicLinksForFactIds(
'fact_topics.role as role', 'fact_topics.role as role',
'fact_topics.position as position', 'fact_topics.position as position',
]) ])
.where('topics.space_id', '=', spaceId)
.where('fact_topics.fact_id', 'in', factIds) .where('fact_topics.fact_id', 'in', factIds)
.orderBy('fact_topics.position', 'asc') .orderBy('fact_topics.position', 'asc')
.execute() as Promise<FactTopicJoinRow[]>; .execute() as Promise<FactTopicJoinRow[]>;

View File

@@ -1,7 +1,7 @@
import type { Kysely, Transaction } from 'kysely'; import type { Kysely, Transaction } from 'kysely';
import type { IdentityDatabaseSchema } from '../types/database'; 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<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>; export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
@@ -9,48 +9,66 @@ export interface ConnectedTopicRow extends TopicRecord {
shared_fact_count: number; shared_fact_count: number;
} }
export async function findSpaceRowByNormalizedName(
executor: DatabaseExecutor,
normalizedName: string,
): Promise<SpaceRecord | undefined> {
return executor
.selectFrom('spaces')
.selectAll()
.where('normalized_name', '=', normalizedName)
.executeTakeFirst();
}
export async function findTopicRowByNormalizedName( export async function findTopicRowByNormalizedName(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
normalizedName: string, normalizedName: string,
): Promise<TopicRecord | undefined> { ): Promise<TopicRecord | undefined> {
return executor return executor
.selectFrom('topics') .selectFrom('topics')
.selectAll() .selectAll()
.where('space_id', '=', spaceId)
.where('normalized_name', '=', normalizedName) .where('normalized_name', '=', normalizedName)
.executeTakeFirst(); .executeTakeFirst();
} }
export async function findTopicRowByNormalizedAlias( export async function findTopicRowByNormalizedAlias(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
normalizedAlias: string, normalizedAlias: string,
): Promise<TopicRecord | undefined> { ): Promise<TopicRecord | undefined> {
return executor return executor
.selectFrom('topic_aliases') .selectFrom('topic_aliases')
.innerJoin('topics', 'topics.id', 'topic_aliases.topic_id') .innerJoin('topics', 'topics.id', 'topic_aliases.topic_id')
.selectAll('topics') .selectAll('topics')
.where('topic_aliases.space_id', '=', spaceId)
.where('topic_aliases.normalized_alias', '=', normalizedAlias) .where('topic_aliases.normalized_alias', '=', normalizedAlias)
.executeTakeFirst(); .executeTakeFirst();
} }
export async function findTopicRowByNameOrAlias( export async function findTopicRowByNameOrAlias(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
normalizedName: string, normalizedName: string,
): Promise<TopicRecord | undefined> { ): Promise<TopicRecord | undefined> {
const directMatch = await findTopicRowByNormalizedName(executor, normalizedName); const directMatch = await findTopicRowByNormalizedName(executor, spaceId, normalizedName);
if (directMatch) { if (directMatch) {
return directMatch; return directMatch;
} }
return findTopicRowByNormalizedAlias(executor, normalizedName); return findTopicRowByNormalizedAlias(executor, spaceId, normalizedName);
} }
export async function listTopicAliasRowsForTopicId( export async function listTopicAliasRowsForTopicId(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
topicId: string, topicId: string,
): Promise<TopicAliasRecord[]> { ): Promise<TopicAliasRecord[]> {
return executor return executor
.selectFrom('topic_aliases') .selectFrom('topic_aliases')
.selectAll() .selectAll()
.where('space_id', '=', spaceId)
.where('topic_id', '=', topicId) .where('topic_id', '=', topicId)
.orderBy('is_primary', 'desc') .orderBy('is_primary', 'desc')
.orderBy('normalized_alias', 'asc') .orderBy('normalized_alias', 'asc')
@@ -59,9 +77,14 @@ export async function listTopicAliasRowsForTopicId(
export async function listTopicRows( export async function listTopicRows(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
limit?: number, limit?: number,
): Promise<TopicRecord[]> { ): Promise<TopicRecord[]> {
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) { if (limit !== undefined) {
query = query.limit(limit); query = query.limit(limit);
@@ -72,14 +95,18 @@ export async function listTopicRows(
export async function findConnectedTopicRows( export async function findConnectedTopicRows(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
topicId: string, topicId: string,
): Promise<ConnectedTopicRow[]> { ): Promise<ConnectedTopicRow[]> {
return executor return executor
.selectFrom('fact_topics as source_link') .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('fact_topics as related_link', 'related_link.fact_id', 'source_link.fact_id')
.innerJoin('topics', 'topics.id', 'related_link.topic_id') .innerJoin('topics', 'topics.id', 'related_link.topic_id')
.selectAll('topics') .selectAll('topics')
.select((eb) => eb.fn.count<number>('related_link.fact_id').as('shared_fact_count')) .select((eb) => eb.fn.count<number>('related_link.fact_id').as('shared_fact_count'))
.where('facts.space_id', '=', spaceId)
.where('topics.space_id', '=', spaceId)
.where('source_link.topic_id', '=', topicId) .where('source_link.topic_id', '=', topicId)
.whereRef('related_link.topic_id', '!=', 'source_link.topic_id') .whereRef('related_link.topic_id', '!=', 'source_link.topic_id')
.groupBy('topics.id') .groupBy('topics.id')
@@ -90,12 +117,14 @@ export async function findConnectedTopicRows(
export async function findChildTopicRows( export async function findChildTopicRows(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
parentTopicId: string, parentTopicId: string,
): Promise<TopicRecord[]> { ): Promise<TopicRecord[]> {
return executor return executor
.selectFrom('topic_relations') .selectFrom('topic_relations')
.innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id') .innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id')
.selectAll('topics') .selectAll('topics')
.where('topics.space_id', '=', spaceId)
.where('topic_relations.parent_topic_id', '=', parentTopicId) .where('topic_relations.parent_topic_id', '=', parentTopicId)
.where('topic_relations.relation', '=', 'parent_of') .where('topic_relations.relation', '=', 'parent_of')
.orderBy('topics.normalized_name', 'asc') .orderBy('topics.normalized_name', 'asc')
@@ -104,12 +133,14 @@ export async function findChildTopicRows(
export async function findParentTopicRows( export async function findParentTopicRows(
executor: DatabaseExecutor, executor: DatabaseExecutor,
spaceId: string,
childTopicId: string, childTopicId: string,
): Promise<TopicRecord[]> { ): Promise<TopicRecord[]> {
return executor return executor
.selectFrom('topic_relations') .selectFrom('topic_relations')
.innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id') .innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id')
.selectAll('topics') .selectAll('topics')
.where('topics.space_id', '=', spaceId)
.where('topic_relations.child_topic_id', '=', childTopicId) .where('topic_relations.child_topic_id', '=', childTopicId)
.where('topic_relations.relation', '=', 'parent_of') .where('topic_relations.relation', '=', 'parent_of')
.orderBy('topics.normalized_name', 'asc') .orderBy('topics.normalized_name', 'asc')

View File

@@ -1,6 +1,26 @@
import type { JsonValue, TopicCategory, TopicGranularity } from './domain'; 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; name: string;
category?: TopicCategory; category?: TopicCategory;
granularity?: TopicGranularity; granularity?: TopicGranularity;
@@ -12,7 +32,7 @@ export interface TopicLinkInput extends UpsertTopicInput {
role?: string | null; role?: string | null;
} }
export interface AddFactInput { export interface AddFactInput extends SpaceScopedInput {
statement: string; statement: string;
summary?: string | null; summary?: string | null;
source?: string | null; source?: string | null;
@@ -21,13 +41,14 @@ export interface AddFactInput {
topics: TopicLinkInput[]; topics: TopicLinkInput[];
} }
export interface LinkTopicsInput { export interface LinkTopicsInput extends SpaceScopedInput {
parentName: string; parentName: string;
childName: string; childName: string;
} }
export interface Topic { export interface Topic {
id: string; id: string;
spaceId: string;
name: string; name: string;
normalizedName: string; normalizedName: string;
category: TopicCategory; category: TopicCategory;
@@ -45,6 +66,7 @@ export interface FactTopic extends Topic {
export interface Fact { export interface Fact {
id: string; id: string;
spaceId: string;
statement: string; statement: string;
summary: string | null; summary: string | null;
source: string | null; source: string | null;
@@ -63,11 +85,11 @@ export interface ConnectedTopic extends Topic {
sharedFactCount: number; sharedFactCount: number;
} }
export interface TopicLookupOptions { export interface TopicLookupOptions extends SpaceScopedInput {
includeFacts?: boolean; includeFacts?: boolean;
} }
export interface ListTopicsOptions { export interface ListTopicsOptions extends SpaceScopedInput {
includeFacts?: boolean; includeFacts?: boolean;
limit?: number; limit?: number;
} }
@@ -79,11 +101,11 @@ export interface EmbeddingProvider {
embedMany?(inputs: string[]): Promise<number[][]>; embedMany?(inputs: string[]): Promise<number[][]>;
} }
export interface IndexFactEmbeddingsInput { export interface IndexFactEmbeddingsInput extends SpaceScopedInput {
provider: EmbeddingProvider; provider: EmbeddingProvider;
} }
export interface SearchFactsInput { export interface SearchFactsInput extends SpaceScopedInput {
query: string; query: string;
provider: EmbeddingProvider; provider: EmbeddingProvider;
topicNames?: string[]; topicNames?: string[];
@@ -91,7 +113,7 @@ export interface SearchFactsInput {
minimumScore?: number; minimumScore?: number;
} }
export interface FindSimilarFactsInput { export interface FindSimilarFactsInput extends SpaceScopedInput {
statement: string; statement: string;
provider: EmbeddingProvider; provider: EmbeddingProvider;
topicNames?: string[]; topicNames?: string[];

View File

@@ -2,12 +2,14 @@ import type {
FactEmbeddingRecord, FactEmbeddingRecord,
FactRecord, FactRecord,
FactTopicRecord, FactTopicRecord,
SpaceRecord,
TopicAliasRecord, TopicAliasRecord,
TopicRecord, TopicRecord,
TopicRelationRecord, TopicRelationRecord,
} from './domain'; } from './domain';
export interface IdentityDatabaseSchema { export interface IdentityDatabaseSchema {
spaces: SpaceRecord;
topics: TopicRecord; topics: TopicRecord;
facts: FactRecord; facts: FactRecord;
fact_topics: FactTopicRecord; fact_topics: FactTopicRecord;

View File

@@ -5,8 +5,19 @@ export type TopicGranularity = 'abstract' | 'concrete' | 'mixed';
export type JsonPrimitive = string | number | boolean | null; export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; 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 { export interface TopicRecord {
id: string; id: string;
space_id: string;
name: string; name: string;
normalized_name: string; normalized_name: string;
category: TopicCategory; category: TopicCategory;
@@ -19,6 +30,7 @@ export interface TopicRecord {
export interface FactRecord { export interface FactRecord {
id: string; id: string;
space_id: string;
statement: string; statement: string;
summary: string | null; summary: string | null;
source: string | null; source: string | null;
@@ -45,6 +57,7 @@ export interface TopicRelationRecord {
export interface TopicAliasRecord { export interface TopicAliasRecord {
id: string; id: string;
space_id: string;
topic_id: string; topic_id: string;
alias: string; alias: string;
normalized_alias: string; normalized_alias: string;

View File

@@ -34,6 +34,58 @@ describe('IdentityDB topic and fact writes', () => {
expect(topics).toHaveLength(1); 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 () => { it('adds one fact that links multiple topics', async () => {
const fact = await db.addFact({ const fact = await db.addFact({
statement: 'I have worked with TypeScript since 2025.', statement: 'I have worked with TypeScript since 2025.',

View File

@@ -16,7 +16,7 @@ afterEach(async () => {
}); });
describe('initializeSchema', () => { 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:' }); const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
openConnections.push(connection.destroy); openConnections.push(connection.destroy);
@@ -31,6 +31,7 @@ describe('initializeSchema', () => {
const tableNames = tables.rows.map((row) => row.name); const tableNames = tables.rows.map((row) => row.name);
expect(tableNames).toContain('spaces');
expect(tableNames).toContain('topics'); expect(tableNames).toContain('topics');
expect(tableNames).toContain('facts'); expect(tableNames).toContain('facts');
expect(tableNames).toContain('fact_embeddings'); expect(tableNames).toContain('fact_embeddings');
@@ -45,6 +46,7 @@ describe('initializeSchema', () => {
await initializeSchema(connection.db); 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 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 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); 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 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); 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([ expect(topicsColumns.rows.map((row) => row.name)).toEqual([
'id', 'id',
'space_id',
'name', 'name',
'normalized_name', 'normalized_name',
'category', 'category',
@@ -66,6 +79,7 @@ describe('initializeSchema', () => {
expect(factsColumns.rows.map((row) => row.name)).toEqual([ expect(factsColumns.rows.map((row) => row.name)).toEqual([
'id', 'id',
'space_id',
'statement', 'statement',
'summary', 'summary',
'source', 'source',
@@ -102,6 +116,7 @@ describe('initializeSchema', () => {
expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([ expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([
'id', 'id',
'space_id',
'topic_id', 'topic_id',
'alias', 'alias',
'normalized_alias', 'normalized_alias',

View File

@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IdentityDB } from '../src/core/identity-db'; 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({ await db.addFact({
statement: 'I have worked with TypeScript since 2025.', statement: 'I have worked with TypeScript since 2025.',
spaceName,
topics: [ topics: [
{ name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' }, { name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' }, { name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' },
@@ -14,6 +15,7 @@ async function seedMemoryGraph(db: IdentityDB): Promise<void> {
await db.addFact({ await db.addFact({
statement: 'TypeScript is a programming language.', statement: 'TypeScript is a programming language.',
spaceName,
topics: [ topics: [
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' }, { name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' }, { name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' },
@@ -23,11 +25,13 @@ async function seedMemoryGraph(db: IdentityDB): Promise<void> {
await db.linkTopics({ await db.linkTopics({
parentName: 'software technology', parentName: 'software technology',
childName: 'programming language', childName: 'programming language',
spaceName,
}); });
await db.linkTopics({ await db.linkTopics({
parentName: 'programming language', parentName: 'programming language',
childName: 'TypeScript', 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 () => { it('resolves alias names in topic lookups', async () => {
await db.addTopicAlias('TypeScript', 'TS'); 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]?.statement).toBe('Bun runs TypeScript tooling quickly.');
expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score); 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', () => { describe('IdentityDB dedup-aware ingestion', () => {
@@ -167,4 +214,26 @@ describe('IdentityDB dedup-aware ingestion', () => {
expect(facts).toHaveLength(1); expect(facts).toHaveLength(1);
expect(facts[0]?.statement).toBe('Bun runs TypeScript tooling quickly.'); 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);
});
}); });