Compare commits
5 Commits
283f91ed91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6accd62df5 | |||
| b77e8eea40 | |||
| cea45a552a | |||
| d83fc31c59 | |||
| b908bc0bd9 |
@@ -18,7 +18,7 @@ jobs:
|
||||
name: verify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
name: publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- verify
|
||||
|
||||
34
README.md
34
README.md
@@ -15,9 +15,9 @@ A single fact like `I have worked with TypeScript since 2025.` can connect the t
|
||||
## Current capabilities
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -25,6 +25,8 @@ A single fact like `I have worked with TypeScript since 2025.` can connect the t
|
||||
|
||||
## Install
|
||||
|
||||
SQLite connections use built-in runtime drivers: `bun:sqlite` under Bun and `node:sqlite` under Node 22+. Run SQLite-backed IdentityDB workloads with Bun or Node 22+. PostgreSQL/MySQL/MariaDB adapters remain usable from Node without SQLite.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
@@ -101,6 +103,34 @@ console.log(matches.map((entry) => [entry.statement, entry.score]));
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
80
bun.lock
80
bun.lock
@@ -5,13 +5,11 @@
|
||||
"": {
|
||||
"name": "identitydb",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.1.1",
|
||||
"kysely": "^0.28.8",
|
||||
"mysql2": "^3.15.3",
|
||||
"pg": "^8.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsup": "^8.5.0",
|
||||
@@ -131,8 +129,6 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
|
||||
|
||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
@@ -165,16 +161,6 @@
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
@@ -185,8 +171,6 @@
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
@@ -195,50 +179,28 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
@@ -261,12 +223,6 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -279,14 +235,8 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
@@ -327,32 +277,16 @@
|
||||
|
||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -365,18 +299,10 @@
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
@@ -399,16 +325,12 @@
|
||||
|
||||
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
@@ -417,8 +339,6 @@
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**Architecture:** IdentityDB will use a layered architecture: a storage layer based on Kysely + dialect adapters, a domain layer for topics/facts/links, and a service layer that exposes ergonomic high-level APIs for querying and ingesting memory. Schema initialization will be automatic and idempotent. AI-assisted ingestion will be abstracted behind a pluggable extractor interface so callers can use a small LLM or a deterministic extractor without coupling the core package to a specific model provider.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, better-sqlite3, pg, mysql2, Vitest, tsup.
|
||||
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, bun:sqlite, pg, mysql2, Vitest, tsup.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**Architecture:** Keep the relational core portable across SQLite, PostgreSQL, MySQL, and MariaDB by introducing dedicated extension tables: `topic_relations` for abstract/concrete hierarchy, `topic_aliases` for canonical topic resolution, and `fact_embeddings` for semantic indexing. Expose high-level APIs from `IdentityDB` while preserving DB-agnostic behavior by doing semantic scoring in the application layer first.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, better-sqlite3, pg, mysql2, Vitest, tsup.
|
||||
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, bun:sqlite, pg, mysql2, Vitest, tsup.
|
||||
|
||||
---
|
||||
|
||||
|
||||
157
docs/plans/2026-05-11-identitydb-space-isolation.md
Normal file
157
docs/plans/2026-05-11-identitydb-space-isolation.md
Normal 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 space’s 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "identitydb",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"description": "TypeScript memory graph database wrapper for topics, facts, and AI-assisted ingestion.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -36,13 +36,11 @@
|
||||
"ai"
|
||||
],
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.1.1",
|
||||
"kysely": "^0.28.8",
|
||||
"mysql2": "^3.15.3",
|
||||
"pg": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsup": "^8.5.0",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from 'kysely';
|
||||
import { createPool as createMysqlPool } from 'mysql2';
|
||||
import { Pool as PostgresPool } from 'pg';
|
||||
@@ -44,16 +43,183 @@ export interface DatabaseConnection {
|
||||
destroy: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface BunSqliteStatement {
|
||||
columnNames: ReadonlyArray<string>;
|
||||
all(parameters?: ReadonlyArray<unknown>): unknown[];
|
||||
run(parameters?: ReadonlyArray<unknown>): {
|
||||
changes: number | bigint;
|
||||
lastInsertRowid: number | bigint;
|
||||
};
|
||||
iterate(parameters?: ReadonlyArray<unknown>): IterableIterator<unknown>;
|
||||
}
|
||||
|
||||
interface BunSqliteDatabase {
|
||||
close(): void;
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): BunSqliteStatement;
|
||||
}
|
||||
|
||||
interface BunSqliteModule {
|
||||
Database: {
|
||||
open(filename: string, flags?: number): BunSqliteDatabase;
|
||||
};
|
||||
constants: {
|
||||
SQLITE_OPEN_CREATE: number;
|
||||
SQLITE_OPEN_MEMORY: number;
|
||||
SQLITE_OPEN_READONLY: number;
|
||||
SQLITE_OPEN_READWRITE: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeSqliteStatement {
|
||||
all(...parameters: ReadonlyArray<unknown>): unknown[];
|
||||
columns(): ReadonlyArray<unknown>;
|
||||
iterate(...parameters: ReadonlyArray<unknown>): IterableIterator<unknown>;
|
||||
run(...parameters: ReadonlyArray<unknown>): {
|
||||
changes: number | bigint;
|
||||
lastInsertRowid: number | bigint;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeSqliteDatabase {
|
||||
close(): void;
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): NodeSqliteStatement;
|
||||
}
|
||||
|
||||
interface NodeSqliteModule {
|
||||
DatabaseSync: new (
|
||||
filename: string,
|
||||
options?: {
|
||||
readOnly?: boolean;
|
||||
},
|
||||
) => NodeSqliteDatabase;
|
||||
}
|
||||
|
||||
interface KyselyCompatibleSqliteStatement {
|
||||
readonly reader: boolean;
|
||||
all(parameters: ReadonlyArray<unknown>): unknown[];
|
||||
run(parameters: ReadonlyArray<unknown>): {
|
||||
changes: number | bigint;
|
||||
lastInsertRowid: number | bigint;
|
||||
};
|
||||
iterate(parameters: ReadonlyArray<unknown>): IterableIterator<unknown>;
|
||||
}
|
||||
|
||||
interface KyselyCompatibleSqliteDatabase {
|
||||
close(): void;
|
||||
prepare(sql: string): KyselyCompatibleSqliteStatement;
|
||||
}
|
||||
|
||||
const BUN_SQLITE_MODULE = 'bun:sqlite';
|
||||
const NODE_SQLITE_MODULE = 'node:sqlite';
|
||||
|
||||
function createUnsupportedSqliteRuntimeError(): IdentityDBConfigurationError {
|
||||
return new IdentityDBConfigurationError(
|
||||
'SQLite connections now require a runtime with a built-in SQLite driver. Use Bun for bun:sqlite support, or Node 22+ for node:sqlite support.',
|
||||
);
|
||||
}
|
||||
|
||||
function isBunRuntime(): boolean {
|
||||
return typeof globalThis === 'object' && 'Bun' in globalThis;
|
||||
}
|
||||
|
||||
async function createBunSqliteDatabase(
|
||||
config: SqliteConnectionConfig,
|
||||
bunSqliteModule?: BunSqliteModule,
|
||||
): Promise<KyselyCompatibleSqliteDatabase> {
|
||||
const bunSqlite = bunSqliteModule
|
||||
?? ((await import(BUN_SQLITE_MODULE).catch(() => {
|
||||
throw createUnsupportedSqliteRuntimeError();
|
||||
})) as BunSqliteModule);
|
||||
|
||||
const flags = config.readonly
|
||||
? bunSqlite.constants.SQLITE_OPEN_READONLY
|
||||
: bunSqlite.constants.SQLITE_OPEN_READWRITE
|
||||
| bunSqlite.constants.SQLITE_OPEN_CREATE
|
||||
| (config.filename === ':memory:' ? bunSqlite.constants.SQLITE_OPEN_MEMORY : 0);
|
||||
|
||||
const database = bunSqlite.Database.open(config.filename, flags);
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
return {
|
||||
close() {
|
||||
database.close();
|
||||
},
|
||||
prepare(sql: string): KyselyCompatibleSqliteStatement {
|
||||
const statement = database.prepare(sql);
|
||||
|
||||
return {
|
||||
reader: statement.columnNames.length > 0,
|
||||
all(parameters) {
|
||||
return statement.all(Array.from(parameters));
|
||||
},
|
||||
run(parameters) {
|
||||
return statement.run(Array.from(parameters));
|
||||
},
|
||||
iterate(parameters) {
|
||||
return statement.iterate(Array.from(parameters));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeSqliteDatabase(
|
||||
config: SqliteConnectionConfig,
|
||||
nodeSqlite: NodeSqliteModule,
|
||||
): KyselyCompatibleSqliteDatabase {
|
||||
const database = new nodeSqlite.DatabaseSync(config.filename, {
|
||||
readOnly: config.readonly ?? false,
|
||||
});
|
||||
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
return {
|
||||
close() {
|
||||
database.close();
|
||||
},
|
||||
prepare(sql: string): KyselyCompatibleSqliteStatement {
|
||||
const statement = database.prepare(sql);
|
||||
|
||||
return {
|
||||
reader: statement.columns().length > 0,
|
||||
all(parameters) {
|
||||
return statement.all(...parameters);
|
||||
},
|
||||
run(parameters) {
|
||||
return statement.run(...parameters);
|
||||
},
|
||||
iterate(parameters) {
|
||||
return statement.iterate(...parameters);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createSqliteDatabase(
|
||||
config: SqliteConnectionConfig,
|
||||
): Promise<KyselyCompatibleSqliteDatabase> {
|
||||
if (isBunRuntime()) {
|
||||
return createBunSqliteDatabase(config);
|
||||
}
|
||||
|
||||
const nodeSqlite = await import(NODE_SQLITE_MODULE).catch(() => null);
|
||||
|
||||
if (nodeSqlite) {
|
||||
return createNodeSqliteDatabase(config, nodeSqlite as NodeSqliteModule);
|
||||
}
|
||||
|
||||
throw createUnsupportedSqliteRuntimeError();
|
||||
}
|
||||
|
||||
export async function createDatabase(
|
||||
config: IdentityDBConnectionConfig,
|
||||
): Promise<DatabaseConnection> {
|
||||
switch (config.client) {
|
||||
case 'sqlite': {
|
||||
const sqlite = new Database(config.filename, {
|
||||
readonly: config.readonly ?? false,
|
||||
});
|
||||
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
const sqlite = await createSqliteDatabase(config);
|
||||
|
||||
const db = new Kysely<IdentityDatabaseSchema>({
|
||||
dialect: new SqliteDialect({
|
||||
@@ -66,7 +232,6 @@ export async function createDatabase(
|
||||
db,
|
||||
destroy: async () => {
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import {
|
||||
type AddFactInput,
|
||||
type ConnectedTopic,
|
||||
type Fact,
|
||||
type FactTopic,
|
||||
type FindSimilarFactsInput,
|
||||
type IndexFactEmbeddingsInput,
|
||||
type LinkTopicsInput,
|
||||
type ListTopicsOptions,
|
||||
type ScoredFact,
|
||||
type SearchFactsInput,
|
||||
type Space,
|
||||
type SpaceScopedInput,
|
||||
type Topic,
|
||||
type TopicLookupOptions,
|
||||
type TopicWithFacts,
|
||||
type UpsertSpaceInput,
|
||||
type UpsertTopicInput,
|
||||
type AddFactInput,
|
||||
type LinkTopicsInput,
|
||||
} from '../types/api';
|
||||
import type { IngestStatementOptions } from '../ingestion/types';
|
||||
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
|
||||
import type { IdentityDatabaseSchema } from '../types/database';
|
||||
import type { FactRecord, TopicRecord } from '../types/domain';
|
||||
import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain';
|
||||
import { createDatabase } from '../adapters/dialect';
|
||||
import { IdentityDBError } from './errors';
|
||||
import { initializeSchema } from './migrations';
|
||||
import {
|
||||
canonicalizeTopicName,
|
||||
cosineSimilarity,
|
||||
createContentHash,
|
||||
createId,
|
||||
deserializeEmbedding,
|
||||
mapFactRow,
|
||||
mapTopicRow,
|
||||
normalizeTopicName,
|
||||
nowIsoString,
|
||||
serializeEmbedding,
|
||||
serializeMetadata,
|
||||
} from './utils';
|
||||
import { extractFact } from '../ingestion/extractor';
|
||||
import {
|
||||
findFactRowsConnectingTopicIds,
|
||||
@@ -41,16 +29,37 @@ import {
|
||||
findTopicLinksForFactIds,
|
||||
} from '../queries/facts';
|
||||
import {
|
||||
type DatabaseExecutor,
|
||||
findChildTopicRows,
|
||||
findConnectedTopicRows,
|
||||
findParentTopicRows,
|
||||
findSpaceRowByNormalizedName,
|
||||
findTopicRowByNameOrAlias,
|
||||
findTopicRowByNormalizedAlias,
|
||||
findTopicRowByNormalizedName,
|
||||
listTopicAliasRowsForTopicId,
|
||||
listTopicRows,
|
||||
findChildTopicRows,
|
||||
findParentTopicRows,
|
||||
type DatabaseExecutor,
|
||||
} from '../queries/topics';
|
||||
import { IdentityDBError } from './errors';
|
||||
import { initializeSchema } from './migrations';
|
||||
import {
|
||||
canonicalizeSpaceName,
|
||||
canonicalizeTopicName,
|
||||
cosineSimilarity,
|
||||
createContentHash,
|
||||
createId,
|
||||
deserializeEmbedding,
|
||||
mapFactRow,
|
||||
mapSpaceRow,
|
||||
mapTopicRow,
|
||||
normalizeSpaceName,
|
||||
normalizeTopicName,
|
||||
nowIsoString,
|
||||
serializeEmbedding,
|
||||
serializeMetadata,
|
||||
} from './utils';
|
||||
|
||||
const DEFAULT_SPACE_NAME = 'default';
|
||||
|
||||
export class IdentityDB {
|
||||
private constructor(private readonly connection: DatabaseConnection) {}
|
||||
@@ -68,6 +77,72 @@ export class IdentityDB {
|
||||
await this.connection.destroy();
|
||||
}
|
||||
|
||||
async upsertSpace(input: UpsertSpaceInput): Promise<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> {
|
||||
return this.upsertTopicInExecutor(this.connection.db, input);
|
||||
}
|
||||
@@ -82,6 +157,7 @@ export class IdentityDB {
|
||||
}
|
||||
|
||||
return this.connection.db.transaction().execute(async (trx) => {
|
||||
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
|
||||
const createdAt = nowIsoString();
|
||||
const factId = createId();
|
||||
|
||||
@@ -89,6 +165,7 @@ export class IdentityDB {
|
||||
.insertInto('facts')
|
||||
.values({
|
||||
id: factId,
|
||||
space_id: space.id,
|
||||
statement: input.statement.trim(),
|
||||
summary: input.summary ?? null,
|
||||
source: input.source ?? null,
|
||||
@@ -103,7 +180,11 @@ export class IdentityDB {
|
||||
|
||||
for (let index = 0; index < input.topics.length; index += 1) {
|
||||
const topicInput = input.topics[index]!;
|
||||
const topic = await this.upsertTopicInExecutor(trx, topicInput);
|
||||
this.assertScopedTopicInput(space, topicInput.spaceName);
|
||||
const topic = await this.upsertTopicInExecutor(trx, {
|
||||
...topicInput,
|
||||
spaceName: space.name,
|
||||
});
|
||||
|
||||
await trx
|
||||
.insertInto('fact_topics')
|
||||
@@ -125,6 +206,7 @@ export class IdentityDB {
|
||||
|
||||
return {
|
||||
id: factId,
|
||||
spaceId: space.id,
|
||||
statement: input.statement.trim(),
|
||||
summary: input.summary ?? null,
|
||||
source: input.source ?? null,
|
||||
@@ -137,14 +219,12 @@ export class IdentityDB {
|
||||
});
|
||||
}
|
||||
|
||||
async ingestStatement(
|
||||
statement: string,
|
||||
options: IngestStatementOptions,
|
||||
): Promise<Fact> {
|
||||
async ingestStatement(statement: string, options: IngestStatementOptions): Promise<Fact> {
|
||||
const extracted = await extractFact(statement, options.extractor);
|
||||
const factInput: AddFactInput = {
|
||||
statement: extracted.statement ?? statement,
|
||||
topics: extracted.topics,
|
||||
spaceName: options.spaceName,
|
||||
};
|
||||
|
||||
if (extracted.summary !== undefined) {
|
||||
@@ -170,6 +250,7 @@ export class IdentityDB {
|
||||
topicNames: factInput.topics.map((topic) => topic.name),
|
||||
limit: 1,
|
||||
minimumScore: options.duplicateThreshold ?? 0.97,
|
||||
spaceName: options.spaceName,
|
||||
});
|
||||
|
||||
if (similarFacts[0]) {
|
||||
@@ -180,15 +261,27 @@ export class IdentityDB {
|
||||
const fact = await this.addFact(factInput);
|
||||
|
||||
if (options.embeddingProvider) {
|
||||
await this.indexFactEmbedding(fact.id, { provider: options.embeddingProvider });
|
||||
await this.indexFactEmbedding(fact.id, {
|
||||
provider: options.embeddingProvider,
|
||||
spaceName: options.spaceName,
|
||||
});
|
||||
}
|
||||
|
||||
return fact;
|
||||
}
|
||||
|
||||
async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise<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) {
|
||||
return;
|
||||
}
|
||||
@@ -222,6 +315,13 @@ export class IdentityDB {
|
||||
throw new IdentityDBError(`Fact not found: ${factId}`);
|
||||
}
|
||||
|
||||
if (input.spaceName) {
|
||||
const space = await this.getSpaceForRead(input.spaceName);
|
||||
if (!space || space.id !== factRow.space_id) {
|
||||
throw new IdentityDBError(`Fact ${factId} does not belong to space ${canonicalizeSpaceName(input.spaceName)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const embedding = await input.provider.embed(factRow.statement);
|
||||
this.assertEmbeddingShape(embedding, input.provider.dimensions);
|
||||
|
||||
@@ -236,6 +336,11 @@ export class IdentityDB {
|
||||
return [];
|
||||
}
|
||||
|
||||
const space = await this.getSpaceForRead(input.spaceName);
|
||||
if (input.spaceName && !space) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryEmbedding = await input.provider.embed(queryText);
|
||||
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
|
||||
|
||||
@@ -245,6 +350,7 @@ export class IdentityDB {
|
||||
topicNames: input.topicNames,
|
||||
limit: input.limit,
|
||||
minimumScore: input.minimumScore,
|
||||
spaceId: space?.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +360,11 @@ export class IdentityDB {
|
||||
return [];
|
||||
}
|
||||
|
||||
const space = await this.getSpaceForRead(input.spaceName);
|
||||
if (input.spaceName && !space) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryEmbedding = await input.provider.embed(statement);
|
||||
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
|
||||
|
||||
@@ -263,6 +374,7 @@ export class IdentityDB {
|
||||
topicNames: input.topicNames,
|
||||
limit: input.limit,
|
||||
minimumScore: input.minimumScore,
|
||||
spaceId: space?.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,12 +391,15 @@ export class IdentityDB {
|
||||
}
|
||||
|
||||
await this.connection.db.transaction().execute(async (trx) => {
|
||||
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
|
||||
const parentTopic = await this.upsertTopicInExecutor(trx, {
|
||||
name: input.parentName,
|
||||
granularity: 'abstract',
|
||||
spaceName: space.name,
|
||||
});
|
||||
const childTopic = await this.upsertTopicInExecutor(trx, {
|
||||
name: input.childName,
|
||||
spaceName: space.name,
|
||||
});
|
||||
|
||||
const existing = await trx
|
||||
@@ -309,7 +424,7 @@ export class IdentityDB {
|
||||
});
|
||||
}
|
||||
|
||||
async addTopicAlias(canonicalName: string, alias: string): Promise<void> {
|
||||
async addTopicAlias(canonicalName: string, alias: string, options?: SpaceScopedInput): Promise<void> {
|
||||
const normalizedAlias = normalizeTopicName(alias);
|
||||
|
||||
if (normalizedAlias.length === 0) {
|
||||
@@ -317,18 +432,22 @@ export class IdentityDB {
|
||||
}
|
||||
|
||||
await this.connection.db.transaction().execute(async (trx) => {
|
||||
const canonicalTopic = await this.upsertTopicInExecutor(trx, { name: canonicalName });
|
||||
const space = await this.getOrCreateSpaceInExecutor(trx, options?.spaceName);
|
||||
const canonicalTopic = await this.upsertTopicInExecutor(trx, {
|
||||
name: canonicalName,
|
||||
spaceName: space.name,
|
||||
});
|
||||
|
||||
if (normalizedAlias === canonicalTopic.normalizedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exactTopicMatch = await findTopicRowByNormalizedName(trx, normalizedAlias);
|
||||
const exactTopicMatch = await findTopicRowByNormalizedName(trx, space.id, normalizedAlias);
|
||||
if (exactTopicMatch && exactTopicMatch.id !== canonicalTopic.id) {
|
||||
throw new IdentityDBError('Cannot assign an alias that already belongs to another canonical topic.');
|
||||
}
|
||||
|
||||
const aliasMatch = await findTopicRowByNormalizedAlias(trx, normalizedAlias);
|
||||
const aliasMatch = await findTopicRowByNormalizedAlias(trx, space.id, normalizedAlias);
|
||||
if (aliasMatch) {
|
||||
if (aliasMatch.id !== canonicalTopic.id) {
|
||||
throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.');
|
||||
@@ -341,6 +460,7 @@ export class IdentityDB {
|
||||
.insertInto('topic_aliases')
|
||||
.values({
|
||||
id: createId(),
|
||||
space_id: space.id,
|
||||
topic_id: canonicalTopic.id,
|
||||
alias: canonicalizeTopicName(alias),
|
||||
normalized_alias: normalizedAlias,
|
||||
@@ -352,47 +472,43 @@ export class IdentityDB {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveTopic(name: string): Promise<Topic | null> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
async resolveTopic(name: string, options?: SpaceScopedInput): Promise<Topic | null> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
return topicRow ? mapTopicRow(topicRow) : null;
|
||||
}
|
||||
|
||||
async getTopicAliases(name: string): Promise<string[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicAliases(name: string, options?: SpaceScopedInput): Promise<string[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.id);
|
||||
const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
|
||||
return aliasRows.map((aliasRow) => aliasRow.alias);
|
||||
}
|
||||
|
||||
async getTopicChildren(name: string): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicChildren(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childRows = await findChildTopicRows(this.connection.db, topicRow.id);
|
||||
const childRows = await findChildTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
|
||||
return childRows.map(mapTopicRow);
|
||||
}
|
||||
|
||||
async getTopicParents(name: string): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicParents(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parentRows = await findParentTopicRows(this.connection.db, topicRow.id);
|
||||
const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
|
||||
return parentRows.map(mapTopicRow);
|
||||
}
|
||||
|
||||
async getTopicLineage(name: string): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicLineage(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
@@ -405,8 +521,7 @@ export class IdentityDB {
|
||||
const nextLevelIds: string[] = [];
|
||||
|
||||
for (const currentId of currentLevelIds) {
|
||||
const parentRows = await findParentTopicRows(this.connection.db, currentId);
|
||||
|
||||
const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, currentId);
|
||||
for (const parentRow of parentRows) {
|
||||
if (visitedTopicIds.has(parentRow.id)) {
|
||||
continue;
|
||||
@@ -424,97 +539,97 @@ export class IdentityDB {
|
||||
return lineage;
|
||||
}
|
||||
|
||||
async getTopicFacts(name: string): Promise<Fact[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicFacts(name: string, options?: SpaceScopedInput): Promise<Fact[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.id);
|
||||
return this.hydrateFacts(factRows);
|
||||
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
|
||||
return this.hydrateFacts(factRows, topicRow.space_id);
|
||||
}
|
||||
|
||||
async getTopicFactsLinkedTo(name: string, linkedTopicName: string): Promise<Fact[]> {
|
||||
return this.findFactsConnectingTopics([name, linkedTopicName]);
|
||||
async getTopicFactsLinkedTo(name: string, linkedTopicName: string, options?: SpaceScopedInput): Promise<Fact[]> {
|
||||
return this.findFactsConnectingTopics([name, linkedTopicName], options);
|
||||
}
|
||||
|
||||
async findFactsConnectingTopics(names: string[]): Promise<Fact[]> {
|
||||
async findFactsConnectingTopics(names: string[], options?: SpaceScopedInput): Promise<Fact[]> {
|
||||
if (names.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name)));
|
||||
const space = await this.getSpaceForRead(options?.spaceName);
|
||||
if (options?.spaceName && !space) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name, options?.spaceName)));
|
||||
if (topicRows.some((topicRow) => topicRow === undefined)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicIds = topicRows.map((topicRow) => topicRow!.id);
|
||||
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, topicIds);
|
||||
const spaceId = topicRows[0]!.space_id ?? space?.id;
|
||||
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, spaceId, topicIds);
|
||||
|
||||
return this.hydrateFacts(factRows);
|
||||
return this.hydrateFacts(factRows, spaceId);
|
||||
}
|
||||
|
||||
async getTopicByName(
|
||||
name: string,
|
||||
options: { includeFacts: true },
|
||||
): Promise<TopicWithFacts | null>;
|
||||
async getTopicByName(name: string, options: { includeFacts: true; spaceName?: string }): Promise<TopicWithFacts | null>;
|
||||
async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | null>;
|
||||
async getTopicByName(
|
||||
name: string,
|
||||
options?: TopicLookupOptions,
|
||||
): Promise<Topic | TopicWithFacts | null> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | TopicWithFacts | null> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topic = mapTopicRow(topicRow);
|
||||
|
||||
if (options?.includeFacts) {
|
||||
return {
|
||||
...topic,
|
||||
facts: await this.getTopicFacts(name),
|
||||
facts: await this.getTopicFacts(name, { spaceName: options.spaceName }),
|
||||
};
|
||||
}
|
||||
|
||||
return topic;
|
||||
}
|
||||
|
||||
async listTopics(options: { includeFacts: true; limit?: number }): Promise<TopicWithFacts[]>;
|
||||
async listTopics(options: { includeFacts: true; limit?: number; spaceName?: string }): Promise<TopicWithFacts[]>;
|
||||
async listTopics(options?: ListTopicsOptions): Promise<Topic[]>;
|
||||
async listTopics(
|
||||
options?: ListTopicsOptions,
|
||||
): Promise<Topic[] | TopicWithFacts[]> {
|
||||
const rows = await listTopicRows(this.connection.db, options?.limit);
|
||||
async listTopics(options?: ListTopicsOptions): Promise<Topic[] | TopicWithFacts[]> {
|
||||
const space = await this.getSpaceForRead(options?.spaceName);
|
||||
if (options?.spaceName && !space) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead();
|
||||
if (!spaceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await listTopicRows(this.connection.db, spaceId, options?.limit);
|
||||
if (!options?.includeFacts) {
|
||||
return rows.map(mapTopicRow);
|
||||
}
|
||||
|
||||
const topicsWithFacts: TopicWithFacts[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
topicsWithFacts.push({
|
||||
...mapTopicRow(row),
|
||||
facts: await this.getTopicFacts(row.name),
|
||||
facts: await this.getTopicFacts(row.name, { spaceName: options?.spaceName }),
|
||||
});
|
||||
}
|
||||
|
||||
return topicsWithFacts;
|
||||
}
|
||||
|
||||
async findConnectedTopics(name: string): Promise<ConnectedTopic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name);
|
||||
|
||||
async findConnectedTopics(name: string, options?: SpaceScopedInput): Promise<ConnectedTopic[]> {
|
||||
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
|
||||
if (!topicRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await findConnectedTopicRows(this.connection.db, topicRow.id);
|
||||
|
||||
const rows = await findConnectedTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
|
||||
return rows.map((row) => ({
|
||||
...mapTopicRow(row),
|
||||
sharedFactCount: row.shared_fact_count,
|
||||
@@ -527,18 +642,25 @@ export class IdentityDB {
|
||||
topicNames?: string[] | undefined;
|
||||
limit?: number | undefined;
|
||||
minimumScore?: number | undefined;
|
||||
spaceId?: string | undefined;
|
||||
}): Promise<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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const factRows = topicIds.length > 0
|
||||
? await findFactRowsConnectingTopicIds(this.connection.db, topicIds)
|
||||
? await findFactRowsConnectingTopicIds(this.connection.db, effectiveSpaceId, topicIds)
|
||||
: await this.connection.db
|
||||
.selectFrom('facts')
|
||||
.innerJoin('fact_embeddings', 'fact_embeddings.fact_id', 'facts.id')
|
||||
.selectAll('facts')
|
||||
.where('facts.space_id', '=', effectiveSpaceId)
|
||||
.where('fact_embeddings.model', '=', input.providerModel)
|
||||
.orderBy('facts.created_at', 'asc')
|
||||
.execute();
|
||||
@@ -547,14 +669,14 @@ export class IdentityDB {
|
||||
return [];
|
||||
}
|
||||
|
||||
const embeddingRowsQuery = this.connection.db
|
||||
const embeddingRows = await this.connection.db
|
||||
.selectFrom('fact_embeddings')
|
||||
.selectAll()
|
||||
.where('model', '=', input.providerModel);
|
||||
|
||||
const embeddingRows = factRows.length > 0
|
||||
? await embeddingRowsQuery.where('fact_id', 'in', factRows.map((factRow) => factRow.id)).execute()
|
||||
: [];
|
||||
.innerJoin('facts', 'facts.id', 'fact_embeddings.fact_id')
|
||||
.selectAll('fact_embeddings')
|
||||
.where('facts.space_id', '=', effectiveSpaceId)
|
||||
.where('fact_embeddings.model', '=', input.providerModel)
|
||||
.where('fact_embeddings.fact_id', 'in', factRows.map((factRow) => factRow.id))
|
||||
.execute();
|
||||
|
||||
const embeddingsByFactId = new Map(
|
||||
embeddingRows.map((embeddingRow) => [embeddingRow.fact_id, deserializeEmbedding(embeddingRow.embedding)]),
|
||||
@@ -578,7 +700,7 @@ export class IdentityDB {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow));
|
||||
const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow), effectiveSpaceId);
|
||||
const factsById = new Map(hydratedFacts.map((fact) => [fact.id, fact]));
|
||||
|
||||
return scoredRows.map((entry) => ({
|
||||
@@ -587,12 +709,12 @@ export class IdentityDB {
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveTopicIds(topicNames?: string[]): Promise<string[] | null> {
|
||||
private async resolveTopicIds(topicNames: string[] | undefined, spaceId: string): Promise<string[] | null> {
|
||||
if (!topicNames || topicNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRow(topicName)));
|
||||
const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRowInSpaceId(topicName, spaceId)));
|
||||
if (topicRows.some((topicRow) => !topicRow)) {
|
||||
return null;
|
||||
}
|
||||
@@ -637,30 +759,28 @@ export class IdentityDB {
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertTopicInExecutor(
|
||||
executor: DatabaseExecutor,
|
||||
input: UpsertTopicInput,
|
||||
): Promise<Topic> {
|
||||
private async upsertTopicInExecutor(executor: DatabaseExecutor, input: UpsertTopicInput): Promise<Topic> {
|
||||
const normalizedName = normalizeTopicName(input.name);
|
||||
|
||||
if (normalizedName.length === 0) {
|
||||
throw new IdentityDBError('Topic name cannot be empty.');
|
||||
}
|
||||
|
||||
const existing = await findTopicRowByNormalizedName(executor, normalizedName);
|
||||
const space = await this.getOrCreateSpaceInExecutor(executor, input.spaceName);
|
||||
const existing = await findTopicRowByNormalizedName(executor, space.id, normalizedName);
|
||||
const now = nowIsoString();
|
||||
|
||||
if (existing) {
|
||||
return this.updateTopicRowInExecutor(executor, existing, input, now, true);
|
||||
}
|
||||
|
||||
const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName);
|
||||
const aliasedTopic = await findTopicRowByNormalizedAlias(executor, space.id, normalizedName);
|
||||
if (aliasedTopic) {
|
||||
return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false);
|
||||
}
|
||||
|
||||
const createdRow: TopicRecord = {
|
||||
id: createId(),
|
||||
space_id: space.id,
|
||||
name: canonicalizeTopicName(input.name),
|
||||
normalized_name: normalizedName,
|
||||
category: input.category ?? 'custom',
|
||||
@@ -672,7 +792,6 @@ export class IdentityDB {
|
||||
};
|
||||
|
||||
await executor.insertInto('topics').values(createdRow).execute();
|
||||
|
||||
return mapTopicRow(createdRow);
|
||||
}
|
||||
|
||||
@@ -705,22 +824,39 @@ export class IdentityDB {
|
||||
return mapTopicRow(updated);
|
||||
}
|
||||
|
||||
private async getRequiredTopicRow(name: string): Promise<TopicRecord | undefined> {
|
||||
const normalizedName = normalizeTopicName(name);
|
||||
private async getRequiredTopicRow(name: string, spaceName?: string): Promise<TopicRecord | undefined> {
|
||||
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) {
|
||||
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 topicLinks = await findTopicLinksForFactIds(this.connection.db, factIds);
|
||||
const topicLinks = await findTopicLinksForFactIds(this.connection.db, effectiveSpaceId, factIds);
|
||||
|
||||
const topicsByFactId = new Map<string, FactTopic[]>();
|
||||
|
||||
for (const topicLink of topicLinks) {
|
||||
const topics = topicsByFactId.get(topicLink.fact_id) ?? [];
|
||||
topics.push({
|
||||
@@ -733,4 +869,57 @@ export class IdentityDB {
|
||||
|
||||
return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? []));
|
||||
}
|
||||
|
||||
private async getOrCreateSpaceInExecutor(executor: DatabaseExecutor, requestedSpaceName?: string): Promise<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}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FACTS_TABLE,
|
||||
FACT_EMBEDDINGS_TABLE,
|
||||
FACT_TOPICS_TABLE,
|
||||
SPACES_TABLE,
|
||||
TOPIC_ALIASES_TABLE,
|
||||
TOPIC_RELATIONS_TABLE,
|
||||
TOPICS_TABLE,
|
||||
@@ -14,23 +15,42 @@ export async function initializeSchema(
|
||||
db: Kysely<IdentityDatabaseSchema>,
|
||||
): Promise<void> {
|
||||
await db.schema
|
||||
.createTable(TOPICS_TABLE)
|
||||
.createTable(SPACES_TABLE)
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (column) => column.primaryKey())
|
||||
.addColumn('name', 'text', (column) => column.notNull())
|
||||
.addColumn('normalized_name', 'text', (column) => column.notNull().unique())
|
||||
.addColumn('category', 'text', (column) => column.notNull())
|
||||
.addColumn('granularity', 'text', (column) => column.notNull())
|
||||
.addColumn('description', 'text')
|
||||
.addColumn('metadata', 'text')
|
||||
.addColumn('created_at', 'text', (column) => column.notNull())
|
||||
.addColumn('updated_at', 'text', (column) => column.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable(TOPICS_TABLE)
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (column) => column.primaryKey())
|
||||
.addColumn('space_id', 'text', (column) =>
|
||||
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
|
||||
)
|
||||
.addColumn('name', 'text', (column) => column.notNull())
|
||||
.addColumn('normalized_name', 'text', (column) => column.notNull())
|
||||
.addColumn('category', 'text', (column) => column.notNull())
|
||||
.addColumn('granularity', 'text', (column) => column.notNull())
|
||||
.addColumn('description', 'text')
|
||||
.addColumn('metadata', 'text')
|
||||
.addColumn('created_at', 'text', (column) => column.notNull())
|
||||
.addColumn('updated_at', 'text', (column) => column.notNull())
|
||||
.addUniqueConstraint('topics_space_normalized_name_key', ['space_id', 'normalized_name'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable(FACTS_TABLE)
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (column) => column.primaryKey())
|
||||
.addColumn('space_id', 'text', (column) =>
|
||||
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
|
||||
)
|
||||
.addColumn('statement', 'text', (column) => column.notNull())
|
||||
.addColumn('summary', 'text')
|
||||
.addColumn('source', 'text')
|
||||
@@ -88,14 +108,32 @@ export async function initializeSchema(
|
||||
.createTable(TOPIC_ALIASES_TABLE)
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (column) => column.primaryKey())
|
||||
.addColumn('space_id', 'text', (column) =>
|
||||
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
|
||||
)
|
||||
.addColumn('topic_id', 'text', (column) =>
|
||||
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
|
||||
)
|
||||
.addColumn('alias', 'text', (column) => column.notNull())
|
||||
.addColumn('normalized_alias', 'text', (column) => column.notNull().unique())
|
||||
.addColumn('normalized_alias', 'text', (column) => column.notNull())
|
||||
.addColumn('is_primary', 'integer', (column) => column.notNull())
|
||||
.addColumn('created_at', 'text', (column) => column.notNull())
|
||||
.addColumn('updated_at', 'text', (column) => column.notNull())
|
||||
.addUniqueConstraint('topic_aliases_space_normalized_alias_key', ['space_id', 'normalized_alias'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('topics_space_id_idx')
|
||||
.ifNotExists()
|
||||
.on(TOPICS_TABLE)
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('facts_space_id_idx')
|
||||
.ifNotExists()
|
||||
.on(FACTS_TABLE)
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
@@ -133,6 +171,13 @@ export async function initializeSchema(
|
||||
.column('child_topic_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('topic_aliases_space_id_idx')
|
||||
.ifNotExists()
|
||||
.on(TOPIC_ALIASES_TABLE)
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('topic_aliases_topic_id_idx')
|
||||
.ifNotExists()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const SPACES_TABLE = 'spaces';
|
||||
export const TOPICS_TABLE = 'topics';
|
||||
export const FACTS_TABLE = 'facts';
|
||||
export const FACT_TOPICS_TABLE = 'fact_topics';
|
||||
@@ -5,8 +6,19 @@ export const TOPIC_RELATIONS_TABLE = 'topic_relations';
|
||||
export const TOPIC_ALIASES_TABLE = 'topic_aliases';
|
||||
export const FACT_EMBEDDINGS_TABLE = 'fact_embeddings';
|
||||
|
||||
export const SPACE_COLUMNS = [
|
||||
'id',
|
||||
'name',
|
||||
'normalized_name',
|
||||
'description',
|
||||
'metadata',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
] as const;
|
||||
|
||||
export const TOPIC_COLUMNS = [
|
||||
'id',
|
||||
'space_id',
|
||||
'name',
|
||||
'normalized_name',
|
||||
'category',
|
||||
@@ -19,6 +31,7 @@ export const TOPIC_COLUMNS = [
|
||||
|
||||
export const FACT_COLUMNS = [
|
||||
'id',
|
||||
'space_id',
|
||||
'statement',
|
||||
'summary',
|
||||
'source',
|
||||
@@ -45,6 +58,7 @@ export const TOPIC_RELATION_COLUMNS = [
|
||||
|
||||
export const TOPIC_ALIAS_COLUMNS = [
|
||||
'id',
|
||||
'space_id',
|
||||
'topic_id',
|
||||
'alias',
|
||||
'normalized_alias',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Fact, FactTopic, Topic } from '../types/api';
|
||||
import type { FactRecord, TopicRecord } from '../types/domain';
|
||||
import type { Fact, FactTopic, Space, Topic } from '../types/api';
|
||||
import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain';
|
||||
|
||||
export function normalizeTopicName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
@@ -11,6 +11,14 @@ export function canonicalizeTopicName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function normalizeSpaceName(name: string): string {
|
||||
return normalizeTopicName(name);
|
||||
}
|
||||
|
||||
export function canonicalizeSpaceName(name: string): string {
|
||||
return canonicalizeTopicName(name);
|
||||
}
|
||||
|
||||
export function nowIsoString(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -71,9 +79,22 @@ export function cosineSimilarity(left: number[], right: number[]): number {
|
||||
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
|
||||
}
|
||||
|
||||
export function mapSpaceRow(record: SpaceRecord): Space {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
normalizedName: record.normalized_name,
|
||||
description: record.description,
|
||||
metadata: deserializeMetadata(record.metadata) as Space['metadata'],
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapTopicRow(record: TopicRecord): Topic {
|
||||
return {
|
||||
id: record.id,
|
||||
spaceId: record.space_id,
|
||||
name: record.name,
|
||||
normalizedName: record.normalized_name,
|
||||
category: record.category,
|
||||
@@ -88,6 +109,7 @@ export function mapTopicRow(record: TopicRecord): Topic {
|
||||
export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact {
|
||||
return {
|
||||
id: record.id,
|
||||
spaceId: record.space_id,
|
||||
statement: record.statement,
|
||||
summary: record.summary,
|
||||
source: record.source,
|
||||
|
||||
@@ -31,4 +31,5 @@ export interface IngestStatementOptions {
|
||||
extractor: FactExtractor;
|
||||
embeddingProvider?: EmbeddingProvider;
|
||||
duplicateThreshold?: number;
|
||||
spaceName?: string;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ export interface FactTopicJoinRow extends TopicRecord {
|
||||
|
||||
export async function findFactRowsForTopicId(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
topicId: string,
|
||||
): Promise<FactRecord[]> {
|
||||
return executor
|
||||
.selectFrom('facts')
|
||||
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
|
||||
.selectAll('facts')
|
||||
.where('facts.space_id', '=', spaceId)
|
||||
.where('fact_topics.topic_id', '=', topicId)
|
||||
.orderBy('facts.created_at', 'asc')
|
||||
.execute();
|
||||
@@ -26,6 +28,7 @@ export async function findFactRowsForTopicId(
|
||||
|
||||
export async function findFactRowsConnectingTopicIds(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
topicIds: string[],
|
||||
): Promise<FactRecord[]> {
|
||||
if (topicIds.length === 0) {
|
||||
@@ -36,6 +39,7 @@ export async function findFactRowsConnectingTopicIds(
|
||||
.selectFrom('facts')
|
||||
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
|
||||
.selectAll('facts')
|
||||
.where('facts.space_id', '=', spaceId)
|
||||
.where('fact_topics.topic_id', 'in', topicIds)
|
||||
.groupBy('facts.id')
|
||||
.having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length)
|
||||
@@ -45,6 +49,7 @@ export async function findFactRowsConnectingTopicIds(
|
||||
|
||||
export async function findTopicLinksForFactIds(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
factIds: string[],
|
||||
): Promise<FactTopicJoinRow[]> {
|
||||
if (factIds.length === 0) {
|
||||
@@ -60,6 +65,7 @@ export async function findTopicLinksForFactIds(
|
||||
'fact_topics.role as role',
|
||||
'fact_topics.position as position',
|
||||
])
|
||||
.where('topics.space_id', '=', spaceId)
|
||||
.where('fact_topics.fact_id', 'in', factIds)
|
||||
.orderBy('fact_topics.position', 'asc')
|
||||
.execute() as Promise<FactTopicJoinRow[]>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Kysely, Transaction } from 'kysely';
|
||||
|
||||
import type { IdentityDatabaseSchema } from '../types/database';
|
||||
import type { TopicAliasRecord, TopicRecord } from '../types/domain';
|
||||
import type { SpaceRecord, TopicAliasRecord, TopicRecord } from '../types/domain';
|
||||
|
||||
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
|
||||
|
||||
@@ -9,48 +9,66 @@ export interface ConnectedTopicRow extends TopicRecord {
|
||||
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(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
normalizedName: string,
|
||||
): Promise<TopicRecord | undefined> {
|
||||
return executor
|
||||
.selectFrom('topics')
|
||||
.selectAll()
|
||||
.where('space_id', '=', spaceId)
|
||||
.where('normalized_name', '=', normalizedName)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function findTopicRowByNormalizedAlias(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
normalizedAlias: string,
|
||||
): Promise<TopicRecord | undefined> {
|
||||
return executor
|
||||
.selectFrom('topic_aliases')
|
||||
.innerJoin('topics', 'topics.id', 'topic_aliases.topic_id')
|
||||
.selectAll('topics')
|
||||
.where('topic_aliases.space_id', '=', spaceId)
|
||||
.where('topic_aliases.normalized_alias', '=', normalizedAlias)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function findTopicRowByNameOrAlias(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
normalizedName: string,
|
||||
): Promise<TopicRecord | undefined> {
|
||||
const directMatch = await findTopicRowByNormalizedName(executor, normalizedName);
|
||||
const directMatch = await findTopicRowByNormalizedName(executor, spaceId, normalizedName);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
return findTopicRowByNormalizedAlias(executor, normalizedName);
|
||||
return findTopicRowByNormalizedAlias(executor, spaceId, normalizedName);
|
||||
}
|
||||
|
||||
export async function listTopicAliasRowsForTopicId(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
topicId: string,
|
||||
): Promise<TopicAliasRecord[]> {
|
||||
return executor
|
||||
.selectFrom('topic_aliases')
|
||||
.selectAll()
|
||||
.where('space_id', '=', spaceId)
|
||||
.where('topic_id', '=', topicId)
|
||||
.orderBy('is_primary', 'desc')
|
||||
.orderBy('normalized_alias', 'asc')
|
||||
@@ -59,9 +77,14 @@ export async function listTopicAliasRowsForTopicId(
|
||||
|
||||
export async function listTopicRows(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
limit?: number,
|
||||
): Promise<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) {
|
||||
query = query.limit(limit);
|
||||
@@ -72,14 +95,18 @@ export async function listTopicRows(
|
||||
|
||||
export async function findConnectedTopicRows(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
topicId: string,
|
||||
): Promise<ConnectedTopicRow[]> {
|
||||
return executor
|
||||
.selectFrom('fact_topics as source_link')
|
||||
.innerJoin('facts', 'facts.id', 'source_link.fact_id')
|
||||
.innerJoin('fact_topics as related_link', 'related_link.fact_id', 'source_link.fact_id')
|
||||
.innerJoin('topics', 'topics.id', 'related_link.topic_id')
|
||||
.selectAll('topics')
|
||||
.select((eb) => eb.fn.count<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)
|
||||
.whereRef('related_link.topic_id', '!=', 'source_link.topic_id')
|
||||
.groupBy('topics.id')
|
||||
@@ -90,12 +117,14 @@ export async function findConnectedTopicRows(
|
||||
|
||||
export async function findChildTopicRows(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
parentTopicId: string,
|
||||
): Promise<TopicRecord[]> {
|
||||
return executor
|
||||
.selectFrom('topic_relations')
|
||||
.innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id')
|
||||
.selectAll('topics')
|
||||
.where('topics.space_id', '=', spaceId)
|
||||
.where('topic_relations.parent_topic_id', '=', parentTopicId)
|
||||
.where('topic_relations.relation', '=', 'parent_of')
|
||||
.orderBy('topics.normalized_name', 'asc')
|
||||
@@ -104,12 +133,14 @@ export async function findChildTopicRows(
|
||||
|
||||
export async function findParentTopicRows(
|
||||
executor: DatabaseExecutor,
|
||||
spaceId: string,
|
||||
childTopicId: string,
|
||||
): Promise<TopicRecord[]> {
|
||||
return executor
|
||||
.selectFrom('topic_relations')
|
||||
.innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id')
|
||||
.selectAll('topics')
|
||||
.where('topics.space_id', '=', spaceId)
|
||||
.where('topic_relations.child_topic_id', '=', childTopicId)
|
||||
.where('topic_relations.relation', '=', 'parent_of')
|
||||
.orderBy('topics.normalized_name', 'asc')
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import type { JsonValue, TopicCategory, TopicGranularity } from './domain';
|
||||
|
||||
export interface UpsertTopicInput {
|
||||
export interface SpaceScopedInput {
|
||||
spaceName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface UpsertSpaceInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
metadata?: JsonValue | null;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
description: string | null;
|
||||
metadata: JsonValue | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpsertTopicInput extends SpaceScopedInput {
|
||||
name: string;
|
||||
category?: TopicCategory;
|
||||
granularity?: TopicGranularity;
|
||||
@@ -12,7 +32,7 @@ export interface TopicLinkInput extends UpsertTopicInput {
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface AddFactInput {
|
||||
export interface AddFactInput extends SpaceScopedInput {
|
||||
statement: string;
|
||||
summary?: string | null;
|
||||
source?: string | null;
|
||||
@@ -21,13 +41,14 @@ export interface AddFactInput {
|
||||
topics: TopicLinkInput[];
|
||||
}
|
||||
|
||||
export interface LinkTopicsInput {
|
||||
export interface LinkTopicsInput extends SpaceScopedInput {
|
||||
parentName: string;
|
||||
childName: string;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
category: TopicCategory;
|
||||
@@ -45,6 +66,7 @@ export interface FactTopic extends Topic {
|
||||
|
||||
export interface Fact {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
statement: string;
|
||||
summary: string | null;
|
||||
source: string | null;
|
||||
@@ -63,11 +85,11 @@ export interface ConnectedTopic extends Topic {
|
||||
sharedFactCount: number;
|
||||
}
|
||||
|
||||
export interface TopicLookupOptions {
|
||||
export interface TopicLookupOptions extends SpaceScopedInput {
|
||||
includeFacts?: boolean;
|
||||
}
|
||||
|
||||
export interface ListTopicsOptions {
|
||||
export interface ListTopicsOptions extends SpaceScopedInput {
|
||||
includeFacts?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -79,11 +101,11 @@ export interface EmbeddingProvider {
|
||||
embedMany?(inputs: string[]): Promise<number[][]>;
|
||||
}
|
||||
|
||||
export interface IndexFactEmbeddingsInput {
|
||||
export interface IndexFactEmbeddingsInput extends SpaceScopedInput {
|
||||
provider: EmbeddingProvider;
|
||||
}
|
||||
|
||||
export interface SearchFactsInput {
|
||||
export interface SearchFactsInput extends SpaceScopedInput {
|
||||
query: string;
|
||||
provider: EmbeddingProvider;
|
||||
topicNames?: string[];
|
||||
@@ -91,7 +113,7 @@ export interface SearchFactsInput {
|
||||
minimumScore?: number;
|
||||
}
|
||||
|
||||
export interface FindSimilarFactsInput {
|
||||
export interface FindSimilarFactsInput extends SpaceScopedInput {
|
||||
statement: string;
|
||||
provider: EmbeddingProvider;
|
||||
topicNames?: string[];
|
||||
|
||||
@@ -2,12 +2,14 @@ import type {
|
||||
FactEmbeddingRecord,
|
||||
FactRecord,
|
||||
FactTopicRecord,
|
||||
SpaceRecord,
|
||||
TopicAliasRecord,
|
||||
TopicRecord,
|
||||
TopicRelationRecord,
|
||||
} from './domain';
|
||||
|
||||
export interface IdentityDatabaseSchema {
|
||||
spaces: SpaceRecord;
|
||||
topics: TopicRecord;
|
||||
facts: FactRecord;
|
||||
fact_topics: FactTopicRecord;
|
||||
|
||||
@@ -5,8 +5,19 @@ export type TopicGranularity = 'abstract' | 'concrete' | 'mixed';
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface SpaceRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
normalized_name: string;
|
||||
description: string | null;
|
||||
metadata: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TopicRecord {
|
||||
id: string;
|
||||
space_id: string;
|
||||
name: string;
|
||||
normalized_name: string;
|
||||
category: TopicCategory;
|
||||
@@ -19,6 +30,7 @@ export interface TopicRecord {
|
||||
|
||||
export interface FactRecord {
|
||||
id: string;
|
||||
space_id: string;
|
||||
statement: string;
|
||||
summary: string | null;
|
||||
source: string | null;
|
||||
@@ -45,6 +57,7 @@ export interface TopicRelationRecord {
|
||||
|
||||
export interface TopicAliasRecord {
|
||||
id: string;
|
||||
space_id: string;
|
||||
topic_id: string;
|
||||
alias: string;
|
||||
normalized_alias: string;
|
||||
|
||||
43
tests/bun-runtime.test.ts
Normal file
43
tests/bun-runtime.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe('Bun runtime compatibility', () => {
|
||||
it('connects to sqlite from the package in Bun', async () => {
|
||||
const script = [
|
||||
"import { IdentityDB } from './src/index.ts';",
|
||||
"const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });",
|
||||
'await db.initialize();',
|
||||
"console.log('bun-sqlite-ok');",
|
||||
'await db.close();',
|
||||
].join('\n');
|
||||
|
||||
const result = await execFileAsync('bun', ['--eval', script], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
expect(result.stdout).toContain('bun-sqlite-ok');
|
||||
});
|
||||
|
||||
it('supports writes and reads through the Bun sqlite adapter path', async () => {
|
||||
const script = [
|
||||
"import { IdentityDB } from './src/index.ts';",
|
||||
"const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });",
|
||||
'await db.initialize();',
|
||||
"await db.addFact({ statement: 'Bun supports sqlite.', topics: [{ name: 'Bun', category: 'entity', granularity: 'concrete' }, { name: 'sqlite', category: 'concept', granularity: 'concrete' }] });",
|
||||
"const facts = await db.getTopicFacts('Bun');",
|
||||
"if (facts.length !== 1 || facts[0]?.statement !== 'Bun supports sqlite.') throw new Error('bun sqlite CRUD failed');",
|
||||
"console.log('bun-sqlite-crud-ok');",
|
||||
'await db.close();',
|
||||
].join('\n');
|
||||
|
||||
const result = await execFileAsync('bun', ['--eval', script], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
expect(result.stdout).toContain('bun-sqlite-crud-ok');
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user