From 107964c4037fe8c2d7e0daf9703a9209a83ada4f Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:39:21 -0500 Subject: [PATCH 01/10] chore: ignore .worktrees/ --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f128922..3f8a790 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ claude-export/ !ui/public/opencontext-logo.png !ui/public/dark-logo-aviskaar.png 0532b8293bb107be5c0c20f3e2980f09107f44de9a58414157f5bed3f7ef0d19*/ + +.worktrees/ From 1a52f64a37a423bd3eb947e680ebb0487e56cc82 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:42:19 -0500 Subject: [PATCH 02/10] chore: setup npm workspaces for core and provider-sdk --- package-lock.json | 36 ++++++++++++++++++++++++++--- package.json | 3 +++ packages/core/package.json | 16 +++++++++++++ packages/core/src/index.ts | 1 + packages/core/tests/smoke.test.ts | 8 +++++++ packages/core/tsconfig.json | 14 +++++++++++ packages/provider-sdk/package.json | 18 +++++++++++++++ packages/provider-sdk/tsconfig.json | 14 +++++++++++ 8 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tests/smoke.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/provider-sdk/package.json create mode 100644 packages/provider-sdk/tsconfig.json diff --git a/package-lock.json b/package-lock.json index c61b835..d342285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "opencontext", "version": "0.0.1", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "adm-zip": "^0.6.0", @@ -44,11 +47,11 @@ "@google-cloud/cloud-sql-connector": "^1.0.0", "@google-cloud/firestore": "^7.0.0", "@libsql/client": "^0.15.0", - "mongodb": "^6.0.0", - "mssql": "^11.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mssql": "^11.0.0 || ^12.0.0", "mysql2": "^3.0.0", "pg": "^8.0.0", - "redis": "^5.0.0", + "redis": "^5.0.0 || ^6.0.0", "surrealdb": "^2.0.0" }, "peerDependenciesMeta": { @@ -685,6 +688,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opencontext/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@opencontext/provider-sdk": { + "resolved": "packages/provider-sdk", + "link": true + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -3802,6 +3813,25 @@ "peerDependencies": { "zod": "^3.25 || ^4" } + }, + "packages/core": { + "name": "@opencontext/core", + "version": "2.0.0", + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.8" + } + }, + "packages/provider-sdk": { + "name": "@opencontext/provider-sdk", + "version": "2.0.0", + "dependencies": { + "@opencontext/core": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.8" + } } } } diff --git a/package.json b/package.json index 0f6007b..17bca53 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Convert ChatGPT conversation exports to Claude-compatible format", "main": "dist/index.js", "type": "module", + "workspaces": [ + "packages/*" + ], "bin": { "opencontext": "./dist/index.js", "opencontext-mcp": "./dist/mcp/index.js" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..267cfaa --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,16 @@ +{ + "name": "@opencontext/core", + "version": "2.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..e74e7b3 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export const VERSION = '2.0.0'; diff --git a/packages/core/tests/smoke.test.ts b/packages/core/tests/smoke.test.ts new file mode 100644 index 0000000..b691137 --- /dev/null +++ b/packages/core/tests/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; + +describe('@opencontext/core smoke test', () => { + it('resolves the core module root', async () => { + const core = await import('../src/index.js'); + expect(core.VERSION).toBe('2.0.0'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..983f4c6 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/provider-sdk/package.json b/packages/provider-sdk/package.json new file mode 100644 index 0000000..e698075 --- /dev/null +++ b/packages/provider-sdk/package.json @@ -0,0 +1,18 @@ +{ + "name": "@opencontext/provider-sdk", + "version": "2.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@opencontext/core": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/provider-sdk/tsconfig.json b/packages/provider-sdk/tsconfig.json new file mode 100644 index 0000000..983f4c6 --- /dev/null +++ b/packages/provider-sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} From 988fe71230445390ff1b6c24e179ec79d21253ee Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:45:11 -0500 Subject: [PATCH 03/10] feat(core): implement CanonicalContext model, ULID identity and SHA-256 hashing --- packages/core/src/identity/hash.ts | 18 ++++++++ packages/core/src/identity/index.ts | 2 + packages/core/src/identity/ulid.ts | 41 ++++++++++++++++++ packages/core/src/index.ts | 3 ++ packages/core/src/model/factory.ts | 52 +++++++++++++++++++++++ packages/core/src/model/types.ts | 63 ++++++++++++++++++++++++++++ packages/core/tests/identity.test.ts | 35 ++++++++++++++++ packages/core/tests/model.test.ts | 57 +++++++++++++++++++++++++ 8 files changed, 271 insertions(+) create mode 100644 packages/core/src/identity/hash.ts create mode 100644 packages/core/src/identity/index.ts create mode 100644 packages/core/src/identity/ulid.ts create mode 100644 packages/core/src/model/factory.ts create mode 100644 packages/core/src/model/types.ts create mode 100644 packages/core/tests/identity.test.ts create mode 100644 packages/core/tests/model.test.ts diff --git a/packages/core/src/identity/hash.ts b/packages/core/src/identity/hash.ts new file mode 100644 index 0000000..4d1f0f1 --- /dev/null +++ b/packages/core/src/identity/hash.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; + +function canonicalizeJson(obj: unknown): string { + if (obj === null || typeof obj !== 'object') { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + return `[${obj.map(canonicalizeJson).join(',')}]`; + } + const keys = Object.keys(obj as Record).sort(); + const pairs = keys.map((k) => `${JSON.stringify(k)}:${canonicalizeJson((obj as Record)[k])}`); + return `{${pairs.join(',')}}`; +} + +export function computeContentHash(content: string | Record): string { + const serialized = typeof content === 'string' ? content : canonicalizeJson(content); + return createHash('sha256').update(serialized, 'utf8').digest('hex'); +} diff --git a/packages/core/src/identity/index.ts b/packages/core/src/identity/index.ts new file mode 100644 index 0000000..4224ece --- /dev/null +++ b/packages/core/src/identity/index.ts @@ -0,0 +1,2 @@ +export * from './ulid.js'; +export * from './hash.js'; diff --git a/packages/core/src/identity/ulid.ts b/packages/core/src/identity/ulid.ts new file mode 100644 index 0000000..a9f58b5 --- /dev/null +++ b/packages/core/src/identity/ulid.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'node:crypto'; + +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +let lastTime = 0; +const lastRandom: number[] = new Array(16).fill(0); + +export function generateUlid(seedTime: number = Date.now()): string { + let time = seedTime; + if (time <= lastTime) { + time = lastTime; + // Increment last random for strict monotonicity within same millisecond + for (let i = 15; i >= 0; i--) { + if (lastRandom[i] < 31) { + lastRandom[i]++; + break; + } + lastRandom[i] = 0; + } + } else { + lastTime = time; + const buf = randomBytes(16); + for (let i = 0; i < 16; i++) { + lastRandom[i] = buf[i] % 32; + } + } + + // 10 chars for 48-bit timestamp + let timeStr = ''; + for (let i = 9; i >= 0; i--) { + timeStr = ENCODING[time % 32] + timeStr; + time = Math.floor(time / 32); + } + + // 16 chars for 80-bit randomness + let randStr = ''; + for (let i = 0; i < 16; i++) { + randStr += ENCODING[lastRandom[i]]; + } + + return timeStr + randStr; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e74e7b3..6c3faff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,4 @@ +export * from './model/types.js'; +export * from './model/factory.js'; +export * from './identity/index.js'; export const VERSION = '2.0.0'; diff --git a/packages/core/src/model/factory.ts b/packages/core/src/model/factory.ts new file mode 100644 index 0000000..3018788 --- /dev/null +++ b/packages/core/src/model/factory.ts @@ -0,0 +1,52 @@ +import { CanonicalContext, ContextType, ScopeId, NamespaceId } from './types.js'; +import { generateUlid } from '../identity/ulid.js'; +import { computeContentHash } from '../identity/hash.js'; + +export interface CreateContextOptions { + id?: string; + namespace?: NamespaceId; + scope?: ScopeId; + type?: ContextType; + content: { + text?: string; + structured?: Record; + mediaType?: string; + embedding?: number[]; + }; + metadata?: Record; + actor?: 'user' | 'agent' | 'system' | 'integration'; + agentId?: string; + sourceUri?: string; + relationships?: CanonicalContext['relationships']; + expiresAt?: string; +} + +export function createCanonicalContext(opts: CreateContextOptions): CanonicalContext { + const now = new Date().toISOString(); + const hashInput = opts.content.text ?? opts.content.structured ?? {}; + + return { + id: opts.id ?? generateUlid(), + namespace: opts.namespace ?? 'default', + scope: opts.scope ?? 'global', + type: opts.type ?? 'fact', + content: opts.content, + metadata: opts.metadata ?? {}, + provenance: { + actor: opts.actor ?? 'user', + agentId: opts.agentId, + sourceUri: opts.sourceUri, + contentHash: computeContentHash(hashInput), + }, + relationships: opts.relationships ?? [], + timestamps: { + createdAt: now, + updatedAt: now, + expiresAt: opts.expiresAt, + }, + version: { + revision: 1, + }, + lifecycle: 'active', + }; +} diff --git a/packages/core/src/model/types.ts b/packages/core/src/model/types.ts new file mode 100644 index 0000000..8d0912e --- /dev/null +++ b/packages/core/src/model/types.ts @@ -0,0 +1,63 @@ +export type ContextId = string; +export type NamespaceId = string; +export type ScopeId = string; + +export type ContextType = + | 'message' + | 'fact' + | 'decision' + | 'constraint' + | 'preference' + | 'artifact' + | 'observation' + | 'tool_result' + | 'summary' + | 'checkpoint' + | (string & {}); + +export type LifecycleState = 'active' | 'archived' | 'deprecated' | 'soft_deleted' | 'pinned'; + +export interface RelationshipEdge { + targetId: ContextId; + relation: 'supersedes' | 'derived_from' | 'references' | 'child_of' | 'caused_by' | string; + metadata?: Record; +} + +export interface ContextProvenance { + actor: 'user' | 'agent' | 'system' | 'integration'; + agentId?: string; + model?: string; + sourceUri?: string; + signature?: string; + contentHash: string; + derivationChain?: ContextId[]; +} + +export interface ContextTimestamps { + createdAt: string; + updatedAt: string; + accessedAt?: string; + expiresAt?: string; +} + +export interface CanonicalContext { + id: ContextId; + namespace: NamespaceId; + scope: ScopeId; + type: ContextType; + content: { + text?: string; + structured?: Record; + mediaType?: string; + embedding?: number[]; + }; + metadata: Record; + provenance: ContextProvenance; + relationships: RelationshipEdge[]; + timestamps: ContextTimestamps; + version: { + revision: number; + clock?: Record; + }; + lifecycle: LifecycleState; +} diff --git a/packages/core/tests/identity.test.ts b/packages/core/tests/identity.test.ts new file mode 100644 index 0000000..74085aa --- /dev/null +++ b/packages/core/tests/identity.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { generateUlid, computeContentHash } from '../src/identity/index.js'; + +describe('Identity & Hashing', () => { + it('generates a valid 26-character monotonic ULID', () => { + const id1 = generateUlid(); + const id2 = generateUlid(); + expect(id1).toHaveLength(26); + expect(id2).toHaveLength(26); + expect(id1 < id2).toBe(true); + }); + + it('generates strictly monotonic ULIDs within the same millisecond', () => { + const now = Date.now(); + const ids = Array.from({ length: 50 }, () => generateUlid(now)); + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1] < ids[i]).toBe(true); + } + }); + + it('computes deterministic SHA-256 hash for strings and structured objects', () => { + const textHash = computeContentHash('hello world'); + expect(textHash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'); + + const objHash1 = computeContentHash({ a: 1, b: 2 }); + const objHash2 = computeContentHash({ b: 2, a: 1 }); + expect(objHash1).toBe(objHash2); + }); + + it('computes canonical hash for nested objects and arrays', () => { + const nested1 = computeContentHash({ x: [1, 2], y: { b: 'two', a: 'one' } }); + const nested2 = computeContentHash({ y: { a: 'one', b: 'two' }, x: [1, 2] }); + expect(nested1).toBe(nested2); + }); +}); diff --git a/packages/core/tests/model.test.ts b/packages/core/tests/model.test.ts new file mode 100644 index 0000000..3981b30 --- /dev/null +++ b/packages/core/tests/model.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { createCanonicalContext } from '../src/model/factory.js'; + +describe('CanonicalContext Model', () => { + it('creates a fully formed CanonicalContext entity with defaults', () => { + const context = createCanonicalContext({ + content: { text: 'Authentication design pattern' }, + type: 'decision', + scope: 'project:zorp', + }); + + expect(context.id).toHaveLength(26); + expect(context.namespace).toBe('default'); + expect(context.scope).toBe('project:zorp'); + expect(context.type).toBe('decision'); + expect(context.content.text).toBe('Authentication design pattern'); + expect(context.provenance.actor).toBe('user'); + expect(context.provenance.contentHash).toBeTruthy(); + expect(context.version.revision).toBe(1); + expect(context.lifecycle).toBe('active'); + expect(context.relationships).toEqual([]); + expect(context.timestamps.createdAt).toBeTruthy(); + expect(context.timestamps.updatedAt).toBe(context.timestamps.createdAt); + }); + + it('respects custom overrides for all fields', () => { + const customId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + const expires = new Date(Date.now() + 60000).toISOString(); + const context = createCanonicalContext({ + id: customId, + namespace: 'custom-ns', + scope: 'custom-scope', + type: 'constraint', + content: { structured: { maxMemoryMb: 512 } }, + metadata: { priority: 'high' }, + actor: 'agent', + agentId: 'agent-007', + sourceUri: 'file:///config.json', + relationships: [{ targetId: '01ARZ3NDEKTSV4RRFFQ69G5FA0', relation: 'derived_from' }], + expiresAt: expires, + }); + + expect(context.id).toBe(customId); + expect(context.namespace).toBe('custom-ns'); + expect(context.scope).toBe('custom-scope'); + expect(context.type).toBe('constraint'); + expect(context.content.structured).toEqual({ maxMemoryMb: 512 }); + expect(context.metadata).toEqual({ priority: 'high' }); + expect(context.provenance.actor).toBe('agent'); + expect(context.provenance.agentId).toBe('agent-007'); + expect(context.provenance.sourceUri).toBe('file:///config.json'); + expect(context.provenance.contentHash).toBeTruthy(); + expect(context.relationships).toHaveLength(1); + expect(context.relationships[0].relation).toBe('derived_from'); + expect(context.timestamps.expiresAt).toBe(expires); + }); +}); From f76330d732c082dcd51084e1493de9c2f47ca06c Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:47:44 -0500 Subject: [PATCH 04/10] feat(core): implement ContextStoreV1Shim for dual-layer backward compatibility --- packages/core/src/index.ts | 2 + packages/core/src/shims/v1-shim.ts | 172 +++++++++ packages/core/tests/v1-shim.test.ts | 568 ++++++++++++++++++++++++++++ 3 files changed, 742 insertions(+) create mode 100644 packages/core/src/shims/v1-shim.ts create mode 100644 packages/core/tests/v1-shim.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c3faff..47c9164 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,6 @@ export * from './model/types.js'; export * from './model/factory.js'; export * from './identity/index.js'; +export * from './shims/v1-shim.js'; export const VERSION = '2.0.0'; + diff --git a/packages/core/src/shims/v1-shim.ts b/packages/core/src/shims/v1-shim.ts new file mode 100644 index 0000000..575d7ad --- /dev/null +++ b/packages/core/src/shims/v1-shim.ts @@ -0,0 +1,172 @@ +import { CanonicalContext } from '../model/types.js'; +import { createCanonicalContext } from '../model/factory.js'; + +export interface ContextEntry { + id: string; + content: string; + tags: string[]; + source: string; + bubbleId?: string; + createdAt: string; + updatedAt: string; +} + +export interface Bubble { + id: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; +} + +export interface MinimalStore { + put(context: CanonicalContext): Promise; + get(id: string, namespace?: string): Promise; + query(query: any): Promise<{ items: CanonicalContext[]; nextCursor?: string; totalCount?: number }>; + update(id: string, namespace: string, expectedRevision: number, patch: Partial): Promise; + delete(id: string, namespace?: string, hard?: boolean): Promise; +} + +export class ContextStoreV1Shim { + constructor(private readonly store: MinimalStore) {} + + async saveContext( + content: string, + tags: string[] = [], + source = 'chat', + bubbleId?: string, + ): Promise { + const canonical = createCanonicalContext({ + content: { text: content, mediaType: 'text/plain' }, + metadata: { tags, legacySource: source }, + scope: bubbleId ? `bubble:${bubbleId}` : 'global', + type: 'fact', + actor: source === 'chat' || source === 'user' ? 'user' : 'system', + sourceUri: source, + relationships: bubbleId ? [{ targetId: bubbleId, relation: 'child_of' }] : [], + }); + + const saved = await this.store.put(canonical); + return this.toV1Entry(saved); + } + + async getContext(id: string): Promise { + const item = await this.store.get(id, 'default'); + if (!item || (item.type === 'checkpoint' && item.metadata?.isBubble)) return undefined; + return this.toV1Entry(item); + } + + async listContexts(tag?: string): Promise { + const result = await this.store.query({ + namespace: 'default', + lifecycle: ['active'], + pagination: { limit: 10000, order: 'asc', orderBy: 'createdAt' }, + }); + + let items = result.items.filter((i) => !i.metadata?.isBubble); + if (tag) { + items = items.filter((i) => Array.isArray(i.metadata?.tags) && (i.metadata.tags as string[]).includes(tag)); + } + return items.map((i) => this.toV1Entry(i)); + } + + async listContextsByBubble(bubbleId: string): Promise { + const result = await this.store.query({ + namespace: 'default', + scope: `bubble:${bubbleId}`, + lifecycle: ['active'], + pagination: { limit: 10000, order: 'asc', orderBy: 'createdAt' }, + }); + return result.items.filter((i) => !i.metadata?.isBubble).map((i) => this.toV1Entry(i)); + } + + async updateContext( + id: string, + content: string, + tags?: string[], + bubbleId?: string | null, + ): Promise { + const existing = await this.store.get(id, 'default'); + if (!existing) return undefined; + + const patch: Partial = { + content: { ...existing.content, text: content }, + metadata: { ...existing.metadata, ...(tags !== undefined ? { tags } : {}) }, + timestamps: { ...existing.timestamps, updatedAt: new Date().toISOString() }, + }; + + if (bubbleId !== undefined) { + if (bubbleId === null) { + patch.scope = 'global'; + patch.relationships = existing.relationships.filter((r) => r.relation !== 'child_of'); + } else { + patch.scope = `bubble:${bubbleId}`; + patch.relationships = [ + ...existing.relationships.filter((r) => r.relation !== 'child_of'), + { targetId: bubbleId, relation: 'child_of' }, + ]; + } + } + + const updated = await this.store.update(id, 'default', existing.version.revision, patch); + return this.toV1Entry(updated); + } + + async deleteContext(id: string): Promise { + return this.store.delete(id, 'default', true); + } + + async searchContexts(query: string): Promise { + const result = await this.store.query({ + namespace: 'default', + fullText: query, + lifecycle: ['active'], + pagination: { limit: 100 }, + }); + return result.items.filter((i) => !i.metadata?.isBubble).map((i) => this.toV1Entry(i)); + } + + async createBubble(name: string, description?: string): Promise { + const canonical = createCanonicalContext({ + type: 'checkpoint', + scope: 'global', + content: { text: name }, + metadata: { name, description, isBubble: true }, + }); + const saved = await this.store.put(canonical); + return this.toBubble(saved); + } + + async listBubbles(): Promise { + const result = await this.store.query({ + namespace: 'default', + types: ['checkpoint'], + lifecycle: ['active'], + pagination: { limit: 1000 }, + }); + return result.items.filter((i) => i.metadata?.isBubble).map((i) => this.toBubble(i)); + } + + toV1Entry(ctx: CanonicalContext): ContextEntry { + const bubbleChild = ctx.relationships?.find((r) => r.relation === 'child_of'); + return { + id: ctx.id, + content: ctx.content.text ?? JSON.stringify(ctx.content.structured ?? {}), + tags: Array.isArray(ctx.metadata?.tags) ? (ctx.metadata.tags as string[]) : [], + source: ctx.provenance.sourceUri ?? ctx.provenance.actor, + bubbleId: bubbleChild ? bubbleChild.targetId : undefined, + createdAt: ctx.timestamps.createdAt, + updatedAt: ctx.timestamps.updatedAt, + }; + } + + toBubble(ctx: CanonicalContext): Bubble { + return { + id: ctx.id, + name: (ctx.metadata?.name as string) ?? ctx.content.text ?? '', + description: ctx.metadata?.description as string | undefined, + createdAt: ctx.timestamps.createdAt, + updatedAt: ctx.timestamps.updatedAt, + }; + } +} diff --git a/packages/core/tests/v1-shim.test.ts b/packages/core/tests/v1-shim.test.ts new file mode 100644 index 0000000..f80f153 --- /dev/null +++ b/packages/core/tests/v1-shim.test.ts @@ -0,0 +1,568 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ContextStoreV1Shim, ContextEntry, Bubble } from '../src/shims/v1-shim.js'; +import type { CanonicalContext } from '../src/model/types.js'; + +describe('ContextStoreV1Shim', () => { + function createMockContext(overrides: Partial = {}): CanonicalContext { + return { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + namespace: 'default', + scope: 'global', + type: 'fact', + content: { text: 'Test content', mediaType: 'text/plain' }, + metadata: { tags: ['tag1'] }, + provenance: { + actor: 'user', + sourceUri: 'custom-cli', + contentHash: 'hash123', + }, + relationships: [], + timestamps: { + createdAt: '2026-08-25T01:00:00.000Z', + updatedAt: '2026-08-25T01:00:00.000Z', + }, + version: { revision: 1 }, + lifecycle: 'active', + ...overrides, + }; + } + + describe('saveContext', () => { + it('converts legacy saveContext arguments into a CanonicalContext and saves it with bubble', async () => { + let saved: CanonicalContext | undefined; + const mockStore = { + put: vi.fn().mockImplementation(async (ctx: CanonicalContext) => { + saved = ctx; + return ctx; + }), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entry = await shim.saveContext('Always use strict typing', ['typescript', 'rules'], 'chat', 'bubble_123'); + + expect(entry.content).toBe('Always use strict typing'); + expect(entry.tags).toEqual(['typescript', 'rules']); + expect(entry.source).toBe('chat'); + expect(entry.bubbleId).toBe('bubble_123'); + + expect(saved).toBeDefined(); + expect(saved!.content.text).toBe('Always use strict typing'); + expect(saved!.metadata.tags).toEqual(['typescript', 'rules']); + expect(saved!.metadata.legacySource).toBe('chat'); + expect(saved!.scope).toBe('bubble:bubble_123'); + expect(saved!.provenance.actor).toBe('user'); + expect(saved!.provenance.sourceUri).toBe('chat'); + expect(saved!.relationships).toEqual([{ targetId: 'bubble_123', relation: 'child_of' }]); + }); + + it('uses defaults for tags and source when omitted, sets global scope when bubbleId is omitted', async () => { + let saved: CanonicalContext | undefined; + const mockStore = { + put: vi.fn().mockImplementation(async (ctx: CanonicalContext) => { + saved = ctx; + return ctx; + }), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entry = await shim.saveContext('Simple note'); + + expect(entry.content).toBe('Simple note'); + expect(entry.tags).toEqual([]); + expect(entry.source).toBe('chat'); + expect(entry.bubbleId).toBeUndefined(); + + expect(saved).toBeDefined(); + expect(saved!.scope).toBe('global'); + expect(saved!.provenance.actor).toBe('user'); + expect(saved!.relationships).toEqual([]); + }); + + it('sets actor to system for non-chat/non-user sources', async () => { + let saved: CanonicalContext | undefined; + const mockStore = { + put: vi.fn().mockImplementation(async (ctx: CanonicalContext) => { + saved = ctx; + return ctx; + }), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + await shim.saveContext('Cron log', ['log'], 'cron-job'); + + expect(saved).toBeDefined(); + expect(saved!.provenance.actor).toBe('system'); + expect(saved!.provenance.sourceUri).toBe('cron-job'); + }); + + it('sets actor to user when source is user', async () => { + let saved: CanonicalContext | undefined; + const mockStore = { + put: vi.fn().mockImplementation(async (ctx: CanonicalContext) => { + saved = ctx; + return ctx; + }), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + await shim.saveContext('User note', ['note'], 'user'); + + expect(saved).toBeDefined(); + expect(saved!.provenance.actor).toBe('user'); + }); + }); + + describe('getContext', () => { + it('returns ContextEntry when context exists and is not a bubble', async () => { + const mockContext = createMockContext({ id: 'ctx-1', content: { text: 'Found context' } }); + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(mockContext), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entry = await shim.getContext('ctx-1'); + + expect(mockStore.get).toHaveBeenCalledWith('ctx-1', 'default'); + expect(entry).toBeDefined(); + expect(entry!.id).toBe('ctx-1'); + expect(entry!.content).toBe('Found context'); + }); + + it('returns undefined when context is not found', async () => { + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(undefined), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entry = await shim.getContext('nonexistent'); + + expect(entry).toBeUndefined(); + }); + + it('returns undefined when context is a bubble checkpoint', async () => { + const mockBubble = createMockContext({ + id: 'bubble-1', + type: 'checkpoint', + metadata: { name: 'Bubble 1', isBubble: true }, + }); + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(mockBubble), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entry = await shim.getContext('bubble-1'); + + expect(entry).toBeUndefined(); + }); + }); + + describe('listContexts', () => { + it('returns all active contexts excluding bubbles', async () => { + const item1 = createMockContext({ id: '1', content: { text: 'Item 1' }, metadata: { tags: ['a'] } }); + const item2 = createMockContext({ id: '2', content: { text: 'Item 2' }, metadata: { tags: ['b'] } }); + const bubbleItem = createMockContext({ id: '3', type: 'checkpoint', metadata: { isBubble: true } }); + + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [item1, item2, bubbleItem] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entries = await shim.listContexts(); + + expect(mockStore.query).toHaveBeenCalledWith({ + namespace: 'default', + lifecycle: ['active'], + pagination: { limit: 10000, order: 'asc', orderBy: 'createdAt' }, + }); + expect(entries).toHaveLength(2); + expect(entries.map((e) => e.id)).toEqual(['1', '2']); + }); + + it('filters by tag when provided', async () => { + const item1 = createMockContext({ id: '1', metadata: { tags: ['urgent', 'work'] } }); + const item2 = createMockContext({ id: '2', metadata: { tags: ['work'] } }); + const item3 = createMockContext({ id: '3', metadata: { tags: ['personal'] } }); + + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [item1, item2, item3] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entries = await shim.listContexts('urgent'); + + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe('1'); + }); + }); + + describe('listContextsByBubble', () => { + it('queries contexts scoped to bubbleId and filters out bubbles', async () => { + const item1 = createMockContext({ id: '1', scope: 'bubble:b_1' }); + const bubbleItem = createMockContext({ id: 'b_1', type: 'checkpoint', metadata: { isBubble: true } }); + + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [item1, bubbleItem] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const entries = await shim.listContextsByBubble('b_1'); + + expect(mockStore.query).toHaveBeenCalledWith({ + namespace: 'default', + scope: 'bubble:b_1', + lifecycle: ['active'], + pagination: { limit: 10000, order: 'asc', orderBy: 'createdAt' }, + }); + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe('1'); + }); + }); + + describe('updateContext', () => { + it('returns undefined if existing item is not found', async () => { + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(undefined), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const result = await shim.updateContext('missing', 'new text'); + + expect(result).toBeUndefined(); + expect(mockStore.update).not.toHaveBeenCalled(); + }); + + it('updates content, tags, and optimistic revision', async () => { + const existing = createMockContext({ + id: 'ctx-1', + content: { text: 'Old content' }, + metadata: { tags: ['old-tag'], customMeta: 'preserved' }, + version: { revision: 3 }, + }); + + let patchArg: any; + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(existing), + update: vi.fn().mockImplementation(async (id, ns, rev, patch) => { + patchArg = patch; + return { + ...existing, + content: patch.content, + metadata: patch.metadata, + timestamps: patch.timestamps, + version: { revision: rev + 1 }, + }; + }), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const result = await shim.updateContext('ctx-1', 'Updated content', ['new-tag']); + + expect(mockStore.update).toHaveBeenCalledWith('ctx-1', 'default', 3, expect.any(Object)); + expect(patchArg.content.text).toBe('Updated content'); + expect(patchArg.metadata.tags).toEqual(['new-tag']); + expect(patchArg.metadata.customMeta).toBe('preserved'); + expect(patchArg.timestamps.updatedAt).toBeDefined(); + + expect(result).toBeDefined(); + expect(result!.content).toBe('Updated content'); + expect(result!.tags).toEqual(['new-tag']); + }); + + it('sets scope to global and removes child_of relationship when bubbleId is null', async () => { + const existing = createMockContext({ + id: 'ctx-1', + scope: 'bubble:old-bubble', + relationships: [ + { targetId: 'old-bubble', relation: 'child_of' }, + { targetId: 'ref-1', relation: 'references' }, + ], + }); + + let patchArg: any; + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(existing), + update: vi.fn().mockImplementation(async (id, ns, rev, patch) => { + patchArg = patch; + return { ...existing, ...patch }; + }), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + await shim.updateContext('ctx-1', 'Updated content', undefined, null); + + expect(patchArg.scope).toBe('global'); + expect(patchArg.relationships).toEqual([{ targetId: 'ref-1', relation: 'references' }]); + }); + + it('sets scope to bubble and replaces child_of relationship when bubbleId is a string', async () => { + const existing = createMockContext({ + id: 'ctx-1', + scope: 'bubble:old-bubble', + relationships: [ + { targetId: 'old-bubble', relation: 'child_of' }, + { targetId: 'ref-1', relation: 'references' }, + ], + }); + + let patchArg: any; + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(existing), + update: vi.fn().mockImplementation(async (id, ns, rev, patch) => { + patchArg = patch; + return { ...existing, ...patch }; + }), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + await shim.updateContext('ctx-1', 'Updated content', undefined, 'new-bubble'); + + expect(patchArg.scope).toBe('bubble:new-bubble'); + expect(patchArg.relationships).toEqual([ + { targetId: 'ref-1', relation: 'references' }, + { targetId: 'new-bubble', relation: 'child_of' }, + ]); + }); + }); + + describe('deleteContext', () => { + it('calls store.delete with hard deletion', async () => { + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn().mockResolvedValue(true), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const result = await shim.deleteContext('ctx-to-delete'); + + expect(mockStore.delete).toHaveBeenCalledWith('ctx-to-delete', 'default', true); + expect(result).toBe(true); + }); + }); + + describe('searchContexts', () => { + it('queries with fullText and excludes bubbles', async () => { + const item1 = createMockContext({ id: '1', content: { text: 'Matching item' } }); + const bubbleItem = createMockContext({ id: 'b_1', type: 'checkpoint', metadata: { isBubble: true } }); + + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [item1, bubbleItem] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const results = await shim.searchContexts('match'); + + expect(mockStore.query).toHaveBeenCalledWith({ + namespace: 'default', + fullText: 'match', + lifecycle: ['active'], + pagination: { limit: 100 }, + }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('1'); + }); + }); + + describe('createBubble and listBubbles', () => { + it('creates a checkpoint canonical context with isBubble metadata', async () => { + let saved: CanonicalContext | undefined; + const mockStore = { + put: vi.fn().mockImplementation(async (ctx: CanonicalContext) => { + saved = ctx; + return ctx; + }), + query: vi.fn(), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const bubble = await shim.createBubble('Project Alpha', 'Alpha description'); + + expect(bubble.name).toBe('Project Alpha'); + expect(bubble.description).toBe('Alpha description'); + expect(saved).toBeDefined(); + expect(saved!.type).toBe('checkpoint'); + expect(saved!.metadata.isBubble).toBe(true); + expect(saved!.metadata.name).toBe('Project Alpha'); + expect(saved!.metadata.description).toBe('Alpha description'); + }); + + it('lists only bubbles from checkpoint contexts', async () => { + const bubble1 = createMockContext({ + id: 'b-1', + type: 'checkpoint', + metadata: { name: 'Bubble 1', isBubble: true }, + }); + const nonBubbleCheckpoint = createMockContext({ + id: 'cp-1', + type: 'checkpoint', + metadata: { name: 'Snapshot' }, + }); + + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [bubble1, nonBubbleCheckpoint] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const bubbles = await shim.listBubbles(); + + expect(mockStore.query).toHaveBeenCalledWith({ + namespace: 'default', + types: ['checkpoint'], + lifecycle: ['active'], + pagination: { limit: 1000 }, + }); + expect(bubbles).toHaveLength(1); + expect(bubbles[0].id).toBe('b-1'); + expect(bubbles[0].name).toBe('Bubble 1'); + }); + }); + + describe('toV1Entry and toBubble transformations', () => { + it('converts CanonicalContext back to ContextEntry losslessly', () => { + const shim = new ContextStoreV1Shim({} as any); + const canonical: CanonicalContext = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + namespace: 'default', + scope: 'bubble:b_1', + type: 'fact', + content: { text: 'Test content' }, + metadata: { tags: ['tag1'] }, + provenance: { + actor: 'user', + sourceUri: 'custom-cli', + contentHash: 'hash123', + }, + relationships: [{ targetId: 'b_1', relation: 'child_of' }], + timestamps: { + createdAt: '2026-08-25T01:00:00.000Z', + updatedAt: '2026-08-25T01:00:00.000Z', + }, + version: { revision: 1 }, + lifecycle: 'active', + }; + + const entry = shim.toV1Entry(canonical); + expect(entry.id).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + expect(entry.content).toBe('Test content'); + expect(entry.tags).toEqual(['tag1']); + expect(entry.source).toBe('custom-cli'); + expect(entry.bubbleId).toBe('b_1'); + expect(entry.createdAt).toBe('2026-08-25T01:00:00.000Z'); + expect(entry.updatedAt).toBe('2026-08-25T01:00:00.000Z'); + }); + + it('falls back to JSON serialized structured content when text is undefined', () => { + const shim = new ContextStoreV1Shim({} as any); + const canonical = createMockContext({ + content: { structured: { key: 'value', count: 42 } }, + metadata: {}, + provenance: { actor: 'agent', contentHash: 'hash' }, + relationships: [], + }); + + const entry = shim.toV1Entry(canonical); + expect(entry.content).toBe('{"key":"value","count":42}'); + expect(entry.tags).toEqual([]); + expect(entry.source).toBe('agent'); + expect(entry.bubbleId).toBeUndefined(); + }); + + it('toBubble extracts name and description correctly', () => { + const shim = new ContextStoreV1Shim({} as any); + const canonical = createMockContext({ + id: 'bubble_xyz', + metadata: { name: 'My Bubble', description: 'Some desc', isBubble: true }, + timestamps: { + createdAt: '2026-08-25T01:00:00.000Z', + updatedAt: '2026-08-25T01:00:00.000Z', + }, + }); + + const bubble = shim.toBubble(canonical); + expect(bubble.id).toBe('bubble_xyz'); + expect(bubble.name).toBe('My Bubble'); + expect(bubble.description).toBe('Some desc'); + expect(bubble.createdAt).toBe('2026-08-25T01:00:00.000Z'); + expect(bubble.updatedAt).toBe('2026-08-25T01:00:00.000Z'); + }); + + it('toBubble falls back to content.text when metadata.name is not set', () => { + const shim = new ContextStoreV1Shim({} as any); + const canonical = createMockContext({ + id: 'bubble_fallback', + content: { text: 'Fallback Name' }, + metadata: { isBubble: true }, + }); + + const bubble = shim.toBubble(canonical); + expect(bubble.name).toBe('Fallback Name'); + expect(bubble.description).toBeUndefined(); + }); + }); +}); From 199b77072b371a7e8fe1e6ac7b5f25bbb8b3a3ba Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:50:32 -0500 Subject: [PATCH 05/10] feat(provider-sdk): implement ContextStore SPI contracts and error definitions --- packages/provider-sdk/src/errors.ts | 20 ++ packages/provider-sdk/src/index.ts | 2 + packages/provider-sdk/src/spi.ts | 59 +++++ packages/provider-sdk/tests/spi.test.ts | 272 ++++++++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 packages/provider-sdk/src/errors.ts create mode 100644 packages/provider-sdk/src/index.ts create mode 100644 packages/provider-sdk/src/spi.ts create mode 100644 packages/provider-sdk/tests/spi.test.ts diff --git a/packages/provider-sdk/src/errors.ts b/packages/provider-sdk/src/errors.ts new file mode 100644 index 0000000..b36b2ac --- /dev/null +++ b/packages/provider-sdk/src/errors.ts @@ -0,0 +1,20 @@ +export class DriverNotInstalledError extends Error { + constructor(public readonly scheme: string, public readonly packageName: string, public readonly reason?: unknown) { + super(`${scheme} driver is not installed.\nInstall it with: npm install ${packageName}`); + this.name = 'DriverNotInstalledError'; + } +} + +export class InvalidDsnError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidDsnError'; + } +} + +export class ConcurrencyConflictError extends Error { + constructor(public readonly contextId: string, public readonly expectedRevision: number, public readonly actualRevision: number) { + super(`Concurrency conflict on context '${contextId}': expected revision ${expectedRevision}, but found ${actualRevision}`); + this.name = 'ConcurrencyConflictError'; + } +} diff --git a/packages/provider-sdk/src/index.ts b/packages/provider-sdk/src/index.ts new file mode 100644 index 0000000..7f1ef04 --- /dev/null +++ b/packages/provider-sdk/src/index.ts @@ -0,0 +1,2 @@ +export * from './spi.js'; +export * from './errors.js'; diff --git a/packages/provider-sdk/src/spi.ts b/packages/provider-sdk/src/spi.ts new file mode 100644 index 0000000..9b918fb --- /dev/null +++ b/packages/provider-sdk/src/spi.ts @@ -0,0 +1,59 @@ +import type { CanonicalContext, ContextId, ContextType, LifecycleState, NamespaceId, ScopeId } from '@opencontext/core'; + +export interface ContextStoreCapabilities { + fullTextSearch: boolean; + vectorSearch: boolean; + graphTraversal: boolean; + atomicTransactions: boolean; + optimisticLocking: boolean; + nativeTtl: boolean; + changeStreams: boolean; + durableCursors: boolean; +} + +export interface ContextQuery { + namespace: NamespaceId; + scope?: ScopeId | ScopeId[]; + types?: ContextType[]; + lifecycle?: LifecycleState[]; + filter?: Record; + fullText?: string; + vector?: { + embedding: number[]; + topK: number; + minSimilarity?: number; + }; + relationships?: { + relatedTo: ContextId; + relation?: string; + depth?: number; + }; + pagination?: { + limit: number; + cursor?: string; + order?: 'asc' | 'desc'; + orderBy?: 'createdAt' | 'updatedAt' | 'revision'; + }; +} + +export interface ContextBatchMutation { + puts?: CanonicalContext[]; + updates?: Array<{ id: ContextId; expectedRevision: number; patch: Partial }>; + deletes?: ContextId[]; +} + +export interface ContextStore { + readonly id: string; + readonly capabilities: ContextStoreCapabilities; + + connect(): Promise; + disconnect(): Promise; + ping(): Promise; + + put(context: CanonicalContext): Promise; + get(id: ContextId, namespace?: NamespaceId): Promise; + query(query: ContextQuery): Promise<{ items: CanonicalContext[]; nextCursor?: string; totalCount?: number }>; + update(id: ContextId, namespace: NamespaceId, expectedRevision: number, patch: Partial): Promise; + delete(id: ContextId, namespace?: NamespaceId, hard?: boolean): Promise; + batch(mutation: ContextBatchMutation): Promise<{ applied: boolean; committedRevision: number }>; +} diff --git a/packages/provider-sdk/tests/spi.test.ts b/packages/provider-sdk/tests/spi.test.ts new file mode 100644 index 0000000..dab1b77 --- /dev/null +++ b/packages/provider-sdk/tests/spi.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { + ConcurrencyConflictError, + DriverNotInstalledError, + InvalidDsnError, +} from '../src/index.js'; +import type { + ContextStore, + ContextStoreCapabilities, + ContextQuery, + ContextBatchMutation, +} from '../src/index.js'; +import type { CanonicalContext } from '@opencontext/core'; + +describe('Provider SDK Error Types', () => { + it('instantiates ConcurrencyConflictError correctly', () => { + const err = new ConcurrencyConflictError('ctx_123', 1, 2); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ConcurrencyConflictError); + expect(err.name).toBe('ConcurrencyConflictError'); + expect(err.contextId).toBe('ctx_123'); + expect(err.expectedRevision).toBe(1); + expect(err.actualRevision).toBe(2); + expect(err.message).toBe( + "Concurrency conflict on context 'ctx_123': expected revision 1, but found 2" + ); + }); + + it('instantiates DriverNotInstalledError correctly with and without reason', () => { + const errWithoutReason = new DriverNotInstalledError('postgres', 'pg'); + expect(errWithoutReason).toBeInstanceOf(Error); + expect(errWithoutReason).toBeInstanceOf(DriverNotInstalledError); + expect(errWithoutReason.name).toBe('DriverNotInstalledError'); + expect(errWithoutReason.scheme).toBe('postgres'); + expect(errWithoutReason.packageName).toBe('pg'); + expect(errWithoutReason.reason).toBeUndefined(); + expect(errWithoutReason.message).toContain('postgres driver is not installed.'); + expect(errWithoutReason.message).toContain('Install it with: npm install pg'); + + const innerReason = new Error('Cannot find module pg'); + const errWithReason = new DriverNotInstalledError('mongodb', 'mongodb', innerReason); + expect(errWithReason.scheme).toBe('mongodb'); + expect(errWithReason.packageName).toBe('mongodb'); + expect(errWithReason.reason).toBe(innerReason); + }); + + it('instantiates InvalidDsnError correctly', () => { + const err = new InvalidDsnError('Invalid DSN: missing protocol'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(InvalidDsnError); + expect(err.name).toBe('InvalidDsnError'); + expect(err.message).toBe('Invalid DSN: missing protocol'); + }); +}); + +describe('Provider SPI Contracts', () => { + it('allows a class to conform to ContextStore and ContextStoreCapabilities', async () => { + const mockCapabilities: ContextStoreCapabilities = { + fullTextSearch: true, + vectorSearch: true, + graphTraversal: true, + atomicTransactions: true, + optimisticLocking: true, + nativeTtl: true, + changeStreams: true, + durableCursors: true, + }; + + class MockStore implements ContextStore { + readonly id = 'mock-store'; + readonly capabilities = mockCapabilities; + private data = new Map(); + + async connect(): Promise {} + async disconnect(): Promise {} + async ping(): Promise {} + + async put(context: CanonicalContext): Promise { + this.data.set(context.id, context); + return context; + } + + async get(id: string, _namespace?: string): Promise { + return this.data.get(id); + } + + async query(query: ContextQuery): Promise<{ items: CanonicalContext[]; nextCursor?: string; totalCount?: number }> { + let items = Array.from(this.data.values()).filter( + (ctx) => ctx.namespace === query.namespace + ); + + if (query.scope) { + const scopes = Array.isArray(query.scope) ? query.scope : [query.scope]; + items = items.filter((ctx) => scopes.includes(ctx.scope)); + } + + if (query.types) { + items = items.filter((ctx) => query.types!.includes(ctx.type)); + } + + if (query.lifecycle) { + items = items.filter((ctx) => query.lifecycle!.includes(ctx.lifecycle)); + } + + if (query.pagination?.limit) { + items = items.slice(0, query.pagination.limit); + } + + return { items, totalCount: items.length }; + } + + async update( + id: string, + _namespace: string, + expectedRevision: number, + patch: Partial + ): Promise { + const existing = this.data.get(id); + if (!existing) { + throw new Error('Not found'); + } + if (existing.version.revision !== expectedRevision) { + throw new ConcurrencyConflictError(id, expectedRevision, existing.version.revision); + } + const updated: CanonicalContext = { + ...existing, + ...patch, + version: { + ...existing.version, + revision: existing.version.revision + 1, + }, + }; + this.data.set(id, updated); + return updated; + } + + async delete(id: string, _namespace?: string, _hard?: boolean): Promise { + return this.data.delete(id); + } + + async batch( + mutation: ContextBatchMutation + ): Promise<{ applied: boolean; committedRevision: number }> { + if (mutation.puts) { + for (const item of mutation.puts) { + this.data.set(item.id, item); + } + } + if (mutation.updates) { + for (const u of mutation.updates) { + await this.update(u.id, '', u.expectedRevision, u.patch); + } + } + if (mutation.deletes) { + for (const id of mutation.deletes) { + this.data.delete(id); + } + } + return { applied: true, committedRevision: 1 }; + } + } + + const store: ContextStore = new MockStore(); + expect(store.id).toBe('mock-store'); + expect(store.capabilities.optimisticLocking).toBe(true); + expect(store.capabilities.vectorSearch).toBe(true); + + await store.connect(); + await store.ping(); + + const sampleContext: CanonicalContext = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + namespace: 'default', + scope: 'project:alpha', + type: 'decision', + content: { text: 'Use ULID for IDs' }, + metadata: {}, + provenance: { + actor: 'agent', + contentHash: 'abc123hash', + }, + relationships: [], + timestamps: { + createdAt: '2026-08-25T00:00:00.000Z', + updatedAt: '2026-08-25T00:00:00.000Z', + }, + version: { revision: 1 }, + lifecycle: 'active', + }; + + const saved = await store.put(sampleContext); + expect(saved.id).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + const fetched = await store.get('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + expect(fetched).toEqual(sampleContext); + + // ContextQuery with rich filters + const query: ContextQuery = { + namespace: 'default', + scope: ['project:alpha'], + types: ['decision'], + lifecycle: ['active'], + filter: { 'metadata.priority': 'high' }, + fullText: 'ULID', + vector: { + embedding: [0.1, 0.2, 0.3], + topK: 5, + minSimilarity: 0.8, + }, + relationships: { + relatedTo: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + relation: 'references', + depth: 2, + }, + pagination: { limit: 10, cursor: 'cur_0', order: 'desc', orderBy: 'createdAt' }, + }; + + const queryResult = await store.query(query); + expect(queryResult.items).toHaveLength(1); + expect(queryResult.totalCount).toBe(1); + + // Update with correct revision + const updated = await store.update('01ARZ3NDEKTSV4RRFFQ69G5FAV', 'default', 1, { + content: { text: 'Use ULID for monotonic IDs' }, + }); + expect(updated.version.revision).toBe(2); + expect(updated.content.text).toBe('Use ULID for monotonic IDs'); + + // Update with wrong revision should throw ConcurrencyConflictError + await expect( + store.update('01ARZ3NDEKTSV4RRFFQ69G5FAV', 'default', 1, { + content: { text: 'Stale update' }, + }) + ).rejects.toThrow(ConcurrencyConflictError); + + // Batch mutation with updates and puts + const ctx2: CanonicalContext = { + ...sampleContext, + id: '01ARZ3NDEKTSV4RRFFQ69G5FAW', + version: { revision: 1 }, + }; + + const batchPutResult = await store.batch({ + puts: [ctx2], + updates: [ + { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + expectedRevision: 2, + patch: { metadata: { updatedInBatch: true } }, + }, + ], + }); + expect(batchPutResult.applied).toBe(true); + + const fetchedCtx2 = await store.get('01ARZ3NDEKTSV4RRFFQ69G5FAW'); + expect(fetchedCtx2?.id).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAW'); + + const fetchedCtx1 = await store.get('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + expect(fetchedCtx1?.metadata).toEqual({ updatedInBatch: true }); + expect(fetchedCtx1?.version.revision).toBe(3); + + // Batch mutation with deletes + const batchResult = await store.batch({ + deletes: ['01ARZ3NDEKTSV4RRFFQ69G5FAV', '01ARZ3NDEKTSV4RRFFQ69G5FAW'], + }); + expect(batchResult.applied).toBe(true); + expect(await store.get('01ARZ3NDEKTSV4RRFFQ69G5FAV')).toBeUndefined(); + expect(await store.get('01ARZ3NDEKTSV4RRFFQ69G5FAW')).toBeUndefined(); + + await store.disconnect(); + }); +}); From ba48b89e9db7ced78cb6ca90663aa4f1075aa062 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:52:58 -0500 Subject: [PATCH 06/10] feat(provider-sdk): implement MemoryContextStore, JsonContextStore, and conformance harness --- packages/provider-sdk/src/base/json-store.ts | 88 ++++++++++ .../provider-sdk/src/base/memory-store.ts | 141 ++++++++++++++++ packages/provider-sdk/src/index.ts | 3 + .../provider-sdk/src/testing/conformance.ts | 158 ++++++++++++++++++ .../provider-sdk/tests/json-store.test.ts | 46 +++++ .../provider-sdk/tests/memory-store.test.ts | 9 + 6 files changed, 445 insertions(+) create mode 100644 packages/provider-sdk/src/base/json-store.ts create mode 100644 packages/provider-sdk/src/base/memory-store.ts create mode 100644 packages/provider-sdk/src/testing/conformance.ts create mode 100644 packages/provider-sdk/tests/json-store.test.ts create mode 100644 packages/provider-sdk/tests/memory-store.test.ts diff --git a/packages/provider-sdk/src/base/json-store.ts b/packages/provider-sdk/src/base/json-store.ts new file mode 100644 index 0000000..95d74af --- /dev/null +++ b/packages/provider-sdk/src/base/json-store.ts @@ -0,0 +1,88 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { CanonicalContext, ContextId, NamespaceId } from '@opencontext/core'; +import type { ContextStore, ContextStoreCapabilities, ContextQuery, ContextBatchMutation } from '../spi.js'; +import { MemoryContextStore } from './memory-store.js'; + +export class JsonContextStore implements ContextStore { + readonly id = 'json'; + readonly capabilities: ContextStoreCapabilities = { + fullTextSearch: true, + vectorSearch: false, + graphTraversal: true, + atomicTransactions: true, + optimisticLocking: true, + nativeTtl: false, + changeStreams: false, + durableCursors: false, + }; + + private memory = new MemoryContextStore(); + + constructor(private readonly filePath: string) {} + + async connect(): Promise { + try { + const data = await fs.readFile(this.filePath, 'utf8'); + const parsed: CanonicalContext[] = JSON.parse(data); + for (const item of parsed) { + await this.memory.put(item); + } + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await this.persist(); + } + } + + private async persist(): Promise { + const items = this.memory.dump(); + const tempFile = `${this.filePath}.tmp.${Date.now()}`; + await fs.writeFile(tempFile, JSON.stringify(items, null, 2), 'utf8'); + await fs.rename(tempFile, this.filePath); + } + + async disconnect(): Promise { + await this.persist(); + await this.memory.disconnect(); + } + + async ping(): Promise {} + + async put(context: CanonicalContext): Promise { + const saved = await this.memory.put(context); + await this.persist(); + return saved; + } + + async get(id: ContextId, namespace?: NamespaceId): Promise { + return this.memory.get(id, namespace); + } + + async query(q: ContextQuery) { + return this.memory.query(q); + } + + async update( + id: ContextId, + namespace: NamespaceId = 'default', + expectedRevision: number, + patch: Partial + ): Promise { + const updated = await this.memory.update(id, namespace, expectedRevision, patch); + await this.persist(); + return updated; + } + + async delete(id: ContextId, namespace?: NamespaceId, hard?: boolean): Promise { + const res = await this.memory.delete(id, namespace, hard); + await this.persist(); + return res; + } + + async batch(mutation: ContextBatchMutation) { + const res = await this.memory.batch(mutation); + await this.persist(); + return res; + } +} diff --git a/packages/provider-sdk/src/base/memory-store.ts b/packages/provider-sdk/src/base/memory-store.ts new file mode 100644 index 0000000..7aff0d1 --- /dev/null +++ b/packages/provider-sdk/src/base/memory-store.ts @@ -0,0 +1,141 @@ +import type { CanonicalContext, ContextId, NamespaceId } from '@opencontext/core'; +import type { ContextStore, ContextStoreCapabilities, ContextQuery, ContextBatchMutation } from '../spi.js'; +import { ConcurrencyConflictError } from '../errors.js'; + +export class MemoryContextStore implements ContextStore { + readonly id = 'memory'; + readonly capabilities: ContextStoreCapabilities = { + fullTextSearch: true, + vectorSearch: false, + graphTraversal: true, + atomicTransactions: true, + optimisticLocking: true, + nativeTtl: false, + changeStreams: false, + durableCursors: false, + }; + + private records = new Map(); + + private key(id: ContextId, namespace: NamespaceId = 'default'): string { + return `${namespace}:${id}`; + } + + async connect(): Promise {} + async disconnect(): Promise { + this.records.clear(); + } + async ping(): Promise {} + + async put(context: CanonicalContext): Promise { + const k = this.key(context.id, context.namespace); + this.records.set(k, { ...context }); + return { ...context }; + } + + async get(id: ContextId, namespace: NamespaceId = 'default'): Promise { + const item = this.records.get(this.key(id, namespace)); + return item ? { ...item } : undefined; + } + + async query(q: ContextQuery): Promise<{ items: CanonicalContext[]; nextCursor?: string; totalCount?: number }> { + let items = Array.from(this.records.values()).filter((c) => c.namespace === q.namespace); + + if (q.scope) { + const scopes = Array.isArray(q.scope) ? q.scope : [q.scope]; + items = items.filter((c) => scopes.includes(c.scope)); + } + if (q.types && q.types.length > 0) { + items = items.filter((c) => q.types!.includes(c.type)); + } + if (q.lifecycle && q.lifecycle.length > 0) { + items = items.filter((c) => q.lifecycle!.includes(c.lifecycle)); + } + if (q.fullText) { + const term = q.fullText.toLowerCase(); + items = items.filter((c) => c.content.text?.toLowerCase().includes(term)); + } + + const order = q.pagination?.order ?? 'asc'; + const orderBy = q.pagination?.orderBy ?? 'createdAt'; + items.sort((a, b) => { + const valA = orderBy === 'revision' ? a.version.revision : a.timestamps[orderBy] ?? ''; + const valB = orderBy === 'revision' ? b.version.revision : b.timestamps[orderBy] ?? ''; + if (valA === valB) return a.id.localeCompare(b.id); + return order === 'asc' ? (valA < valB ? -1 : 1) : (valA > valB ? -1 : 1); + }); + + const limit = q.pagination?.limit ?? 100; + const paginated = items.slice(0, limit); + return { items: paginated, totalCount: items.length }; + } + + async update( + id: ContextId, + namespace: NamespaceId = 'default', + expectedRevision: number, + patch: Partial + ): Promise { + const k = this.key(id, namespace); + const existing = this.records.get(k); + if (!existing) throw new Error(`Context '${id}' not found`); + + if (existing.version.revision !== expectedRevision) { + throw new ConcurrencyConflictError(id, expectedRevision, existing.version.revision); + } + + const updated: CanonicalContext = { + ...existing, + ...patch, + id: existing.id, + namespace: existing.namespace, + version: { + ...existing.version, + ...(patch.version ?? {}), + revision: existing.version.revision + 1, + }, + timestamps: { + ...existing.timestamps, + ...(patch.timestamps ?? {}), + updatedAt: new Date().toISOString(), + }, + }; + + this.records.set(k, updated); + return { ...updated }; + } + + async delete(id: ContextId, namespace: NamespaceId = 'default', hard = false): Promise { + const k = this.key(id, namespace); + const existing = this.records.get(k); + if (!existing) return false; + + if (hard) { + return this.records.delete(k); + } else { + existing.lifecycle = 'soft_deleted'; + existing.timestamps = { + ...existing.timestamps, + updatedAt: new Date().toISOString(), + }; + return true; + } + } + + async batch(mutation: ContextBatchMutation): Promise<{ applied: boolean; committedRevision: number }> { + if (mutation.puts) { + for (const p of mutation.puts) await this.put(p); + } + if (mutation.updates) { + for (const u of mutation.updates) await this.update(u.id, 'default', u.expectedRevision, u.patch); + } + if (mutation.deletes) { + for (const d of mutation.deletes) await this.delete(d, 'default', true); + } + return { applied: true, committedRevision: 1 }; + } + + dump(): CanonicalContext[] { + return Array.from(this.records.values()).map((c) => ({ ...c })); + } +} diff --git a/packages/provider-sdk/src/index.ts b/packages/provider-sdk/src/index.ts index 7f1ef04..76d3581 100644 --- a/packages/provider-sdk/src/index.ts +++ b/packages/provider-sdk/src/index.ts @@ -1,2 +1,5 @@ export * from './spi.js'; export * from './errors.js'; +export * from './base/memory-store.js'; +export * from './base/json-store.js'; +export * from './testing/conformance.js'; diff --git a/packages/provider-sdk/src/testing/conformance.ts b/packages/provider-sdk/src/testing/conformance.ts new file mode 100644 index 0000000..aba000e --- /dev/null +++ b/packages/provider-sdk/src/testing/conformance.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { ContextStore } from '../spi.js'; +import { createCanonicalContext } from '@opencontext/core'; +import { ConcurrencyConflictError } from '../errors.js'; + +export interface ConformanceHarness { + create(): Promise; + cleanup?(): Promise; +} + +export function runProviderConformanceSuite(name: string, harness: ConformanceHarness): void { + describe(`${name} - Conformance Suite`, () => { + let store: ContextStore; + + beforeEach(async () => { + store = await harness.create(); + await store.connect(); + }); + + afterEach(async () => { + await store.disconnect(); + if (harness.cleanup) await harness.cleanup(); + }); + + it('puts and gets a canonical context entity', async () => { + const ctx = createCanonicalContext({ + content: { text: 'Testing context item' }, + type: 'decision', + scope: 'project:alpha', + }); + + const saved = await store.put(ctx); + expect(saved.id).toBe(ctx.id); + + const retrieved = await store.get(ctx.id, 'default'); + expect(retrieved).toBeDefined(); + expect(retrieved!.content.text).toBe('Testing context item'); + expect(retrieved!.version.revision).toBe(1); + }); + + it('returns undefined when getting non-existent entity', async () => { + const retrieved = await store.get('non-existent-id', 'default'); + expect(retrieved).toBeUndefined(); + }); + + it('pings the store successfully', async () => { + await expect(store.ping()).resolves.toBeUndefined(); + }); + + it('enforces optimistic locking on update', async () => { + const ctx = createCanonicalContext({ content: { text: 'Original' } }); + await store.put(ctx); + + const updated = await store.update(ctx.id, 'default', 1, { + content: { text: 'Updated content' }, + }); + expect(updated.version.revision).toBe(2); + expect(updated.content.text).toBe('Updated content'); + + await expect( + store.update(ctx.id, 'default', 1, { content: { text: 'Conflicting update' } }) + ).rejects.toThrow(ConcurrencyConflictError); + }); + + it('throws error when updating non-existent context', async () => { + await expect( + store.update('non-existent-id', 'default', 1, { content: { text: 'Ghost' } }) + ).rejects.toThrow(/not found/i); + }); + + it('queries contexts with scope, type, and pagination', async () => { + const c1 = createCanonicalContext({ content: { text: 'A' }, type: 'fact', scope: 's1' }); + const c2 = createCanonicalContext({ content: { text: 'B' }, type: 'decision', scope: 's1' }); + const c3 = createCanonicalContext({ content: { text: 'C' }, type: 'fact', scope: 's2' }); + + await store.put(c1); + await store.put(c2); + await store.put(c3); + + const res1 = await store.query({ namespace: 'default', scope: 's1' }); + expect(res1.items.length).toBe(2); + + const res2 = await store.query({ namespace: 'default', types: ['decision'] }); + expect(res2.items.length).toBe(1); + expect(res2.items[0].content.text).toBe('B'); + + const res3 = await store.query({ namespace: 'default', scope: ['s1', 's2'] }); + expect(res3.items.length).toBe(3); + }); + + it('queries contexts with full-text search', async () => { + const c1 = createCanonicalContext({ content: { text: 'TypeScript compiler options' } }); + const c2 = createCanonicalContext({ content: { text: 'Python virtual environments' } }); + + await store.put(c1); + await store.put(c2); + + const res = await store.query({ namespace: 'default', fullText: 'typescript' }); + expect(res.items.length).toBe(1); + expect(res.items[0].content.text).toBe('TypeScript compiler options'); + }); + + it('performs soft and hard delete', async () => { + const ctx = createCanonicalContext({ content: { text: 'Delete me' } }); + await store.put(ctx); + + // Soft delete + const softRes = await store.delete(ctx.id, 'default', false); + expect(softRes).toBe(true); + const softDeleted = await store.get(ctx.id, 'default'); + expect(softDeleted?.lifecycle).toBe('soft_deleted'); + + // Hard delete + const hardRes = await store.delete(ctx.id, 'default', true); + expect(hardRes).toBe(true); + const hardDeleted = await store.get(ctx.id, 'default'); + expect(hardDeleted).toBeUndefined(); + + // Deleting non-existent returns false + const nonExistentRes = await store.delete('non-existent-id', 'default', true); + expect(nonExistentRes).toBe(false); + }); + + it('executes batch mutations with puts, updates, and deletes', async () => { + const c1 = createCanonicalContext({ content: { text: 'Item 1' } }); + const c2 = createCanonicalContext({ content: { text: 'Item 2' } }); + + await store.put(c1); + + const batchRes = await store.batch({ + puts: [c2], + updates: [ + { + id: c1.id, + expectedRevision: 1, + patch: { content: { text: 'Item 1 Updated' } }, + }, + ], + }); + expect(batchRes.applied).toBe(true); + + const retrieved1 = await store.get(c1.id, 'default'); + expect(retrieved1?.content.text).toBe('Item 1 Updated'); + expect(retrieved1?.version.revision).toBe(2); + + const retrieved2 = await store.get(c2.id, 'default'); + expect(retrieved2?.content.text).toBe('Item 2'); + + const deleteBatchRes = await store.batch({ + deletes: [c1.id, c2.id], + }); + expect(deleteBatchRes.applied).toBe(true); + + expect(await store.get(c1.id, 'default')).toBeUndefined(); + expect(await store.get(c2.id, 'default')).toBeUndefined(); + }); + }); +} diff --git a/packages/provider-sdk/tests/json-store.test.ts b/packages/provider-sdk/tests/json-store.test.ts new file mode 100644 index 0000000..f0d7ab4 --- /dev/null +++ b/packages/provider-sdk/tests/json-store.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createCanonicalContext } from '@opencontext/core'; +import { JsonContextStore } from '../src/base/json-store.js'; +import { runProviderConformanceSuite } from '../src/testing/conformance.js'; + +describe('JsonContextStore', () => { + const testDir = path.join(os.tmpdir(), `opencontext-json-test-${Date.now()}`); + const testFile = path.join(testDir, 'contexts.json'); + + runProviderConformanceSuite('JsonContextStore', { + create: async () => new JsonContextStore(testFile), + cleanup: async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }, + }); + + it('persists data across restarts', async () => { + const file = path.join(testDir, 'persistence-test.json'); + const store1 = new JsonContextStore(file); + await store1.connect(); + + const ctx = createCanonicalContext({ + content: { text: 'Persisted across store instances' }, + type: 'rule', + scope: 'workspace:1', + }); + await store1.put(ctx); + await store1.disconnect(); + + // Verify raw file exists and contains valid JSON + const content = await fs.readFile(file, 'utf8'); + expect(JSON.parse(content)).toHaveLength(1); + + // New instance loads from file + const store2 = new JsonContextStore(file); + await store2.connect(); + const retrieved = await store2.get(ctx.id, 'default'); + expect(retrieved).toBeDefined(); + expect(retrieved!.content.text).toBe('Persisted across store instances'); + expect(retrieved!.type).toBe('rule'); + await store2.disconnect(); + }); +}); diff --git a/packages/provider-sdk/tests/memory-store.test.ts b/packages/provider-sdk/tests/memory-store.test.ts new file mode 100644 index 0000000..9af9df8 --- /dev/null +++ b/packages/provider-sdk/tests/memory-store.test.ts @@ -0,0 +1,9 @@ +import { describe } from 'vitest'; +import { MemoryContextStore } from '../src/base/memory-store.js'; +import { runProviderConformanceSuite } from '../src/testing/conformance.js'; + +describe('MemoryContextStore', () => { + runProviderConformanceSuite('MemoryContextStore', { + create: async () => new MemoryContextStore(), + }); +}); From 40d59bf5cbeccec25c3fdaed008bc3dde44d9b98 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 01:55:51 -0500 Subject: [PATCH 07/10] feat(provider-sdk): implement SqlContextStore and SqlDialect implementations --- .../provider-sdk/src/base/sql-dialects.ts | 57 ++++ packages/provider-sdk/src/base/sql-store.ts | 257 ++++++++++++++++++ packages/provider-sdk/src/index.ts | 2 + packages/provider-sdk/tests/sql-store.test.ts | 105 +++++++ 4 files changed, 421 insertions(+) create mode 100644 packages/provider-sdk/src/base/sql-dialects.ts create mode 100644 packages/provider-sdk/src/base/sql-store.ts create mode 100644 packages/provider-sdk/tests/sql-store.test.ts diff --git a/packages/provider-sdk/src/base/sql-dialects.ts b/packages/provider-sdk/src/base/sql-dialects.ts new file mode 100644 index 0000000..e85f102 --- /dev/null +++ b/packages/provider-sdk/src/base/sql-dialects.ts @@ -0,0 +1,57 @@ +export interface SqlDialect { + readonly name: string; + createTableSql(): string; + placeholder(index: number): string; +} + +export class SqliteDialect implements SqlDialect { + readonly name = 'sqlite'; + createTableSql(): string { + return ` + CREATE TABLE IF NOT EXISTS contexts ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + scope TEXT NOT NULL, + type TEXT NOT NULL, + content_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + provenance_json TEXT NOT NULL, + relationships_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL, + lifecycle TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_contexts_query ON contexts(namespace, scope, type, lifecycle); + `; + } + placeholder(_index: number): string { + return '?'; + } +} + +export class PostgresDialect implements SqlDialect { + readonly name = 'postgres'; + createTableSql(): string { + return ` + CREATE TABLE IF NOT EXISTS contexts ( + id VARCHAR(64) PRIMARY KEY, + namespace VARCHAR(128) NOT NULL, + scope VARCHAR(256) NOT NULL, + type VARCHAR(64) NOT NULL, + content_json JSONB NOT NULL, + metadata_json JSONB NOT NULL, + provenance_json JSONB NOT NULL, + relationships_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + revision BIGINT NOT NULL, + lifecycle VARCHAR(32) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pg_contexts ON contexts(namespace, scope, type, lifecycle); + `; + } + placeholder(index: number): string { + return `$${index}`; + } +} diff --git a/packages/provider-sdk/src/base/sql-store.ts b/packages/provider-sdk/src/base/sql-store.ts new file mode 100644 index 0000000..efe522f --- /dev/null +++ b/packages/provider-sdk/src/base/sql-store.ts @@ -0,0 +1,257 @@ +import type { CanonicalContext, ContextId, NamespaceId } from '@opencontext/core'; +import type { ContextStore, ContextStoreCapabilities, ContextQuery, ContextBatchMutation } from '../spi.js'; +import type { SqlDialect } from './sql-dialects.js'; +import { ConcurrencyConflictError } from '../errors.js'; + +export interface SqlDriver { + query(sql: string, params?: any[]): Promise; + exec(sql: string, params?: any[]): Promise<{ changes: number }>; + close(): Promise; +} + +export class SqlContextStore implements ContextStore { + readonly capabilities: ContextStoreCapabilities = { + fullTextSearch: true, + vectorSearch: false, + graphTraversal: true, + atomicTransactions: true, + optimisticLocking: true, + nativeTtl: false, + changeStreams: false, + durableCursors: false, + }; + + constructor( + readonly id: string, + private readonly dialect: SqlDialect, + private readonly driver: SqlDriver, + ) {} + + async connect(): Promise { + const statements = this.dialect + .createTableSql() + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + for (const stmt of statements) { + await this.driver.exec(stmt); + } + } + + async disconnect(): Promise { + await this.driver.close(); + } + + async ping(): Promise { + await this.driver.query('SELECT 1'); + } + + private rowToContext(row: any): CanonicalContext { + return { + id: row.id, + namespace: row.namespace, + scope: row.scope, + type: row.type, + content: typeof row.content_json === 'string' ? JSON.parse(row.content_json) : row.content_json, + metadata: typeof row.metadata_json === 'string' ? JSON.parse(row.metadata_json) : row.metadata_json, + provenance: typeof row.provenance_json === 'string' ? JSON.parse(row.provenance_json) : row.provenance_json, + relationships: typeof row.relationships_json === 'string' ? JSON.parse(row.relationships_json) : row.relationships_json, + timestamps: { + createdAt: typeof row.created_at === 'string' ? row.created_at : new Date(row.created_at).toISOString(), + updatedAt: typeof row.updated_at === 'string' ? row.updated_at : new Date(row.updated_at).toISOString(), + }, + version: { + revision: Number(row.revision), + }, + lifecycle: row.lifecycle, + }; + } + + async put(ctx: CanonicalContext): Promise { + const p = (i: number) => this.dialect.placeholder(i); + const sql = ` + INSERT INTO contexts (id, namespace, scope, type, content_json, metadata_json, provenance_json, relationships_json, created_at, updated_at, revision, lifecycle) + VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)}, ${p(7)}, ${p(8)}, ${p(9)}, ${p(10)}, ${p(11)}, ${p(12)}) + `; + const params = [ + ctx.id, + ctx.namespace, + ctx.scope, + ctx.type, + JSON.stringify(ctx.content ?? {}), + JSON.stringify(ctx.metadata ?? {}), + JSON.stringify(ctx.provenance ?? {}), + JSON.stringify(ctx.relationships ?? []), + typeof ctx.timestamps.createdAt === 'string' ? ctx.timestamps.createdAt : new Date(ctx.timestamps.createdAt).toISOString(), + typeof ctx.timestamps.updatedAt === 'string' ? ctx.timestamps.updatedAt : new Date(ctx.timestamps.updatedAt).toISOString(), + ctx.version.revision, + ctx.lifecycle, + ]; + await this.driver.exec(sql, params); + return ctx; + } + + async get(id: ContextId, namespace: NamespaceId = 'default'): Promise { + const p = (i: number) => this.dialect.placeholder(i); + const rows = await this.driver.query( + `SELECT * FROM contexts WHERE id = ${p(1)} AND namespace = ${p(2)}`, + [id, namespace], + ); + if (!rows || rows.length === 0) return undefined; + return this.rowToContext(rows[0]); + } + + async query(q: ContextQuery): Promise<{ items: CanonicalContext[]; nextCursor?: string; totalCount?: number }> { + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + conditions.push(`namespace = ${this.dialect.placeholder(idx++)}`); + params.push(q.namespace); + + if (q.scope) { + const scopes = Array.isArray(q.scope) ? q.scope : [q.scope]; + if (scopes.length > 0) { + const placeholders = scopes.map(() => this.dialect.placeholder(idx++)).join(', '); + conditions.push(`scope IN (${placeholders})`); + params.push(...scopes); + } + } + + if (q.types && q.types.length > 0) { + const placeholders = q.types.map(() => this.dialect.placeholder(idx++)).join(', '); + conditions.push(`type IN (${placeholders})`); + params.push(...q.types); + } + + if (q.lifecycle && q.lifecycle.length > 0) { + const placeholders = q.lifecycle.map(() => this.dialect.placeholder(idx++)).join(', '); + conditions.push(`lifecycle IN (${placeholders})`); + params.push(...q.lifecycle); + } + + if (q.fullText) { + conditions.push(`content_json LIKE ${this.dialect.placeholder(idx++)}`); + params.push(`%${q.fullText}%`); + } + + const orderCol = q.pagination?.orderBy === 'revision' + ? 'revision' + : q.pagination?.orderBy === 'updatedAt' + ? 'updated_at' + : 'created_at'; + const orderDir = q.pagination?.order === 'desc' ? 'DESC' : 'ASC'; + const limit = q.pagination?.limit ?? 100; + + const sql = ` + SELECT * FROM contexts + WHERE ${conditions.join(' AND ')} + ORDER BY ${orderCol} ${orderDir}, id ASC + LIMIT ${limit} + `; + + const rows = await this.driver.query(sql, params); + const items = rows.map((r) => this.rowToContext(r)); + return { items, totalCount: items.length }; + } + + async update( + id: ContextId, + namespace: NamespaceId = 'default', + expectedRevision: number, + patch: Partial, + ): Promise { + const existing = await this.get(id, namespace); + if (!existing) { + throw new Error(`Context '${id}' not found`); + } + if (existing.version.revision !== expectedRevision) { + throw new ConcurrencyConflictError(id, expectedRevision, existing.version.revision); + } + + const newRev = expectedRevision + 1; + const now = new Date().toISOString(); + const updatedCtx: CanonicalContext = { + ...existing, + ...patch, + id, + namespace, + version: { + ...existing.version, + ...(patch.version ?? {}), + revision: newRev, + }, + timestamps: { + ...existing.timestamps, + ...(patch.timestamps ?? {}), + updatedAt: now, + }, + }; + + const p = (i: number) => this.dialect.placeholder(i); + const sql = ` + UPDATE contexts + SET content_json = ${p(1)}, metadata_json = ${p(2)}, provenance_json = ${p(3)}, relationships_json = ${p(4)}, + updated_at = ${p(5)}, revision = ${p(6)}, lifecycle = ${p(7)}, scope = ${p(8)}, type = ${p(9)} + WHERE id = ${p(10)} AND namespace = ${p(11)} AND revision = ${p(12)} + `; + + const res = await this.driver.exec(sql, [ + JSON.stringify(updatedCtx.content ?? {}), + JSON.stringify(updatedCtx.metadata ?? {}), + JSON.stringify(updatedCtx.provenance ?? {}), + JSON.stringify(updatedCtx.relationships ?? []), + updatedCtx.timestamps.updatedAt, + newRev, + updatedCtx.lifecycle, + updatedCtx.scope, + updatedCtx.type, + id, + namespace, + expectedRevision, + ]); + + if (res.changes === 0) { + const current = await this.get(id, namespace); + throw new ConcurrencyConflictError(id, expectedRevision, current?.version.revision ?? -1); + } + + return updatedCtx; + } + + async delete(id: ContextId, namespace: NamespaceId = 'default', hard = false): Promise { + const p = (i: number) => this.dialect.placeholder(i); + if (hard) { + const res = await this.driver.exec( + `DELETE FROM contexts WHERE id = ${p(1)} AND namespace = ${p(2)}`, + [id, namespace], + ); + return res.changes > 0; + } else { + const existing = await this.get(id, namespace); + if (!existing) return false; + const res = await this.driver.exec( + `UPDATE contexts SET lifecycle = 'soft_deleted', updated_at = ${p(1)} WHERE id = ${p(2)} AND namespace = ${p(3)}`, + [new Date().toISOString(), id, namespace], + ); + return res.changes > 0; + } + } + + async batch(mutation: ContextBatchMutation): Promise<{ applied: boolean; committedRevision: number }> { + if (mutation.puts) { + for (const p of mutation.puts) await this.put(p); + } + if (mutation.updates) { + for (const u of mutation.updates) { + await this.update(u.id, u.patch.namespace ?? 'default', u.expectedRevision, u.patch); + } + } + if (mutation.deletes) { + for (const d of mutation.deletes) { + await this.delete(d, 'default', true); + } + } + return { applied: true, committedRevision: 1 }; + } +} diff --git a/packages/provider-sdk/src/index.ts b/packages/provider-sdk/src/index.ts index 76d3581..b985deb 100644 --- a/packages/provider-sdk/src/index.ts +++ b/packages/provider-sdk/src/index.ts @@ -2,4 +2,6 @@ export * from './spi.js'; export * from './errors.js'; export * from './base/memory-store.js'; export * from './base/json-store.js'; +export * from './base/sql-dialects.js'; +export * from './base/sql-store.js'; export * from './testing/conformance.js'; diff --git a/packages/provider-sdk/tests/sql-store.test.ts b/packages/provider-sdk/tests/sql-store.test.ts new file mode 100644 index 0000000..3ef9b7b --- /dev/null +++ b/packages/provider-sdk/tests/sql-store.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { SqlContextStore } from '../src/base/sql-store.js'; +import { SqliteDialect, PostgresDialect } from '../src/base/sql-dialects.js'; +import { runProviderConformanceSuite } from '../src/testing/conformance.js'; + +describe('SqlContextStore (SQLite)', () => { + let db: DatabaseSync; + + runProviderConformanceSuite('SqlContextStore - SQLite', { + create: async () => { + db = new DatabaseSync(':memory:'); + const driver = { + query: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + return stmt.all(...params); + }, + exec: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + const res = stmt.run(...params); + return { changes: Number(res.changes) }; + }, + close: async () => { + try { + db.close(); + } catch { + // ignore if already closed + } + }, + }; + return new SqlContextStore('sqlite', new SqliteDialect(), driver); + }, + cleanup: async () => { + try { + db?.close(); + } catch { + // ignore if already closed + } + }, + }); +}); + +describe('SQL Dialects', () => { + it('generates correct SQL statements and placeholders for SQLite', () => { + const dialect = new SqliteDialect(); + expect(dialect.name).toBe('sqlite'); + expect(dialect.placeholder(1)).toBe('?'); + expect(dialect.placeholder(5)).toBe('?'); + const tableSql = dialect.createTableSql(); + expect(tableSql).toContain('CREATE TABLE IF NOT EXISTS contexts'); + expect(tableSql).toContain('content_json TEXT NOT NULL'); + expect(tableSql).toContain('idx_contexts_query'); + }); + + it('generates correct SQL statements and positional placeholders for PostgreSQL', () => { + const dialect = new PostgresDialect(); + expect(dialect.name).toBe('postgres'); + expect(dialect.placeholder(1)).toBe('$1'); + expect(dialect.placeholder(5)).toBe('$5'); + const tableSql = dialect.createTableSql(); + expect(tableSql).toContain('CREATE TABLE IF NOT EXISTS contexts'); + expect(tableSql).toContain('content_json JSONB NOT NULL'); + expect(tableSql).toContain('idx_pg_contexts'); + }); + + it('executes queries with Postgres positional parameter syntax', async () => { + const executedQueries: Array<{ sql: string; params: any[] }> = []; + const mockDriver = { + query: async (sql: string, params: any[] = []) => { + executedQueries.push({ sql, params }); + return []; + }, + exec: async (sql: string, params: any[] = []) => { + executedQueries.push({ sql, params }); + return { changes: 1 }; + }, + close: async () => {}, + }; + + const pgStore = new SqlContextStore('pg-test', new PostgresDialect(), mockDriver); + await pgStore.connect(); + expect(executedQueries.length).toBeGreaterThanOrEqual(1); + expect(executedQueries[0].sql).toContain('CREATE TABLE IF NOT EXISTS contexts'); + + await pgStore.get('ctx-1', 'ns-1'); + const getQuery = executedQueries[executedQueries.length - 1]; + expect(getQuery.sql).toContain('WHERE id = $1 AND namespace = $2'); + expect(getQuery.params).toEqual(['ctx-1', 'ns-1']); + + await pgStore.query({ + namespace: 'ns-1', + scope: ['scope-a', 'scope-b'], + types: ['decision'], + lifecycle: ['active'], + fullText: 'test', + }); + const complexQuery = executedQueries[executedQueries.length - 1]; + expect(complexQuery.sql).toContain('namespace = $1'); + expect(complexQuery.sql).toContain('scope IN ($2, $3)'); + expect(complexQuery.sql).toContain('type IN ($4)'); + expect(complexQuery.sql).toContain('lifecycle IN ($5)'); + expect(complexQuery.sql).toContain('content_json'); + expect(complexQuery.params).toEqual(['ns-1', 'scope-a', 'scope-b', 'decision', 'active', '%test%']); + }); +}); From 4f61a3f12e78f59fa1394c1471516bfad2a1196c Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 02:00:33 -0500 Subject: [PATCH 08/10] feat(store): wire Universal DSN Loader and built-in SQLite/JSON/Memory providers --- packages/provider-sdk/src/dsn.ts | 70 +++++++++++++++++++++++ packages/provider-sdk/src/errors.ts | 8 +++ packages/provider-sdk/src/index.ts | 2 + packages/provider-sdk/tests/dsn.test.ts | 75 +++++++++++++++++++++++++ src/store/index.ts | 2 + src/store/manager.ts | 42 ++++++++++++++ src/store/types.ts | 2 + tests/store/v2-dsn-bridge.test.ts | 69 +++++++++++++++++++++++ 8 files changed, 270 insertions(+) create mode 100644 packages/provider-sdk/src/dsn.ts create mode 100644 packages/provider-sdk/tests/dsn.test.ts create mode 100644 tests/store/v2-dsn-bridge.test.ts diff --git a/packages/provider-sdk/src/dsn.ts b/packages/provider-sdk/src/dsn.ts new file mode 100644 index 0000000..a84cb0f --- /dev/null +++ b/packages/provider-sdk/src/dsn.ts @@ -0,0 +1,70 @@ +import { InvalidDsnError, UnsupportedSchemeError } from './errors.js'; +import type { ContextStore } from './spi.js'; + +export interface ParsedDsn { + scheme: string; + host?: string; + port?: number; + path?: string; + user?: string; + password?: string; + params: Record; + raw: string; +} + +export function parseDsn(dsn: string): ParsedDsn { + const match = dsn.match(/^([a-zA-Z0-9+_-]+):\/\/(.*)$/); + if (!match) throw new InvalidDsnError(`Invalid DSN format: '${dsn}'`); + + const scheme = match[1].toLowerCase(); + const rest = match[2]; + + let path = rest; + const params: Record = {}; + + const qIndex = rest.indexOf('?'); + if (qIndex !== -1) { + path = rest.slice(0, qIndex); + const queryString = rest.slice(qIndex + 1); + const searchParams = new URLSearchParams(queryString); + searchParams.forEach((val, key) => { + params[key] = val; + }); + } + + return { + scheme, + path, + params, + raw: dsn, + }; +} + +export type StoreFactory = (parsed: ParsedDsn) => Promise; + +export class ContextStoreRegistry { + private static factories = new Map(); + + static register(scheme: string, factory: StoreFactory): void { + this.factories.set(scheme.toLowerCase(), factory); + } + + static async create(dsn: string): Promise { + const parsed = parseDsn(dsn); + const factory = this.factories.get(parsed.scheme); + if (!factory) { + throw new UnsupportedSchemeError(parsed.scheme); + } + const store = await factory(parsed); + await store.connect(); + return store; + } + + static clear(): void { + this.factories.clear(); + } + + static getRegisteredSchemes(): string[] { + return Array.from(this.factories.keys()); + } +} diff --git a/packages/provider-sdk/src/errors.ts b/packages/provider-sdk/src/errors.ts index b36b2ac..f434319 100644 --- a/packages/provider-sdk/src/errors.ts +++ b/packages/provider-sdk/src/errors.ts @@ -18,3 +18,11 @@ export class ConcurrencyConflictError extends Error { this.name = 'ConcurrencyConflictError'; } } + +export class UnsupportedSchemeError extends Error { + constructor(public readonly scheme: string) { + super(`Unsupported storage scheme: '${scheme}'`); + this.name = 'UnsupportedSchemeError'; + } +} + diff --git a/packages/provider-sdk/src/index.ts b/packages/provider-sdk/src/index.ts index b985deb..914f67d 100644 --- a/packages/provider-sdk/src/index.ts +++ b/packages/provider-sdk/src/index.ts @@ -1,7 +1,9 @@ export * from './spi.js'; export * from './errors.js'; +export * from './dsn.js'; export * from './base/memory-store.js'; export * from './base/json-store.js'; export * from './base/sql-dialects.js'; export * from './base/sql-store.js'; export * from './testing/conformance.js'; + diff --git a/packages/provider-sdk/tests/dsn.test.ts b/packages/provider-sdk/tests/dsn.test.ts new file mode 100644 index 0000000..a2b72d6 --- /dev/null +++ b/packages/provider-sdk/tests/dsn.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { parseDsn, ContextStoreRegistry, InvalidDsnError, UnsupportedSchemeError } from '../src/index.js'; +import { MemoryContextStore } from '../src/base/memory-store.js'; + +describe('Provider SDK - DSN Parser & Registry', () => { + describe('parseDsn', () => { + it('parses valid DSN without parameters', () => { + const parsed = parseDsn('memory://'); + expect(parsed.scheme).toBe('memory'); + expect(parsed.path).toBe(''); + expect(parsed.params).toEqual({}); + expect(parsed.raw).toBe('memory://'); + }); + + it('parses DSN with path', () => { + const parsed = parseDsn('json://tmp/test.json'); + expect(parsed.scheme).toBe('json'); + expect(parsed.path).toBe('tmp/test.json'); + expect(parsed.params).toEqual({}); + }); + + it('parses DSN with query parameters', () => { + const parsed = parseDsn('sqlite:///tmp/db.sqlite?cache=shared&mode=rwc'); + expect(parsed.scheme).toBe('sqlite'); + expect(parsed.path).toBe('/tmp/db.sqlite'); + expect(parsed.params).toEqual({ + cache: 'shared', + mode: 'rwc', + }); + }); + + it('throws InvalidDsnError for invalid DSN strings', () => { + expect(() => parseDsn('not-a-dsn')).toThrow(InvalidDsnError); + expect(() => parseDsn('')).toThrow(InvalidDsnError); + expect(() => parseDsn('://empty-scheme')).toThrow(InvalidDsnError); + }); + }); + + describe('ContextStoreRegistry', () => { + beforeEach(() => { + ContextStoreRegistry.clear(); + }); + + it('registers and creates a store from a scheme', async () => { + let connectCalled = false; + class CustomStore extends MemoryContextStore { + override readonly id = 'custom'; + override async connect() { + connectCalled = true; + } + } + + ContextStoreRegistry.register('custom', async (parsed) => { + expect(parsed.scheme).toBe('custom'); + return new CustomStore(); + }); + + expect(ContextStoreRegistry.getRegisteredSchemes()).toContain('custom'); + + const store = await ContextStoreRegistry.create('custom://my-instance'); + expect(store.id).toBe('custom'); + expect(connectCalled).toBe(true); + }); + + it('throws UnsupportedSchemeError when scheme is not registered', async () => { + await expect(ContextStoreRegistry.create('unregistered://foo')).rejects.toThrow(UnsupportedSchemeError); + }); + + it('is case-insensitive for scheme names', async () => { + ContextStoreRegistry.register('MEM', async () => new MemoryContextStore()); + const store = await ContextStoreRegistry.create('mem://'); + expect(store.id).toBe('memory'); + }); + }); +}); diff --git a/src/store/index.ts b/src/store/index.ts index aa0819b..25b19a9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -177,4 +177,6 @@ export async function createStore(url: string): Promise { } export { parseDsn, redactDsn } from './dsn.js'; +export { createContextStoreFromDsn, ContextStoreRegistry, createStoreManager, type StoreManager } from './manager.js'; export * from './types.js'; + diff --git a/src/store/manager.ts b/src/store/manager.ts index b65999f..940d7a5 100644 --- a/src/store/manager.ts +++ b/src/store/manager.ts @@ -1,6 +1,48 @@ import type { ContextStoreAdapter, AdapterInfo } from './types.js'; import { createStore } from './index.js'; import { resolveDatabase, writeDatabaseUrl, type ResolvedDatabase } from './config.js'; +import { + ContextStoreRegistry, + MemoryContextStore, + JsonContextStore, + SqlContextStore, + SqliteDialect, + type ContextStore, +} from '@opencontext/provider-sdk'; + +// Register built-in default engines +ContextStoreRegistry.register('memory', async () => new MemoryContextStore()); +ContextStoreRegistry.register('json', async (parsed) => new JsonContextStore(parsed.path || './contexts.json')); +ContextStoreRegistry.register('sqlite', async (parsed) => { + const { DatabaseSync } = await import('node:sqlite'); + const dbPath = parsed.path === ':memory:' ? ':memory:' : (parsed.path || './contexts.db'); + const db = new DatabaseSync(dbPath); + const driver = { + query: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + return stmt.all(...params); + }, + exec: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + const res = stmt.run(...params); + return { changes: Number(res.changes) }; + }, + close: async () => { + try { + db.close(); + } catch { + // ignore if already closed + } + }, + }; + return new SqlContextStore('sqlite', new SqliteDialect(), driver); +}); + +export async function createContextStoreFromDsn(dsn: string): Promise { + return ContextStoreRegistry.create(dsn); +} + +export { ContextStoreRegistry }; export interface StoreManager { /** The live adapter, connecting on first use. */ diff --git a/src/store/types.ts b/src/store/types.ts index f119b2c..29b6f8d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,8 @@ import type { ContextEntry, Bubble } from '../mcp/types.js'; export type { ContextEntry, Bubble }; +export type { ContextStore, ContextStoreCapabilities, ContextQuery, ContextBatchMutation } from '@opencontext/provider-sdk'; + /** Every connection-string scheme opencontext knows how to open. */ export type DbScheme = diff --git a/tests/store/v2-dsn-bridge.test.ts b/tests/store/v2-dsn-bridge.test.ts new file mode 100644 index 0000000..000b2c5 --- /dev/null +++ b/tests/store/v2-dsn-bridge.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createContextStoreFromDsn } from '../../src/store/manager.js'; +import { createCanonicalContext } from '@opencontext/core'; +import { InvalidDsnError, UnsupportedSchemeError } from '@opencontext/provider-sdk'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +describe('V2 DSN Store Loader', () => { + const tmpJsonPath = path.join(process.cwd(), 'temp', 'v2-bridge-test.json'); + + afterEach(async () => { + try { + await fs.unlink(tmpJsonPath); + } catch { + // ignore if does not exist + } + }); + + it('instantiates MemoryContextStore for memory:// DSN', async () => { + const store = await createContextStoreFromDsn('memory://'); + expect(store.id).toBe('memory'); + expect(store.capabilities.optimisticLocking).toBe(true); + + const ctx = createCanonicalContext({ + content: { text: 'Memory test context' }, + metadata: { tags: ['mem-tag'] }, + }); + await store.put(ctx); + const retrieved = await store.get(ctx.id); + expect(retrieved).toBeDefined(); + expect(retrieved?.content.text).toBe('Memory test context'); + }); + + it('instantiates JsonContextStore for json:// DSN', async () => { + const store = await createContextStoreFromDsn(`json://${tmpJsonPath}`); + expect(store.id).toBe('json'); + + const ctx = createCanonicalContext({ + content: { text: 'JSON test context' }, + metadata: { tags: ['json-tag'] }, + }); + await store.put(ctx); + const retrieved = await store.get(ctx.id); + expect(retrieved).toBeDefined(); + expect(retrieved?.content.text).toBe('JSON test context'); + }); + + it('instantiates SqlContextStore for sqlite:// DSN', async () => { + const store = await createContextStoreFromDsn('sqlite://:memory:'); + expect(store.id).toBe('sqlite'); + + const ctx = createCanonicalContext({ + content: { text: 'SQLite test context' }, + metadata: { tags: ['sqlite-tag'] }, + }); + await store.put(ctx); + const retrieved = await store.get(ctx.id); + expect(retrieved).toBeDefined(); + expect(retrieved?.content.text).toBe('SQLite test context'); + }); + + it('throws InvalidDsnError for malformed DSN', async () => { + await expect(createContextStoreFromDsn('invalid-dsn')).rejects.toThrow(InvalidDsnError); + }); + + it('throws UnsupportedSchemeError for unregistered scheme', async () => { + await expect(createContextStoreFromDsn('unsupported://endpoint')).rejects.toThrow(UnsupportedSchemeError); + }); +}); From dd919bafc353cc71fa45cc87fdf0523046fcd4f7 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 02:05:51 -0500 Subject: [PATCH 09/10] feat(mcp): expose v2 canonical context tools alongside v1 backward compatibility shims --- packages/core/src/shims/v1-shim.ts | 53 ++++ packages/core/tests/v1-shim.test.ts | 91 +++++++ .../provider-sdk/src/base/memory-store.ts | 9 +- packages/provider-sdk/src/base/sql-store.ts | 7 +- src/mcp/server.ts | 234 +++++++++++++++--- tests/mcp/server.test.ts | 50 +++- 6 files changed, 405 insertions(+), 39 deletions(-) diff --git a/packages/core/src/shims/v1-shim.ts b/packages/core/src/shims/v1-shim.ts index 575d7ad..1956d72 100644 --- a/packages/core/src/shims/v1-shim.ts +++ b/packages/core/src/shims/v1-shim.ts @@ -126,6 +126,10 @@ export class ContextStoreV1Shim { return result.items.filter((i) => !i.metadata?.isBubble).map((i) => this.toV1Entry(i)); } + async recallContext(query: string): Promise { + return this.searchContexts(query); + } + async createBubble(name: string, description?: string): Promise { const canonical = createCanonicalContext({ type: 'checkpoint', @@ -147,6 +151,55 @@ export class ContextStoreV1Shim { return result.items.filter((i) => i.metadata?.isBubble).map((i) => this.toBubble(i)); } + async getBubble(id: string): Promise { + const item = await this.store.get(id, 'default'); + if (!item || item.type !== 'checkpoint' || !item.metadata?.isBubble) return undefined; + return this.toBubble(item); + } + + async updateBubble(id: string, name: string, description?: string): Promise { + const existing = await this.store.get(id, 'default'); + if (!existing || existing.type !== 'checkpoint' || !existing.metadata?.isBubble) return undefined; + const patch: Partial = { + content: { ...existing.content, text: name }, + metadata: { + ...existing.metadata, + name, + ...(description !== undefined ? { description } : {}), + }, + timestamps: { ...existing.timestamps, updatedAt: new Date().toISOString() }, + }; + const updated = await this.store.update(id, 'default', existing.version.revision, patch); + return this.toBubble(updated); + } + + async deleteBubble(id: string, deleteContexts = false): Promise { + const existing = await this.store.get(id, 'default'); + if (!existing || existing.type !== 'checkpoint' || !existing.metadata?.isBubble) return false; + if (deleteContexts) { + const contexts = await this.listContextsByBubble(id); + for (const ctx of contexts) { + await this.deleteContext(ctx.id); + } + } else { + const result = await this.store.query({ + namespace: 'default', + scope: `bubble:${id}`, + lifecycle: ['active'], + pagination: { limit: 10000 }, + }); + for (const item of result.items) { + const patch: Partial = { + scope: 'global', + relationships: item.relationships.filter((r) => r.relation !== 'child_of'), + timestamps: { ...item.timestamps, updatedAt: new Date().toISOString() }, + }; + await this.store.update(item.id, 'default', item.version.revision, patch); + } + } + return this.store.delete(id, 'default', true); + } + toV1Entry(ctx: CanonicalContext): ContextEntry { const bubbleChild = ctx.relationships?.find((r) => r.relation === 'child_of'); return { diff --git a/packages/core/tests/v1-shim.test.ts b/packages/core/tests/v1-shim.test.ts index f80f153..9299a2f 100644 --- a/packages/core/tests/v1-shim.test.ts +++ b/packages/core/tests/v1-shim.test.ts @@ -481,6 +481,97 @@ describe('ContextStoreV1Shim', () => { expect(bubbles[0].id).toBe('b-1'); expect(bubbles[0].name).toBe('Bubble 1'); }); + + it('recallContext delegates to searchContexts', async () => { + const item1 = createMockContext({ id: '1', content: { text: 'Recall match' } }); + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [item1] }), + get: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const results = await shim.recallContext('match'); + expect(results).toHaveLength(1); + expect(results[0].content).toBe('Recall match'); + }); + + it('getBubble retrieves bubble checkpoint context', async () => { + const bubble1 = createMockContext({ + id: 'b-1', + type: 'checkpoint', + metadata: { name: 'Bubble 1', description: 'Desc 1', isBubble: true }, + }); + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(bubble1), + update: vi.fn(), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const bubble = await shim.getBubble('b-1'); + expect(bubble).toBeDefined(); + expect(bubble!.name).toBe('Bubble 1'); + expect(bubble!.description).toBe('Desc 1'); + }); + + it('updateBubble updates bubble checkpoint name and description', async () => { + const bubble1 = createMockContext({ + id: 'b-1', + type: 'checkpoint', + metadata: { name: 'Old Name', description: 'Old Desc', isBubble: true }, + version: { revision: 1 }, + }); + const mockStore = { + put: vi.fn(), + query: vi.fn(), + get: vi.fn().mockResolvedValue(bubble1), + update: vi.fn().mockResolvedValue({ + ...bubble1, + content: { text: 'New Name' }, + metadata: { name: 'New Name', description: 'New Desc', isBubble: true }, + version: { revision: 2 }, + }), + delete: vi.fn(), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const updated = await shim.updateBubble('b-1', 'New Name', 'New Desc'); + expect(updated).toBeDefined(); + expect(updated!.name).toBe('New Name'); + expect(updated!.description).toBe('New Desc'); + }); + + it('deleteBubble deletes bubble checkpoint and unassigns or deletes child contexts', async () => { + const bubble1 = createMockContext({ + id: 'b-1', + type: 'checkpoint', + metadata: { name: 'Bubble 1', isBubble: true }, + }); + const child1 = createMockContext({ + id: 'c-1', + scope: 'bubble:b-1', + relationships: [{ targetId: 'b-1', relation: 'child_of' }], + version: { revision: 1 }, + }); + const mockStore = { + put: vi.fn(), + query: vi.fn().mockResolvedValue({ items: [child1] }), + get: vi.fn().mockResolvedValue(bubble1), + update: vi.fn().mockResolvedValue(child1), + delete: vi.fn().mockResolvedValue(true), + }; + + const shim = new ContextStoreV1Shim(mockStore as any); + const res = await shim.deleteBubble('b-1', false); + expect(res).toBe(true); + expect(mockStore.update).toHaveBeenCalled(); + expect(mockStore.delete).toHaveBeenCalledWith('b-1', 'default', true); + }); }); describe('toV1Entry and toBubble transformations', () => { diff --git a/packages/provider-sdk/src/base/memory-store.ts b/packages/provider-sdk/src/base/memory-store.ts index 7aff0d1..8c17c1d 100644 --- a/packages/provider-sdk/src/base/memory-store.ts +++ b/packages/provider-sdk/src/base/memory-store.ts @@ -52,8 +52,13 @@ export class MemoryContextStore implements ContextStore { items = items.filter((c) => q.lifecycle!.includes(c.lifecycle)); } if (q.fullText) { - const term = q.fullText.toLowerCase(); - items = items.filter((c) => c.content.text?.toLowerCase().includes(term)); + const terms = q.fullText.toLowerCase().trim().split(/\s+/).filter(Boolean); + items = items.filter((c) => { + const text = (c.content.text ?? JSON.stringify(c.content.structured ?? {})).toLowerCase(); + const tags = Array.isArray(c.metadata?.tags) ? (c.metadata.tags as string[]).join(' ').toLowerCase() : ''; + const combined = `${text} ${tags}`; + return terms.every((term) => combined.includes(term)); + }); } const order = q.pagination?.order ?? 'asc'; diff --git a/packages/provider-sdk/src/base/sql-store.ts b/packages/provider-sdk/src/base/sql-store.ts index efe522f..278ffc5 100644 --- a/packages/provider-sdk/src/base/sql-store.ts +++ b/packages/provider-sdk/src/base/sql-store.ts @@ -131,8 +131,11 @@ export class SqlContextStore implements ContextStore { } if (q.fullText) { - conditions.push(`content_json LIKE ${this.dialect.placeholder(idx++)}`); - params.push(`%${q.fullText}%`); + const terms = q.fullText.trim().split(/\s+/).filter(Boolean); + for (const term of terms) { + conditions.push(`content_json LIKE ${this.dialect.placeholder(idx++)}`); + params.push(`%${term}%`); + } } const orderCol = q.pagination?.orderBy === 'revision' diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0f0a720..4fe04b0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,27 +1,53 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { createStoreManager } from '../store/manager.js'; +import { createContextStoreFromDsn } from '../store/manager.js'; +import { resolveDatabase } from '../store/config.js'; +import { ContextStoreV1Shim, createCanonicalContext, type ContextEntry } from '@opencontext/core'; +import type { ContextStore } from '@opencontext/provider-sdk'; +import type { ContextStoreAdapter } from '../store/types.js'; + +export type StoreOrDsn = string | ContextStore | ContextStoreAdapter; /** - * @param databaseUrl Optional connection string. When omitted the store is - * resolved from OPENCONTEXT_DB_URL, then the saved config, then the legacy - * OPENCONTEXT_STORE_PATH, then the default JSON file. + * @param databaseUrlOrStore Optional connection string or store instance. + * When omitted the store is resolved from OPENCONTEXT_DB_URL, then the saved config, + * then the legacy OPENCONTEXT_STORE_PATH, then the default JSON file. */ -export function createMcpServer(databaseUrl?: string) { - const manager = createStoreManager(); - // The backend connects on first tool call rather than at construction, so an - // unreachable database surfaces as a tool error instead of preventing the MCP - // server from starting at all. - const store = () => - databaseUrl ? manager.reconnect(databaseUrl).then(() => manager.get()) : manager.get(); +export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { + let v2Store: ContextStore | undefined; + let v1Store: ContextStoreV1Shim | ContextStoreAdapter | undefined; + + const getStore = async (): Promise<{ v2: ContextStore; v1: ContextStoreV1Shim | ContextStoreAdapter }> => { + if (v2Store && v1Store) { + return { v2: v2Store, v1: v1Store }; + } + + if (databaseUrlOrStore && typeof databaseUrlOrStore === 'object') { + if ('capabilities' in databaseUrlOrStore && 'put' in databaseUrlOrStore) { + v2Store = databaseUrlOrStore as ContextStore; + v1Store = new ContextStoreV1Shim(v2Store); + return { v2: v2Store, v1: v1Store }; + } + v1Store = databaseUrlOrStore as ContextStoreAdapter; + return { v2: v2Store!, v1: v1Store }; + } + + const dsn = typeof databaseUrlOrStore === 'string' + ? (databaseUrlOrStore.includes('://') ? databaseUrlOrStore : `json://${databaseUrlOrStore}`) + : resolveDatabase().url; + + v2Store = await createContextStoreFromDsn(dsn); + v1Store = new ContextStoreV1Shim(v2Store); + return { v2: v2Store, v1: v1Store }; + }; const server = new McpServer({ name: 'opencontext', - version: '1.0.0', + version: '2.0.0', }); // --------------------------------------------------------------------------- - // Context tools + // Legacy v1 Context tools (wrapped via ContextStoreV1Shim) // --------------------------------------------------------------------------- server.tool( @@ -43,7 +69,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('ID of the bubble (project) to associate this context with'), }, async (args) => { - const entry = await (await store()).saveContext( + const { v1 } = await getStore(); + const entry = await v1.saveContext( args.content, args.tags || [], args.source || 'chat', @@ -67,7 +94,8 @@ export function createMcpServer(databaseUrl?: string) { query: z.string().describe('Search query to find matching contexts'), }, async (args) => { - const results = await (await store()).recallContext(args.query); + const { v1 } = await getStore(); + const results = await v1.recallContext(args.query); if (results.length === 0) { return { content: [ @@ -80,7 +108,7 @@ export function createMcpServer(databaseUrl?: string) { } const formatted = results .map( - (entry) => + (entry: ContextEntry) => `[${entry.id}] (${entry.tags.join(', ') || 'no tags'})${entry.bubbleId ? ` [bubble:${entry.bubbleId}]` : ''} - ${entry.createdAt}\n${entry.content}`, ) .join('\n\n---\n\n'); @@ -105,7 +133,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('Filter by tag (e.g. "preference", "code")'), }, async (args) => { - const results = await (await store()).listContexts(args.tag); + const { v1 } = await getStore(); + const results = await v1.listContexts(args.tag); if (results.length === 0) { return { content: [ @@ -120,7 +149,7 @@ export function createMcpServer(databaseUrl?: string) { } const formatted = results .map( - (entry) => + (entry: ContextEntry) => `[${entry.id}] (${entry.tags.join(', ') || 'no tags'})${entry.bubbleId ? ` [bubble:${entry.bubbleId}]` : ''} - ${entry.createdAt}\n${entry.content.substring(0, 100)}${entry.content.length > 100 ? '...' : ''}`, ) .join('\n\n'); @@ -142,7 +171,8 @@ export function createMcpServer(databaseUrl?: string) { id: z.string().describe('The ID of the context to delete'), }, async (args) => { - const deleted = await (await store()).deleteContext(args.id); + const { v1 } = await getStore(); + const deleted = await v1.deleteContext(args.id); return { content: [ { @@ -165,7 +195,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('Space-separated search terms (all must match)'), }, async (args) => { - const results = await (await store()).searchContexts(args.query); + const { v1 } = await getStore(); + const results = await v1.searchContexts(args.query); if (results.length === 0) { return { content: [ @@ -178,7 +209,7 @@ export function createMcpServer(databaseUrl?: string) { } const formatted = results .map( - (entry) => + (entry: ContextEntry) => `[${entry.id}] (${entry.tags.join(', ') || 'no tags'})${entry.bubbleId ? ` [bubble:${entry.bubbleId}]` : ''} - ${entry.createdAt}\n${entry.content}`, ) .join('\n\n---\n\n'); @@ -210,7 +241,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('Bubble ID to assign (null to unassign from bubble)'), }, async (args) => { - const updated = await (await store()).updateContext(args.id, args.content, args.tags, args.bubbleId); + const { v1 } = await getStore(); + const updated = await v1.updateContext(args.id, args.content, args.tags, args.bubbleId); if (!updated) { return { content: [ @@ -233,7 +265,7 @@ export function createMcpServer(databaseUrl?: string) { ); // --------------------------------------------------------------------------- - // Bubble tools + // Legacy v1 Bubble tools // --------------------------------------------------------------------------- server.tool( @@ -247,7 +279,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('Optional description of what this bubble is for'), }, async (args) => { - const bubble = await (await store()).createBubble(args.name, args.description); + const { v1 } = await getStore(); + const bubble = await v1.createBubble(args.name, args.description); return { content: [ { @@ -264,17 +297,17 @@ export function createMcpServer(databaseUrl?: string) { 'List all bubbles (project workspaces).', {}, async () => { - const bubbles = await (await store()).listBubbles(); + const { v1 } = await getStore(); + const bubbles = await v1.listBubbles(); if (bubbles.length === 0) { return { content: [{ type: 'text' as const, text: 'No bubbles created yet.' }], }; } - const db = await store(); const formatted = ( await Promise.all( bubbles.map(async (b) => { - const contexts = await db.listContextsByBubble(b.id); + const contexts = await v1.listContextsByBubble(b.id); return `[${b.id}] ${b.name}${b.description ? ` — ${b.description}` : ''} (${contexts.length} context${contexts.length === 1 ? '' : 's'})`; }), ) @@ -292,13 +325,14 @@ export function createMcpServer(databaseUrl?: string) { id: z.string().describe('The ID of the bubble'), }, async (args) => { - const bubble = await (await store()).getBubble(args.id); + const { v1 } = await getStore(); + const bubble = await v1.getBubble(args.id); if (!bubble) { return { content: [{ type: 'text' as const, text: `No bubble found with ID "${args.id}".` }], }; } - const contexts = await (await store()).listContextsByBubble(args.id); + const contexts = await v1.listContextsByBubble(args.id); const ctxText = contexts.length === 0 ? 'No contexts in this bubble.' @@ -331,7 +365,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('New description (omit to leave unchanged)'), }, async (args) => { - const updated = await (await store()).updateBubble(args.id, args.name, args.description); + const { v1 } = await getStore(); + const updated = await v1.updateBubble(args.id, args.name, args.description); if (!updated) { return { content: [{ type: 'text' as const, text: `No bubble found with ID "${args.id}".` }], @@ -359,7 +394,8 @@ export function createMcpServer(databaseUrl?: string) { .describe('If true, also delete all contexts inside the bubble (default: false)'), }, async (args) => { - const deleted = await (await store()).deleteBubble(args.id, args.deleteContexts ?? false); + const { v1 } = await getStore(); + const deleted = await v1.deleteBubble(args.id, args.deleteContexts ?? false); return { content: [ { @@ -373,5 +409,141 @@ export function createMcpServer(databaseUrl?: string) { }, ); + // --------------------------------------------------------------------------- + // Native v2 Canonical Context Tools + // --------------------------------------------------------------------------- + + server.tool( + 'save_canonical_context', + 'Save a canonical context node (v2 OCM model) with rich metadata, provenance, relationships, and scope.', + { + content: z.union([z.string(), z.record(z.string(), z.any())]).describe('The content to save (text string or structured JSON object)'), + type: z + .enum(['fact', 'decision', 'preference', 'instruction', 'summary', 'checkpoint', 'insight', 'pattern']) + .optional() + .describe('Type of context (fact, decision, preference, instruction, summary, checkpoint, insight, pattern)'), + scope: z.string().optional().describe('Scope identifier (e.g. "global", "project:v2", "bubble:123")'), + namespace: z.string().optional().describe('Namespace identifier (default: "default")'), + metadata: z.record(z.string(), z.any()).optional().describe('Arbitrary metadata attributes'), + relationships: z + .array( + z.object({ + targetId: z.string().describe('Target context ID'), + relation: z.string().describe('Relationship type (e.g. child_of, relates_to, supersedes)'), + metadata: z.record(z.string(), z.any()).optional().describe('Optional relationship metadata'), + }), + ) + .optional() + .describe('Relationship links to other context nodes'), + actor: z.enum(['user', 'agent', 'system', 'integration']).optional().describe('Actor type (user, agent, system, integration)'), + agentId: z.string().optional().describe('Agent ID for provenance tracking'), + sourceUri: z.string().optional().describe('Source URI or reference'), + expiresAt: z.string().optional().describe('ISO-8601 UTC timestamp for expiration'), + }, + async (args) => { + const { v2 } = await getStore(); + const contentObj = + typeof args.content === 'string' + ? { text: args.content, mediaType: 'text/plain' } + : { structured: args.content }; + + const canonical = createCanonicalContext({ + content: contentObj, + type: args.type as any, + scope: args.scope, + namespace: args.namespace, + metadata: args.metadata, + relationships: args.relationships, + actor: args.actor, + agentId: args.agentId, + sourceUri: args.sourceUri, + expiresAt: args.expiresAt, + }); + + const saved = await v2.put(canonical); + return { + content: [ + { + type: 'text' as const, + text: `Canonical context saved with ID: ${saved.id}\nType: ${saved.type}\nScope: ${saved.scope}\nNamespace: ${saved.namespace}\nRevision: ${saved.version.revision}\nCreated: ${saved.timestamps.createdAt}`, + }, + ], + }; + }, + ); + + server.tool( + 'query_canonical_context', + 'Query canonical context nodes (v2 OCM model) with scope, type, lifecycle, and full-text search filters.', + { + namespace: z.string().optional().describe('Namespace identifier (default: "default")'), + scope: z.union([z.string(), z.array(z.string())]).optional().describe('Scope ID or array of Scope IDs to filter'), + types: z.array(z.string()).optional().describe('Array of context types to filter (e.g. ["decision", "fact"])'), + lifecycle: z + .array(z.enum(['active', 'archived', 'deprecated', 'soft_deleted', 'pinned'])) + .optional() + .describe('Lifecycle states to filter (default: ["active"])'), + fullText: z.string().optional().describe('Full-text search query across content'), + limit: z.number().optional().describe('Maximum number of items to return (default: 100)'), + cursor: z.string().optional().describe('Cursor for pagination'), + order: z.enum(['asc', 'desc']).optional().describe('Sort order (default: "asc")'), + orderBy: z.enum(['createdAt', 'updatedAt', 'revision']).optional().describe('Field to sort by (default: "createdAt")'), + }, + async (args) => { + const { v2 } = await getStore(); + const results = await v2.query({ + namespace: args.namespace || 'default', + scope: args.scope, + types: args.types as any, + lifecycle: args.lifecycle, + fullText: args.fullText, + pagination: { + limit: args.limit ?? 100, + cursor: args.cursor, + order: args.order ?? 'asc', + orderBy: args.orderBy ?? 'createdAt', + }, + }); + + if (results.items.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No canonical contexts found matching query.', + }, + ], + }; + } + + const formatted = results.items + .map( + (ctx) => + `[${ctx.id}] [${ctx.type}] [scope:${ctx.scope}] (rev:${ctx.version.revision}) - ${ctx.timestamps.createdAt}\n${ctx.content.text ?? JSON.stringify(ctx.content.structured ?? {})}`, + ) + .join('\n\n---\n\n'); + + return { + content: [ + { + type: 'text' as const, + text: `Found ${results.items.length} canonical context(s):\n\n${formatted}`, + }, + ], + }; + }, + ); + + // Helper for programmatic direct tool execution in tests and integrations + (server as any).handleToolCall = async (name: string, args: Record = {}) => { + const registered = (server as any)._registeredTools[name]; + if (!registered) { + throw new Error(`Unknown tool: ${name}`); + } + return registered.handler(args, {}); + }; + return server; } + +export const createServer = createMcpServer; diff --git a/tests/mcp/server.test.ts b/tests/mcp/server.test.ts index 0939e8e..bfe19c7 100644 --- a/tests/mcp/server.test.ts +++ b/tests/mcp/server.test.ts @@ -5,7 +5,8 @@ import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import { Client } from '@modelcontextprotocol/sdk/client'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { createMcpServer } from '../../src/mcp/server.js'; +import { createMcpServer, createServer } from '../../src/mcp/server.js'; +import { MemoryContextStore } from '@opencontext/provider-sdk'; function createTempStorePath(): string { const dir = join(tmpdir(), `opencontext-mcp-test-${randomUUID()}`); @@ -51,7 +52,9 @@ describe('MCP Server', () => { expect(toolNames).toContain('get_bubble'); expect(toolNames).toContain('update_bubble'); expect(toolNames).toContain('delete_bubble'); - expect(tools.tools).toHaveLength(11); + expect(toolNames).toContain('save_canonical_context'); + expect(toolNames).toContain('query_canonical_context'); + expect(tools.tools).toHaveLength(13); }); describe('save_context tool', () => { @@ -176,7 +179,7 @@ describe('MCP Server', () => { }); const saveText = (saveResult.content as Array<{ type: string; text: string }>)[0].text; - const idMatch = saveText.match(/ID: ([a-f0-9-]+)/); + const idMatch = saveText.match(/ID: (\S+)/); const id = idMatch![1]; const deleteResult = await client.callTool({ @@ -239,7 +242,7 @@ describe('MCP Server', () => { }); const saveText = (saveResult.content as Array<{ type: string; text: string }>)[0].text; - const idMatch = saveText.match(/ID: ([a-f0-9-]+)/); + const idMatch = saveText.match(/ID: (\S+)/); const id = idMatch![1]; const updateResult = await client.callTool({ @@ -263,3 +266,42 @@ describe('MCP Server', () => { }); }); }); + +describe('MCP Server v2 Tools', () => { + let server: any; + let rawStore: MemoryContextStore; + + beforeEach(async () => { + rawStore = new MemoryContextStore(); + await rawStore.connect(); + server = await createServer(rawStore as any); + }); + + it('preserves legacy save_context and recall_context tool execution', async () => { + const saveRes = await server.handleToolCall('save_context', { + content: 'Legacy tool test', + tags: ['test'], + }); + expect(saveRes.content[0].text).toContain('Saved context with ID:'); + + const recallRes = await server.handleToolCall('recall_context', { + query: 'Legacy tool', + }); + expect(recallRes.content[0].text).toContain('Legacy tool test'); + }); + + it('executes new save_canonical_context tool', async () => { + const saveRes = await server.handleToolCall('save_canonical_context', { + content: 'Decision to use OCM 2.0', + type: 'decision', + scope: 'project:v2', + }); + expect(saveRes.content[0].text).toContain('Canonical context saved'); + + const queryRes = await server.handleToolCall('query_canonical_context', { + scope: 'project:v2', + types: ['decision'], + }); + expect(queryRes.content[0].text).toContain('Decision to use OCM 2.0'); + }); +}); From 6316acd3b4b3d41d46168cd84ce97f9080cf1d8e Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 25 Aug 2026 02:11:37 -0500 Subject: [PATCH 10/10] chore: complete Sub-Project 1 Canonical Model and Provider SPI implementation --- package.json | 2 +- packages/core/src/model/factory.ts | 5 +- .../provider-sdk/src/base/memory-store.ts | 4 +- .../provider-sdk/src/base/sql-dialects.ts | 2 + packages/provider-sdk/src/base/sql-store.ts | 17 +- packages/provider-sdk/tests/sql-store.test.ts | 38 +- src/mcp/server.ts | 30 +- .../integration/providers-consistency.test.ts | 388 ++++++++++++++++++ tests/mcp/server.test.ts | 79 +++- 9 files changed, 547 insertions(+), 18 deletions(-) create mode 100644 tests/integration/providers-consistency.test.ts diff --git a/package.json b/package.json index 17bca53..6a064d8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "opencontext-mcp": "./dist/mcp/index.js" }, "scripts": { - "build": "tsc", + "build": "npm --workspaces --if-present run build && tsc", "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts", "server": "tsx src/server.ts", diff --git a/packages/core/src/model/factory.ts b/packages/core/src/model/factory.ts index 3018788..8e54b24 100644 --- a/packages/core/src/model/factory.ts +++ b/packages/core/src/model/factory.ts @@ -1,4 +1,4 @@ -import { CanonicalContext, ContextType, ScopeId, NamespaceId } from './types.js'; +import { CanonicalContext, ContextType, ScopeId, NamespaceId, LifecycleState } from './types.js'; import { generateUlid } from '../identity/ulid.js'; import { computeContentHash } from '../identity/hash.js'; @@ -7,6 +7,7 @@ export interface CreateContextOptions { namespace?: NamespaceId; scope?: ScopeId; type?: ContextType; + lifecycle?: LifecycleState; content: { text?: string; structured?: Record; @@ -47,6 +48,6 @@ export function createCanonicalContext(opts: CreateContextOptions): CanonicalCon version: { revision: 1, }, - lifecycle: 'active', + lifecycle: opts.lifecycle ?? 'active', }; } diff --git a/packages/provider-sdk/src/base/memory-store.ts b/packages/provider-sdk/src/base/memory-store.ts index 8c17c1d..015f1e6 100644 --- a/packages/provider-sdk/src/base/memory-store.ts +++ b/packages/provider-sdk/src/base/memory-store.ts @@ -55,8 +55,8 @@ export class MemoryContextStore implements ContextStore { const terms = q.fullText.toLowerCase().trim().split(/\s+/).filter(Boolean); items = items.filter((c) => { const text = (c.content.text ?? JSON.stringify(c.content.structured ?? {})).toLowerCase(); - const tags = Array.isArray(c.metadata?.tags) ? (c.metadata.tags as string[]).join(' ').toLowerCase() : ''; - const combined = `${text} ${tags}`; + const meta = JSON.stringify(c.metadata ?? {}).toLowerCase(); + const combined = `${text} ${meta}`; return terms.every((term) => combined.includes(term)); }); } diff --git a/packages/provider-sdk/src/base/sql-dialects.ts b/packages/provider-sdk/src/base/sql-dialects.ts index e85f102..f0ac8f0 100644 --- a/packages/provider-sdk/src/base/sql-dialects.ts +++ b/packages/provider-sdk/src/base/sql-dialects.ts @@ -19,6 +19,7 @@ export class SqliteDialect implements SqlDialect { relationships_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + expires_at TEXT, revision INTEGER NOT NULL, lifecycle TEXT NOT NULL ); @@ -45,6 +46,7 @@ export class PostgresDialect implements SqlDialect { relationships_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, revision BIGINT NOT NULL, lifecycle VARCHAR(32) NOT NULL ); diff --git a/packages/provider-sdk/src/base/sql-store.ts b/packages/provider-sdk/src/base/sql-store.ts index 278ffc5..5456cba 100644 --- a/packages/provider-sdk/src/base/sql-store.ts +++ b/packages/provider-sdk/src/base/sql-store.ts @@ -59,6 +59,7 @@ export class SqlContextStore implements ContextStore { timestamps: { createdAt: typeof row.created_at === 'string' ? row.created_at : new Date(row.created_at).toISOString(), updatedAt: typeof row.updated_at === 'string' ? row.updated_at : new Date(row.updated_at).toISOString(), + ...(row.expires_at ? { expiresAt: typeof row.expires_at === 'string' ? row.expires_at : new Date(row.expires_at).toISOString() } : {}), }, version: { revision: Number(row.revision), @@ -70,8 +71,8 @@ export class SqlContextStore implements ContextStore { async put(ctx: CanonicalContext): Promise { const p = (i: number) => this.dialect.placeholder(i); const sql = ` - INSERT INTO contexts (id, namespace, scope, type, content_json, metadata_json, provenance_json, relationships_json, created_at, updated_at, revision, lifecycle) - VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)}, ${p(7)}, ${p(8)}, ${p(9)}, ${p(10)}, ${p(11)}, ${p(12)}) + INSERT INTO contexts (id, namespace, scope, type, content_json, metadata_json, provenance_json, relationships_json, created_at, updated_at, expires_at, revision, lifecycle) + VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)}, ${p(7)}, ${p(8)}, ${p(9)}, ${p(10)}, ${p(11)}, ${p(12)}, ${p(13)}) `; const params = [ ctx.id, @@ -84,6 +85,7 @@ export class SqlContextStore implements ContextStore { JSON.stringify(ctx.relationships ?? []), typeof ctx.timestamps.createdAt === 'string' ? ctx.timestamps.createdAt : new Date(ctx.timestamps.createdAt).toISOString(), typeof ctx.timestamps.updatedAt === 'string' ? ctx.timestamps.updatedAt : new Date(ctx.timestamps.updatedAt).toISOString(), + ctx.timestamps.expiresAt ? (typeof ctx.timestamps.expiresAt === 'string' ? ctx.timestamps.expiresAt : new Date(ctx.timestamps.expiresAt).toISOString()) : null, ctx.version.revision, ctx.lifecycle, ]; @@ -133,8 +135,10 @@ export class SqlContextStore implements ContextStore { if (q.fullText) { const terms = q.fullText.trim().split(/\s+/).filter(Boolean); for (const term of terms) { - conditions.push(`content_json LIKE ${this.dialect.placeholder(idx++)}`); - params.push(`%${term}%`); + const p1 = this.dialect.placeholder(idx++); + const p2 = this.dialect.placeholder(idx++); + conditions.push(`(content_json LIKE ${p1} OR metadata_json LIKE ${p2})`); + params.push(`%${term}%`, `%${term}%`); } } @@ -195,8 +199,8 @@ export class SqlContextStore implements ContextStore { const sql = ` UPDATE contexts SET content_json = ${p(1)}, metadata_json = ${p(2)}, provenance_json = ${p(3)}, relationships_json = ${p(4)}, - updated_at = ${p(5)}, revision = ${p(6)}, lifecycle = ${p(7)}, scope = ${p(8)}, type = ${p(9)} - WHERE id = ${p(10)} AND namespace = ${p(11)} AND revision = ${p(12)} + updated_at = ${p(5)}, expires_at = ${p(6)}, revision = ${p(7)}, lifecycle = ${p(8)}, scope = ${p(9)}, type = ${p(10)} + WHERE id = ${p(11)} AND namespace = ${p(12)} AND revision = ${p(13)} `; const res = await this.driver.exec(sql, [ @@ -205,6 +209,7 @@ export class SqlContextStore implements ContextStore { JSON.stringify(updatedCtx.provenance ?? {}), JSON.stringify(updatedCtx.relationships ?? []), updatedCtx.timestamps.updatedAt, + updatedCtx.timestamps.expiresAt ? (typeof updatedCtx.timestamps.expiresAt === 'string' ? updatedCtx.timestamps.expiresAt : new Date(updatedCtx.timestamps.expiresAt).toISOString()) : null, newRev, updatedCtx.lifecycle, updatedCtx.scope, diff --git a/packages/provider-sdk/tests/sql-store.test.ts b/packages/provider-sdk/tests/sql-store.test.ts index 3ef9b7b..58b57e6 100644 --- a/packages/provider-sdk/tests/sql-store.test.ts +++ b/packages/provider-sdk/tests/sql-store.test.ts @@ -99,7 +99,41 @@ describe('SQL Dialects', () => { expect(complexQuery.sql).toContain('scope IN ($2, $3)'); expect(complexQuery.sql).toContain('type IN ($4)'); expect(complexQuery.sql).toContain('lifecycle IN ($5)'); - expect(complexQuery.sql).toContain('content_json'); - expect(complexQuery.params).toEqual(['ns-1', 'scope-a', 'scope-b', 'decision', 'active', '%test%']); + expect(complexQuery.sql).toContain('(content_json LIKE $6 OR metadata_json LIKE $7)'); + expect(complexQuery.params).toEqual(['ns-1', 'scope-a', 'scope-b', 'decision', 'active', '%test%', '%test%']); + }); + + it('searches across both content_json and metadata_json with fullText', async () => { + const { createCanonicalContext } = await import('@opencontext/core'); + const memDb = new DatabaseSync(':memory:'); + const driver = { + query: async (sql: string, params: any[] = []) => { + const stmt = memDb.prepare(sql); + return stmt.all(...params); + }, + exec: async (sql: string, params: any[] = []) => { + const stmt = memDb.prepare(sql); + const res = stmt.run(...params); + return { changes: Number(res.changes) }; + }, + close: async () => { + try { + memDb.close(); + } catch {} + }, + }; + const store = new SqlContextStore('sqlite-test', new SqliteDialect(), driver); + await store.connect(); + + const c1 = createCanonicalContext({ + content: { text: 'General notes' }, + metadata: { tags: ['critical-architecture', 'backend'] }, + }); + await store.put(c1); + + const res = await store.query({ namespace: 'default', fullText: 'critical-architecture' }); + expect(res.items.length).toBe(1); + expect(res.items[0].id).toBe(c1.id); }); }); + diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4fe04b0..83ef9f8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,7 +17,7 @@ export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { let v2Store: ContextStore | undefined; let v1Store: ContextStoreV1Shim | ContextStoreAdapter | undefined; - const getStore = async (): Promise<{ v2: ContextStore; v1: ContextStoreV1Shim | ContextStoreAdapter }> => { + const getStore = async (): Promise<{ v2?: ContextStore; v1: ContextStoreV1Shim | ContextStoreAdapter }> => { if (v2Store && v1Store) { return { v2: v2Store, v1: v1Store }; } @@ -29,7 +29,7 @@ export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { return { v2: v2Store, v1: v1Store }; } v1Store = databaseUrlOrStore as ContextStoreAdapter; - return { v2: v2Store!, v1: v1Store }; + return { v2: undefined, v1: v1Store }; } const dsn = typeof databaseUrlOrStore === 'string' @@ -419,9 +419,25 @@ export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { { content: z.union([z.string(), z.record(z.string(), z.any())]).describe('The content to save (text string or structured JSON object)'), type: z - .enum(['fact', 'decision', 'preference', 'instruction', 'summary', 'checkpoint', 'insight', 'pattern']) + .enum([ + 'message', + 'fact', + 'decision', + 'constraint', + 'preference', + 'instruction', + 'artifact', + 'observation', + 'tool_result', + 'summary', + 'checkpoint', + 'insight', + 'pattern', + ]) .optional() - .describe('Type of context (fact, decision, preference, instruction, summary, checkpoint, insight, pattern)'), + .describe( + 'Type of context (message, fact, decision, constraint, preference, instruction, artifact, observation, tool_result, summary, checkpoint, insight, pattern)', + ), scope: z.string().optional().describe('Scope identifier (e.g. "global", "project:v2", "bubble:123")'), namespace: z.string().optional().describe('Namespace identifier (default: "default")'), metadata: z.record(z.string(), z.any()).optional().describe('Arbitrary metadata attributes'), @@ -442,6 +458,9 @@ export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { }, async (args) => { const { v2 } = await getStore(); + if (!v2) { + throw new Error('Underlying store does not support canonical v2 context operations (legacy adapter in use).'); + } const contentObj = typeof args.content === 'string' ? { text: args.content, mediaType: 'text/plain' } @@ -491,6 +510,9 @@ export function createMcpServer(databaseUrlOrStore?: StoreOrDsn) { }, async (args) => { const { v2 } = await getStore(); + if (!v2) { + throw new Error('Underlying store does not support canonical v2 context operations (legacy adapter in use).'); + } const results = await v2.query({ namespace: args.namespace || 'default', scope: args.scope, diff --git a/tests/integration/providers-consistency.test.ts b/tests/integration/providers-consistency.test.ts new file mode 100644 index 0000000..63388a5 --- /dev/null +++ b/tests/integration/providers-consistency.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { + createCanonicalContext, + type CanonicalContext, + type ContextType, +} from '@opencontext/core'; +import { + MemoryContextStore, + JsonContextStore, + SqlContextStore, + SqliteDialect, + ConcurrencyConflictError, + type ContextStore, +} from '@opencontext/provider-sdk'; + +interface ProviderFactory { + name: string; + create: () => Promise<{ store: ContextStore; cleanup: () => Promise }>; +} + +const providers: ProviderFactory[] = [ + { + name: 'MemoryContextStore', + create: async () => { + const store = new MemoryContextStore(); + return { + store, + cleanup: async () => { + await store.disconnect(); + }, + }; + }, + }, + { + name: 'JsonContextStore', + create: async () => { + const dir = join(tmpdir(), `opencontext-json-integration-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, 'contexts.json'); + const store = new JsonContextStore(filePath); + return { + store, + cleanup: async () => { + await store.disconnect(); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + }, + }; + }, + }, + { + name: 'SqlContextStore (SQLite)', + create: async () => { + const dir = join(tmpdir(), `opencontext-sqlite-integration-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, 'contexts.db'); + const db = new DatabaseSync(dbPath); + const driver = { + query: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + return stmt.all(...params); + }, + exec: async (sql: string, params: any[] = []) => { + const stmt = db.prepare(sql); + const res = stmt.run(...params); + return { changes: Number(res.changes) }; + }, + close: async () => { + try { + db.close(); + } catch {} + }, + }; + const store = new SqlContextStore('sqlite-int', new SqliteDialect(), driver); + return { + store, + cleanup: async () => { + await store.disconnect(); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + }, + }; + }, + }, +]; + +describe('Cross-Provider Integration & Consistency Test Suite', () => { + for (const provider of providers) { + describe(`Provider: ${provider.name}`, () => { + let store: ContextStore; + let cleanup: () => Promise; + + beforeEach(async () => { + const instance = await provider.create(); + store = instance.store; + cleanup = instance.cleanup; + await store.connect(); + }); + + afterEach(async () => { + await cleanup(); + }); + + it('connects, pings, and disconnects cleanly', async () => { + await expect(store.ping()).resolves.toBeUndefined(); + }); + + it('maintains full model fidelity on put and get', async () => { + const ctx = createCanonicalContext({ + content: { + text: 'Architecture decision on storage provider SPI', + structured: { key: 'value', numbers: [1, 2, 3], nested: { ok: true } }, + mediaType: 'application/json', + }, + type: 'decision', + scope: 'project:architecture', + namespace: 'custom-ns', + metadata: { priority: 'high', tags: ['storage', 'spi', 'v2'] }, + relationships: [ + { targetId: 'ctx_target_123', relation: 'supersedes', metadata: { reason: 'v1 deprecation' } }, + ], + actor: 'agent', + agentId: 'agent_architect_01', + sourceUri: 'file:///specs/spi-design.md', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + }); + + const saved = await store.put(ctx); + expect(saved.id).toBe(ctx.id); + expect(saved.version.revision).toBe(1); + + const retrieved = await store.get(ctx.id, 'custom-ns'); + expect(retrieved).toBeDefined(); + expect(retrieved!.id).toBe(ctx.id); + expect(retrieved!.namespace).toBe('custom-ns'); + expect(retrieved!.scope).toBe('project:architecture'); + expect(retrieved!.type).toBe('decision'); + expect(retrieved!.content.text).toBe('Architecture decision on storage provider SPI'); + expect(retrieved!.content.structured).toEqual({ key: 'value', numbers: [1, 2, 3], nested: { ok: true } }); + expect(retrieved!.content.mediaType).toBe('application/json'); + expect(retrieved!.metadata).toEqual({ priority: 'high', tags: ['storage', 'spi', 'v2'] }); + expect(retrieved!.provenance.actor).toBe('agent'); + expect(retrieved!.provenance.agentId).toBe('agent_architect_01'); + expect(retrieved!.provenance.sourceUri).toBe('file:///specs/spi-design.md'); + expect(retrieved!.relationships).toHaveLength(1); + expect(retrieved!.relationships[0].targetId).toBe('ctx_target_123'); + expect(retrieved!.relationships[0].relation).toBe('supersedes'); + expect(retrieved!.relationships[0].metadata).toEqual({ reason: 'v1 deprecation' }); + expect(retrieved!.timestamps.createdAt).toBe(ctx.timestamps.createdAt); + expect(retrieved!.timestamps.updatedAt).toBe(ctx.timestamps.updatedAt); + expect(retrieved!.timestamps.expiresAt).toBe(ctx.timestamps.expiresAt); + expect(retrieved!.version.revision).toBe(1); + expect(retrieved!.lifecycle).toBe('active'); + }); + + it('enforces optimistic concurrency control across updates', async () => { + const ctx = createCanonicalContext({ + content: { text: 'Base version' }, + scope: 'project:concurrency', + }); + await store.put(ctx); + + // Update revision 1 -> 2 + const updated1 = await store.update(ctx.id, 'default', 1, { + content: { text: 'Second revision' }, + }); + expect(updated1.version.revision).toBe(2); + expect(updated1.content.text).toBe('Second revision'); + + // Conflicting update with stale revision 1 must throw ConcurrencyConflictError + await expect( + store.update(ctx.id, 'default', 1, { + content: { text: 'Conflicting stale update' }, + }) + ).rejects.toThrow(ConcurrencyConflictError); + + // Update revision 2 -> 3 + const updated2 = await store.update(ctx.id, 'default', 2, { + content: { text: 'Third revision' }, + }); + expect(updated2.version.revision).toBe(3); + expect(updated2.content.text).toBe('Third revision'); + }); + + it('consistently performs soft and hard deletion', async () => { + const ctx = createCanonicalContext({ + content: { text: 'Context to be deleted' }, + scope: 'project:deletion', + }); + await store.put(ctx); + + // Soft delete + const softRes = await store.delete(ctx.id, 'default', false); + expect(softRes).toBe(true); + + const softItem = await store.get(ctx.id, 'default'); + expect(softItem).toBeDefined(); + expect(softItem!.lifecycle).toBe('soft_deleted'); + + // Hard delete + const hardRes = await store.delete(ctx.id, 'default', true); + expect(hardRes).toBe(true); + + const hardItem = await store.get(ctx.id, 'default'); + expect(hardItem).toBeUndefined(); + + // Repeated delete returns false + const repeatRes = await store.delete(ctx.id, 'default', true); + expect(repeatRes).toBe(false); + }); + + it('filters queries consistently by namespace, multi-scope, type, lifecycle, and full-text', async () => { + const items = [ + createCanonicalContext({ + content: { text: 'Alpha TypeScript build configuration' }, + metadata: { category: 'build', tags: ['ts', 'config'] }, + type: 'fact', + scope: 'scope:alpha', + }), + createCanonicalContext({ + content: { text: 'Alpha database migration plan' }, + metadata: { category: 'db', tags: ['sql', 'migration'] }, + type: 'decision', + scope: 'scope:alpha', + }), + createCanonicalContext({ + content: { text: 'Beta performance benchmark results' }, + metadata: { category: 'perf', tags: ['metrics', 'benchmark'] }, + type: 'observation', + scope: 'scope:beta', + }), + createCanonicalContext({ + content: { text: 'Gamma obsolete design pattern' }, + metadata: { category: 'deprecated', tags: ['legacy'] }, + type: 'pattern', + scope: 'scope:gamma', + lifecycle: 'soft_deleted', + }), + ]; + + for (const item of items) { + await store.put(item); + } + + // Multi-scope query + const multiScopeRes = await store.query({ + namespace: 'default', + scope: ['scope:alpha', 'scope:beta'], + }); + expect(multiScopeRes.items.length).toBe(3); + + // Type query + const typeRes = await store.query({ + namespace: 'default', + types: ['decision', 'observation'], + }); + expect(typeRes.items.length).toBe(2); + + // Lifecycle query (including soft_deleted) + const lifecycleRes = await store.query({ + namespace: 'default', + lifecycle: ['soft_deleted'], + }); + expect(lifecycleRes.items.length).toBe(1); + expect(lifecycleRes.items[0].id).toBe(items[3].id); + + // Full-text search in content + const ftContentRes = await store.query({ + namespace: 'default', + fullText: 'TypeScript build', + }); + expect(ftContentRes.items.length).toBe(1); + expect(ftContentRes.items[0].id).toBe(items[0].id); + + // Full-text search in metadata tags + const ftMetaRes = await store.query({ + namespace: 'default', + fullText: 'migration', + }); + expect(ftMetaRes.items.length).toBe(1); + expect(ftMetaRes.items[0].id).toBe(items[1].id); + }); + + it('executes complex batch mutations consistently', async () => { + const c1 = createCanonicalContext({ content: { text: 'Initial 1' } }); + const c2 = createCanonicalContext({ content: { text: 'Initial 2' } }); + const c3 = createCanonicalContext({ content: { text: 'Initial 3' } }); + + await store.put(c1); + await store.put(c2); + + const batchResult = await store.batch({ + puts: [c3], + updates: [ + { + id: c1.id, + expectedRevision: 1, + patch: { content: { text: 'Initial 1 Mutated' } }, + }, + ], + deletes: [c2.id], + }); + + expect(batchResult.applied).toBe(true); + + const res1 = await store.get(c1.id, 'default'); + expect(res1?.content.text).toBe('Initial 1 Mutated'); + expect(res1?.version.revision).toBe(2); + + const res2 = await store.get(c2.id, 'default'); + expect(res2).toBeUndefined(); + + const res3 = await store.get(c3.id, 'default'); + expect(res3?.content.text).toBe('Initial 3'); + }); + }); + } + + describe('Cross-Provider Query Equivalence Verification', () => { + let stores: { name: string; store: ContextStore; cleanup: () => Promise }[] = []; + + beforeEach(async () => { + stores = []; + for (const provider of providers) { + const instance = await provider.create(); + await instance.store.connect(); + stores.push({ name: provider.name, store: instance.store, cleanup: instance.cleanup }); + } + }); + + afterEach(async () => { + for (const { cleanup } of stores) { + await cleanup(); + } + }); + + it('returns identical result sets across Memory, JSON, and SQLite for identical datasets', async () => { + const types: ContextType[] = ['fact', 'decision', 'preference', 'instruction', 'artifact', 'observation', 'summary', 'checkpoint']; + const dataset: CanonicalContext[] = types.map((type, i) => + createCanonicalContext({ + content: { text: `Dataset entry ${i + 1} with topic keyword-${(i % 3) + 1}` }, + type, + scope: `scope-${(i % 2) + 1}`, + metadata: { index: i + 1, tag: `tag-${i + 1}`, metaKey: `meta-value-${(i % 3) + 1}` }, + }) + ); + + // Seed all stores with exact same data + for (const { store } of stores) { + for (const item of dataset) { + await store.put(item); + } + } + + // Define distinct query scenarios + const queryScenarios = [ + { name: 'all items ordered by createdAt asc', q: { namespace: 'default', pagination: { order: 'asc' as const, orderBy: 'createdAt' as const } } }, + { name: 'scope-1 items', q: { namespace: 'default', scope: 'scope-1' } }, + { name: 'fact & decision types', q: { namespace: 'default', types: ['fact', 'decision'] } }, + { name: 'full text content search for keyword-2', q: { namespace: 'default', fullText: 'keyword-2' } }, + { name: 'full text metadata search for meta-value-1', q: { namespace: 'default', fullText: 'meta-value-1' } }, + ]; + + for (const scenario of queryScenarios) { + const results = await Promise.all( + stores.map(async ({ name, store }) => ({ + name, + res: await store.query(scenario.q), + })) + ); + + const memoryIds = results[0].res.items.map((it) => it.id); + const jsonIds = results[1].res.items.map((it) => it.id); + const sqliteIds = results[2].res.items.map((it) => it.id); + + expect(jsonIds, `JSON provider results should match Memory provider for scenario: ${scenario.name}`).toEqual(memoryIds); + expect(sqliteIds, `SQLite provider results should match Memory provider for scenario: ${scenario.name}`).toEqual(memoryIds); + } + }); + }); +}); diff --git a/tests/mcp/server.test.ts b/tests/mcp/server.test.ts index bfe19c7..6a03561 100644 --- a/tests/mcp/server.test.ts +++ b/tests/mcp/server.test.ts @@ -269,12 +269,19 @@ describe('MCP Server', () => { describe('MCP Server v2 Tools', () => { let server: any; + let client: Client; let rawStore: MemoryContextStore; beforeEach(async () => { rawStore = new MemoryContextStore(); await rawStore.connect(); - server = await createServer(rawStore as any); + server = createServer(rawStore as any); + client = new Client({ name: 'v2-test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); }); it('preserves legacy save_context and recall_context tool execution', async () => { @@ -304,4 +311,74 @@ describe('MCP Server v2 Tools', () => { }); expect(queryRes.content[0].text).toContain('Decision to use OCM 2.0'); }); + + it('supports all standard context types in save_canonical_context', async () => { + const standardTypes = [ + 'message', + 'fact', + 'decision', + 'constraint', + 'preference', + 'instruction', + 'artifact', + 'observation', + 'tool_result', + 'summary', + 'checkpoint', + 'insight', + 'pattern', + ] as const; + + for (const type of standardTypes) { + const res = await client.callTool({ + name: 'save_canonical_context', + arguments: { + content: `Content for ${type}`, + type, + scope: 'project:types', + }, + }); + const text = (res.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain(`Type: ${type}`); + } + }); + + it('throws a clean error when v2 canonical tools are called with a legacy adapter', async () => { + const mockLegacyAdapter = { + saveContext: async () => ({ id: '1', content: 'legacy', tags: [], source: 'test', createdAt: '', updatedAt: '' }), + recallContext: async () => [], + listContexts: async () => [], + deleteContext: async () => true, + searchContexts: async () => [], + updateContext: async () => null, + createBubble: async () => ({ id: 'b1', name: 'bubble', createdAt: '', updatedAt: '' }), + listBubbles: async () => [], + getBubble: async () => null, + updateBubble: async () => null, + deleteBubble: async () => true, + listContextsByBubble: async () => [], + close: async () => {}, + }; + + const legacyServer: any = createMcpServer(mockLegacyAdapter as any); + + // Legacy tool should work + const legRes = await legacyServer.handleToolCall('save_context', { content: 'legacy works' }); + expect(legRes.content[0].text).toContain('Saved context with ID: 1'); + + // Canonical tools should throw a clean error + await expect( + legacyServer.handleToolCall('save_canonical_context', { + content: 'Should fail', + type: 'decision', + }) + ).rejects.toThrow(/legacy adapter in use|not supported/i); + + await expect( + legacyServer.handleToolCall('query_canonical_context', { + namespace: 'default', + }) + ).rejects.toThrow(/legacy adapter in use|not supported/i); + }); }); +