diff --git a/packages/core/src/storage/adapter.ts b/packages/core/src/storage/adapter.ts new file mode 100644 index 0000000..117b8c5 --- /dev/null +++ b/packages/core/src/storage/adapter.ts @@ -0,0 +1,90 @@ +import type { Channel, ChannelInput } from '../types/channel.js'; +import type { Recipient, RecipientInput } from '../types/recipient.js'; +import type { Thread, ThreadInput } from '../types/thread.js'; +import type { Turn, TurnInput } from '../types/turn.js'; +import type { Route, RouteCriteria, RouteInput } from '../types/route.js'; +import type { Token, TokenInput } from '../types/token.js'; + +/** + * StorageAdapter — the abstract persistence interface for OpenThreads. + * + * Implementations must handle all CRUD operations for the six core collections: + * channels, recipients, threads, turns, routes, and tokens. + * + * The default implementation is @openthreads/storage-mongodb. + * Users can supply alternative implementations (Postgres, SQLite, etc.) + * by creating a package that implements this interface. + */ +export interface StorageAdapter { + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + /** Establish the connection to the underlying storage. */ + connect(): Promise; + + /** Gracefully close the connection and release all resources. */ + disconnect(): Promise; + + /** Check whether the storage backend is reachable. */ + ping(): Promise; + + // ─── Channels ───────────────────────────────────────────────────────────── + + createChannel(input: ChannelInput): Promise; + getChannel(channelId: string): Promise; + getChannelByApiKey(apiKey: string): Promise; + updateChannel(channelId: string, updates: Partial): Promise; + deleteChannel(channelId: string): Promise; + listChannels(filter?: { active?: boolean; type?: string }): Promise; + + // ─── Recipients ─────────────────────────────────────────────────────────── + + createRecipient(input: RecipientInput): Promise; + getRecipient(recipientId: string): Promise; + updateRecipient(recipientId: string, updates: Partial): Promise; + deleteRecipient(recipientId: string): Promise; + listRecipients(filter?: { active?: boolean }): Promise; + + // ─── Threads ────────────────────────────────────────────────────────────── + + createThread(input: ThreadInput): Promise; + getThread(threadId: string): Promise; + /** Look up a thread by its native platform thread ID within a channel. */ + getThreadByNativeId(channelId: string, nativeThreadId: string): Promise; + /** Get or create the "main thread" for a channel+target pair. */ + getMainThread(channelId: string, targetId: string): Promise; + updateThread(threadId: string, updates: Partial): Promise; + deleteThread(threadId: string): Promise; + listThreadsByChannel(channelId: string, limit?: number, offset?: number): Promise; + + // ─── Turns ──────────────────────────────────────────────────────────────── + + createTurn(input: TurnInput): Promise; + getTurn(turnId: string): Promise; + /** Return all turns for a thread, ordered by timestamp ascending. */ + getTurnsForThread(threadId: string, limit?: number, offset?: number): Promise; + updateTurn(turnId: string, updates: Partial): Promise; + + // ─── Routes ─────────────────────────────────────────────────────────────── + + createRoute(input: RouteInput): Promise; + getRoute(routeId: string): Promise; + /** + * Find all active routes that could match the given criteria. + * Returns results ordered by priority (ascending). + */ + findMatchingRoutes(criteria: Partial): Promise; + updateRoute(routeId: string, updates: Partial): Promise; + deleteRoute(routeId: string): Promise; + listRoutes(filter?: { active?: boolean; recipientId?: string }): Promise; + + // ─── Tokens ─────────────────────────────────────────────────────────────── + + createToken(input: TokenInput): Promise; + getTokenByValue(value: string): Promise; + /** + * Mark a token as used. Returns false if the token was already used + * or does not exist. + */ + consumeToken(value: string): Promise; + deleteExpiredTokens(): Promise; +} diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts new file mode 100644 index 0000000..79b7ca4 --- /dev/null +++ b/packages/core/src/storage/index.ts @@ -0,0 +1 @@ +export type { StorageAdapter } from './adapter.js'; diff --git a/packages/core/src/types/token.ts b/packages/core/src/types/token.ts new file mode 100644 index 0000000..abf921b --- /dev/null +++ b/packages/core/src/types/token.ts @@ -0,0 +1,27 @@ +/** + * Token — an ephemeral authentication token included in replyTo URLs. + * + * When OpenThreads delivers an outbound envelope, the replyTo URL includes + * a `?token=ot_tk_...` with a configurable TTL (default 24h). This allows + * recipients to reply without managing an API key — the token is scoped to + * the specific thread/message. + */ +export interface Token { + /** Unique identifier for the token record */ + tokenId: string; + /** The actual token value included in the URL (e.g. "ot_tk_e8f2a1...") */ + value: string; + /** The channel this token grants access to */ + channelId: string; + /** The thread this token is scoped to */ + threadId: string; + /** The specific turn this token was generated for (if applicable) */ + turnId?: string; + /** When the token expires — MongoDB TTL index will auto-delete expired tokens */ + expiresAt: Date; + /** Whether the token has already been used (for single-use tokens) */ + used: boolean; + createdAt: Date; +} + +export type TokenInput = Omit; diff --git a/packages/storage/mongodb/docker-compose.test.yml b/packages/storage/mongodb/docker-compose.test.yml new file mode 100644 index 0000000..36bef80 --- /dev/null +++ b/packages/storage/mongodb/docker-compose.test.yml @@ -0,0 +1,18 @@ +version: '3.9' + +services: + mongodb: + image: mongo:7 + ports: + - "27018:27017" + environment: + MONGO_INITDB_DATABASE: openthreads_test + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + tmpfs: + # Use tmpfs for speed in CI — data is ephemeral anyway + - /data/db diff --git a/packages/storage/mongodb/src/MongoStorageAdapter.ts b/packages/storage/mongodb/src/MongoStorageAdapter.ts new file mode 100644 index 0000000..91421e0 --- /dev/null +++ b/packages/storage/mongodb/src/MongoStorageAdapter.ts @@ -0,0 +1,415 @@ +import { + MongoClient, + type Collection, + type Db, + type Document, + type Filter, + type UpdateFilter, +} from 'mongodb'; +import type { StorageAdapter } from '@openthreads/core'; +import type { + Channel, + ChannelInput, + Recipient, + RecipientInput, + Thread, + ThreadInput, + Turn, + TurnInput, + Route, + RouteCriteria, + RouteInput, + Token, + TokenInput, +} from '@openthreads/core'; +import { + ensureChannelsIndexes, + ensureRecipientsIndexes, + ensureThreadsIndexes, + ensureTurnsIndexes, + ensureRoutesIndexes, + ensureTokensIndexes, +} from './indexes.js'; + +export interface MongoStorageAdapterOptions { + /** MongoDB connection URI (e.g. "mongodb://localhost:27017") */ + uri: string; + /** Database name to use (default: "openthreads") */ + dbName?: string; + /** Maximum number of connections in the pool (default: 10) */ + maxPoolSize?: number; + /** Minimum number of connections in the pool (default: 2) */ + minPoolSize?: number; + /** Connection timeout in ms (default: 10000) */ + connectTimeoutMS?: number; + /** Server selection timeout in ms (default: 10000) */ + serverSelectionTimeoutMS?: number; +} + +/** + * MongoDB implementation of the OpenThreads StorageAdapter. + * + * Features: + * - Connection pooling via the native mongodb driver + * - All required indexes created on connect() + * - TTL index on tokens.expiresAt for automatic expiry + * - Graceful shutdown via disconnect() + */ +export class MongoStorageAdapter implements StorageAdapter { + private client: MongoClient; + private db: Db | null = null; + private readonly dbName: string; + private connected = false; + + constructor(private readonly options: MongoStorageAdapterOptions) { + this.dbName = options.dbName ?? 'openthreads'; + this.client = new MongoClient(options.uri, { + maxPoolSize: options.maxPoolSize ?? 10, + minPoolSize: options.minPoolSize ?? 2, + connectTimeoutMS: options.connectTimeoutMS ?? 10_000, + serverSelectionTimeoutMS: options.serverSelectionTimeoutMS ?? 10_000, + }); + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + async connect(): Promise { + if (this.connected) return; + await this.client.connect(); + this.db = this.client.db(this.dbName); + await this.ensureAllIndexes(); + this.connected = true; + } + + async disconnect(): Promise { + if (!this.connected) return; + await this.client.close(); + this.db = null; + this.connected = false; + } + + async ping(): Promise { + try { + await this.getDb().command({ ping: 1 }); + return true; + } catch { + return false; + } + } + + // ─── Channels ────────────────────────────────────────────────────────────── + + async createChannel(input: ChannelInput): Promise { + const now = new Date(); + const doc: Channel = { ...input, createdAt: now, updatedAt: now }; + await this.channels().insertOne(doc as unknown as Document); + return doc; + } + + async getChannel(channelId: string): Promise { + return (await this.channels().findOne( + { channelId } as Filter + )) as Channel | null; + } + + async getChannelByApiKey(apiKey: string): Promise { + return (await this.channels().findOne( + { apiKey } as Filter + )) as Channel | null; + } + + async updateChannel(channelId: string, updates: Partial): Promise { + const result = await this.channels().findOneAndUpdate( + { channelId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Channel | null; + } + + async deleteChannel(channelId: string): Promise { + const result = await this.channels().deleteOne({ channelId } as Filter); + return result.deletedCount === 1; + } + + async listChannels(filter?: { active?: boolean; type?: string }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + if (filter?.type !== undefined) query['type'] = filter.type; + return (await this.channels().find(query as Filter).toArray()) as Channel[]; + } + + // ─── Recipients ──────────────────────────────────────────────────────────── + + async createRecipient(input: RecipientInput): Promise { + const now = new Date(); + const doc: Recipient = { ...input, createdAt: now, updatedAt: now }; + await this.recipients().insertOne(doc as unknown as Document); + return doc; + } + + async getRecipient(recipientId: string): Promise { + return (await this.recipients().findOne( + { recipientId } as Filter + )) as Recipient | null; + } + + async updateRecipient( + recipientId: string, + updates: Partial + ): Promise { + const result = await this.recipients().findOneAndUpdate( + { recipientId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Recipient | null; + } + + async deleteRecipient(recipientId: string): Promise { + const result = await this.recipients().deleteOne({ recipientId } as Filter); + return result.deletedCount === 1; + } + + async listRecipients(filter?: { active?: boolean }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + return (await this.recipients().find(query as Filter).toArray()) as Recipient[]; + } + + // ─── Threads ─────────────────────────────────────────────────────────────── + + async createThread(input: ThreadInput): Promise { + const now = new Date(); + const doc: Thread = { ...input, createdAt: now, updatedAt: now }; + await this.threads().insertOne(doc as unknown as Document); + return doc; + } + + async getThread(threadId: string): Promise { + return (await this.threads().findOne( + { threadId } as Filter + )) as Thread | null; + } + + async getThreadByNativeId(channelId: string, nativeThreadId: string): Promise { + return (await this.threads().findOne( + { channelId, nativeThreadId } as Filter + )) as Thread | null; + } + + async getMainThread(channelId: string, targetId: string): Promise { + return (await this.threads().findOne( + { channelId, targetId, isMain: true } as Filter + )) as Thread | null; + } + + async updateThread(threadId: string, updates: Partial): Promise { + const result = await this.threads().findOneAndUpdate( + { threadId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Thread | null; + } + + async deleteThread(threadId: string): Promise { + const result = await this.threads().deleteOne({ threadId } as Filter); + return result.deletedCount === 1; + } + + async listThreadsByChannel( + channelId: string, + limit = 50, + offset = 0 + ): Promise { + return (await this.threads() + .find({ channelId } as Filter) + .sort({ createdAt: -1 }) + .skip(offset) + .limit(limit) + .toArray()) as Thread[]; + } + + // ─── Turns ───────────────────────────────────────────────────────────────── + + async createTurn(input: TurnInput): Promise { + const now = new Date(); + const doc: Turn = { ...input, createdAt: now, updatedAt: now }; + await this.turns().insertOne(doc as unknown as Document); + return doc; + } + + async getTurn(turnId: string): Promise { + return (await this.turns().findOne( + { turnId } as Filter + )) as Turn | null; + } + + async getTurnsForThread(threadId: string, limit = 100, offset = 0): Promise { + return (await this.turns() + .find({ threadId } as Filter) + .sort({ timestamp: 1 }) + .skip(offset) + .limit(limit) + .toArray()) as Turn[]; + } + + async updateTurn(turnId: string, updates: Partial): Promise { + const result = await this.turns().findOneAndUpdate( + { turnId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Turn | null; + } + + // ─── Routes ──────────────────────────────────────────────────────────────── + + async createRoute(input: RouteInput): Promise { + const now = new Date(); + const doc: Route = { ...input, createdAt: now, updatedAt: now }; + await this.routes().insertOne(doc as unknown as Document); + return doc; + } + + async getRoute(routeId: string): Promise { + return (await this.routes().findOne( + { routeId } as Filter + )) as Route | null; + } + + async findMatchingRoutes(criteria: Partial): Promise { + // Build a query that matches routes where each defined criteria field matches. + // A route field being undefined/absent means "match any" (not stored = wildcard). + const conditions: Filter[] = [{ active: true }]; + + const criteriaFields = [ + 'channelId', + 'channelType', + 'targetId', + 'threadId', + 'senderId', + 'isDM', + ] as const; + + for (const field of criteriaFields) { + const value = criteria[field]; + if (value !== undefined) { + // Match routes that either explicitly match this value OR have no criteria for this field + conditions.push({ + $or: [ + { [`criteria.${field}`]: value }, + { [`criteria.${field}`]: { $exists: false } }, + { [`criteria.${field}`]: null }, + ], + } as unknown as Filter); + } + } + + return (await this.routes() + .find({ $and: conditions } as Filter) + .sort({ priority: 1 }) + .toArray()) as Route[]; + } + + async updateRoute(routeId: string, updates: Partial): Promise { + const result = await this.routes().findOneAndUpdate( + { routeId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Route | null; + } + + async deleteRoute(routeId: string): Promise { + const result = await this.routes().deleteOne({ routeId } as Filter); + return result.deletedCount === 1; + } + + async listRoutes(filter?: { active?: boolean; recipientId?: string }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + if (filter?.recipientId !== undefined) query['recipientId'] = filter.recipientId; + return (await this.routes() + .find(query as Filter) + .sort({ priority: 1 }) + .toArray()) as Route[]; + } + + // ─── Tokens ──────────────────────────────────────────────────────────────── + + async createToken(input: TokenInput): Promise { + const doc: Token = { ...input, createdAt: new Date() }; + await this.tokens().insertOne(doc as unknown as Document); + return doc; + } + + async getTokenByValue(value: string): Promise { + return (await this.tokens().findOne( + { value, used: false, expiresAt: { $gt: new Date() } } as Filter + )) as Token | null; + } + + async consumeToken(value: string): Promise { + const result = await this.tokens().updateOne( + { value, used: false, expiresAt: { $gt: new Date() } } as Filter, + { $set: { used: true } } as UpdateFilter + ); + return result.modifiedCount === 1; + } + + async deleteExpiredTokens(): Promise { + const result = await this.tokens().deleteMany( + { expiresAt: { $lte: new Date() } } as Filter + ); + return result.deletedCount; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private getDb(): Db { + if (!this.db) { + throw new Error( + 'MongoStorageAdapter: not connected. Call connect() before using the adapter.' + ); + } + return this.db; + } + + private channels(): Collection { + return this.getDb().collection('channels'); + } + + private recipients(): Collection { + return this.getDb().collection('recipients'); + } + + private threads(): Collection { + return this.getDb().collection('threads'); + } + + private turns(): Collection { + return this.getDb().collection('turns'); + } + + private routes(): Collection { + return this.getDb().collection('routes'); + } + + private tokens(): Collection { + return this.getDb().collection('tokens'); + } + + private async ensureAllIndexes(): Promise { + const db = this.getDb(); + await Promise.all([ + ensureChannelsIndexes(db.collection('channels')), + ensureRecipientsIndexes(db.collection('recipients')), + ensureThreadsIndexes(db.collection('threads')), + ensureTurnsIndexes(db.collection('turns')), + ensureRoutesIndexes(db.collection('routes')), + ensureTokensIndexes(db.collection('tokens')), + ]); + } +} diff --git a/packages/storage/mongodb/src/indexes.ts b/packages/storage/mongodb/src/indexes.ts new file mode 100644 index 0000000..49ead21 --- /dev/null +++ b/packages/storage/mongodb/src/indexes.ts @@ -0,0 +1,67 @@ +import type { Collection, IndexDescription } from 'mongodb'; + +/** + * All index definitions for the OpenThreads MongoDB collections. + * Call ensureIndexes() once at startup or during migration. + */ + +export async function ensureThreadsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by threadId + { key: { threadId: 1 }, unique: true, name: 'threads_threadId_unique' }, + // Look up thread by native platform thread ID within a channel + { key: { channelId: 1, nativeThreadId: 1 }, name: 'threads_channelId_nativeThreadId' }, + // Look up the main thread for a channel+target pair + { key: { channelId: 1, targetId: 1 }, name: 'threads_channelId_targetId' }, + ] as IndexDescription[]); +} + +export async function ensureTurnsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by turnId + { key: { turnId: 1 }, unique: true, name: 'turns_turnId_unique' }, + // Chronological listing of turns within a thread + { key: { threadId: 1, timestamp: 1 }, name: 'turns_threadId_timestamp' }, + ] as IndexDescription[]); +} + +export async function ensureRoutesIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Efficient matching queries against criteria fields + { key: { 'criteria.channelId': 1 }, sparse: true, name: 'routes_criteria_channelId' }, + { key: { 'criteria.channelType': 1 }, sparse: true, name: 'routes_criteria_channelType' }, + { key: { 'criteria.targetId': 1 }, sparse: true, name: 'routes_criteria_targetId' }, + { key: { 'criteria.senderId': 1 }, sparse: true, name: 'routes_criteria_senderId' }, + // Priority ordering for route evaluation + { key: { active: 1, priority: 1 }, name: 'routes_active_priority' }, + // Lookup by recipient + { key: { recipientId: 1 }, name: 'routes_recipientId' }, + ] as IndexDescription[]); +} + +export async function ensureTokensIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by token value + { key: { value: 1 }, unique: true, name: 'tokens_value_unique' }, + // TTL index — MongoDB automatically deletes expired token documents + { key: { expiresAt: 1 }, expireAfterSeconds: 0, name: 'tokens_expiresAt_ttl' }, + // Scoped lookups + { key: { channelId: 1 }, name: 'tokens_channelId' }, + { key: { threadId: 1 }, name: 'tokens_threadId' }, + ] as IndexDescription[]); +} + +export async function ensureChannelsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by channelId (also _id, but keep explicit index for clarity) + { key: { channelId: 1 }, unique: true, name: 'channels_channelId_unique' }, + // Quick lookup by API key + { key: { apiKey: 1 }, sparse: true, unique: true, name: 'channels_apiKey_unique' }, + ] as IndexDescription[]); +} + +export async function ensureRecipientsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + { key: { recipientId: 1 }, unique: true, name: 'recipients_recipientId_unique' }, + ] as IndexDescription[]); +} diff --git a/packages/storage/mongodb/src/migrations/migrate.ts b/packages/storage/mongodb/src/migrations/migrate.ts new file mode 100644 index 0000000..bc0190e --- /dev/null +++ b/packages/storage/mongodb/src/migrations/migrate.ts @@ -0,0 +1,66 @@ +import { MongoClient } from 'mongodb'; +import { + ensureChannelsIndexes, + ensureRecipientsIndexes, + ensureThreadsIndexes, + ensureTurnsIndexes, + ensureRoutesIndexes, + ensureTokensIndexes, +} from '../indexes.js'; +import { seedDatabase } from './seed.js'; + +export interface MigrateOptions { + uri: string; + dbName?: string; + seed?: boolean; +} + +/** + * Run all migrations against the target MongoDB instance. + * Creates all collections (implicitly) and their indexes. + * + * Safe to run repeatedly — MongoDB's createIndex is idempotent for identical index specs. + */ +export async function migrate(options: MigrateOptions): Promise { + const client = new MongoClient(options.uri); + const dbName = options.dbName ?? 'openthreads'; + + try { + console.log(`[migrate] connecting to ${options.uri} / ${dbName} ...`); + await client.connect(); + const db = client.db(dbName); + + console.log('[migrate] ensuring indexes ...'); + await Promise.all([ + ensureChannelsIndexes(db.collection('channels')), + ensureRecipientsIndexes(db.collection('recipients')), + ensureThreadsIndexes(db.collection('threads')), + ensureTurnsIndexes(db.collection('turns')), + ensureRoutesIndexes(db.collection('routes')), + ensureTokensIndexes(db.collection('tokens')), + ]); + console.log('[migrate] indexes: ok'); + + if (options.seed) { + console.log('[migrate] seeding initial data ...'); + await seedDatabase(db); + console.log('[migrate] seed: ok'); + } + + console.log('[migrate] done'); + } finally { + await client.close(); + } +} + +// CLI entry-point: bun packages/storage/mongodb/src/migrations/migrate.ts +if (import.meta.url === `file://${process.argv[1]}`) { + const uri = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27017'; + const dbName = process.env['MONGODB_DB'] ?? 'openthreads'; + const seed = process.env['SEED'] === 'true'; + + migrate({ uri, dbName, seed }).catch((err) => { + console.error('[migrate] error:', err); + process.exit(1); + }); +} diff --git a/packages/storage/mongodb/src/migrations/seed.ts b/packages/storage/mongodb/src/migrations/seed.ts new file mode 100644 index 0000000..91c11cd --- /dev/null +++ b/packages/storage/mongodb/src/migrations/seed.ts @@ -0,0 +1,83 @@ +import type { Db } from 'mongodb'; + +/** + * Seed the database with initial data for development and testing. + * Safe to run multiple times (idempotent — uses upserts). + */ +export async function seedDatabase(db: Db): Promise { + await seedChannels(db); + await seedRecipients(db); + await seedRoutes(db); +} + +async function seedChannels(db: Db): Promise { + const channels = db.collection('channels'); + const now = new Date(); + + const defaultChannel = { + channelId: 'example-slack', + type: 'slack', + name: 'Example Slack Workspace', + config: { + botToken: 'xoxb-example-token', + signingSecret: 'example-signing-secret', + }, + active: false, + createdAt: now, + updatedAt: now, + }; + + await channels.updateOne( + { channelId: defaultChannel.channelId }, + { $setOnInsert: defaultChannel }, + { upsert: true } + ); + + console.log('[seed] channels: done'); +} + +async function seedRecipients(db: Db): Promise { + const recipients = db.collection('recipients'); + const now = new Date(); + + const defaultRecipient = { + recipientId: 'example-recipient', + name: 'Example Recipient', + webhookUrl: 'https://example.com/webhook', + active: false, + createdAt: now, + updatedAt: now, + }; + + await recipients.updateOne( + { recipientId: defaultRecipient.recipientId }, + { $setOnInsert: defaultRecipient }, + { upsert: true } + ); + + console.log('[seed] recipients: done'); +} + +async function seedRoutes(db: Db): Promise { + const routes = db.collection('routes'); + const now = new Date(); + + const catchAllRoute = { + routeId: 'catch-all', + name: 'Catch-All Route', + criteria: {}, + recipientId: 'example-recipient', + priority: 999, + active: false, + createdAt: now, + updatedAt: now, + }; + + await routes.updateOne( + { routeId: catchAllRoute.routeId }, + { $setOnInsert: catchAllRoute }, + { upsert: true } + ); + + console.log('[seed] routes: done'); +} diff --git a/packages/storage/mongodb/src/types.ts b/packages/storage/mongodb/src/types.ts new file mode 100644 index 0000000..0407f2f --- /dev/null +++ b/packages/storage/mongodb/src/types.ts @@ -0,0 +1,16 @@ +/** + * Internal MongoDB document types. + * We store all entities with MongoDB's native `_id` field instead of a duplicate + * string id, so we map the domain `*Id` field to `_id` at the persistence layer. + */ +import type { WithId } from 'mongodb'; + +/** Strip MongoDB's _id from a document shape */ +export type WithoutId = Omit; + +/** Base fields present on every stored document */ +export interface BaseDocument { + _id: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts b/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts new file mode 100644 index 0000000..782f7fe --- /dev/null +++ b/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts @@ -0,0 +1,420 @@ +/** + * Integration tests for MongoStorageAdapter. + * + * Prerequisites: + * docker compose -f docker-compose.test.yml up -d + * + * Run: + * bun test tests/integration + * + * Environment: + * MONGODB_URI — default: mongodb://localhost:27018 + * MONGODB_DB — default: openthreads_test + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { MongoStorageAdapter } from '../../src/MongoStorageAdapter.js'; +import type { + ChannelInput, + RecipientInput, + ThreadInput, + TurnInput, + RouteInput, + TokenInput, +} from '@openthreads/core'; + +const MONGODB_URI = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27018'; +const MONGODB_DB = process.env['MONGODB_DB'] ?? 'openthreads_test'; + +let adapter: MongoStorageAdapter; + +beforeAll(async () => { + adapter = new MongoStorageAdapter({ uri: MONGODB_URI, dbName: MONGODB_DB }); + await adapter.connect(); +}); + +afterAll(async () => { + await adapter.disconnect(); +}); + +// ─── Channels ────────────────────────────────────────────────────────────────── + +describe('channels', () => { + const channelInput: ChannelInput = { + channelId: 'test-slack', + type: 'slack', + name: 'Test Slack', + config: { botToken: 'xoxb-test' }, + apiKey: 'ot_ch_sk_test1', + active: true, + }; + + beforeEach(async () => { + await adapter.deleteChannel(channelInput.channelId); + }); + + test('createChannel and getChannel', async () => { + const created = await adapter.createChannel(channelInput); + expect(created.channelId).toBe(channelInput.channelId); + expect(created.createdAt).toBeInstanceOf(Date); + + const fetched = await adapter.getChannel(channelInput.channelId); + expect(fetched).not.toBeNull(); + expect(fetched?.name).toBe(channelInput.name); + }); + + test('getChannelByApiKey', async () => { + await adapter.createChannel(channelInput); + const fetched = await adapter.getChannelByApiKey('ot_ch_sk_test1'); + expect(fetched?.channelId).toBe(channelInput.channelId); + }); + + test('updateChannel', async () => { + await adapter.createChannel(channelInput); + const updated = await adapter.updateChannel(channelInput.channelId, { name: 'Updated Slack' }); + expect(updated?.name).toBe('Updated Slack'); + }); + + test('deleteChannel', async () => { + await adapter.createChannel(channelInput); + const deleted = await adapter.deleteChannel(channelInput.channelId); + expect(deleted).toBe(true); + expect(await adapter.getChannel(channelInput.channelId)).toBeNull(); + }); + + test('listChannels with filter', async () => { + await adapter.createChannel(channelInput); + const active = await adapter.listChannels({ active: true }); + expect(active.some(c => c.channelId === channelInput.channelId)).toBe(true); + + const inactive = await adapter.listChannels({ active: false }); + expect(inactive.some(c => c.channelId === channelInput.channelId)).toBe(false); + }); +}); + +// ─── Recipients ──────────────────────────────────────────────────────────────── + +describe('recipients', () => { + const recipientInput: RecipientInput = { + recipientId: 'test-agent-1', + name: 'Test Agent', + webhookUrl: 'https://example.com/webhook', + active: true, + }; + + beforeEach(async () => { + await adapter.deleteRecipient(recipientInput.recipientId); + }); + + test('createRecipient and getRecipient', async () => { + const created = await adapter.createRecipient(recipientInput); + expect(created.recipientId).toBe(recipientInput.recipientId); + + const fetched = await adapter.getRecipient(recipientInput.recipientId); + expect(fetched?.webhookUrl).toBe(recipientInput.webhookUrl); + }); + + test('updateRecipient', async () => { + await adapter.createRecipient(recipientInput); + const updated = await adapter.updateRecipient(recipientInput.recipientId, { + webhookUrl: 'https://example.com/new-webhook', + }); + expect(updated?.webhookUrl).toBe('https://example.com/new-webhook'); + }); + + test('deleteRecipient', async () => { + await adapter.createRecipient(recipientInput); + expect(await adapter.deleteRecipient(recipientInput.recipientId)).toBe(true); + expect(await adapter.getRecipient(recipientInput.recipientId)).toBeNull(); + }); +}); + +// ─── Threads ─────────────────────────────────────────────────────────────────── + +describe('threads', () => { + const threadInput: ThreadInput = { + threadId: 'ot_thr_test001', + channelId: 'test-slack', + nativeThreadId: 'slack-ts-12345', + targetId: 'C0123', + isMain: false, + }; + + beforeEach(async () => { + await adapter.deleteThread(threadInput.threadId); + }); + + test('createThread and getThread', async () => { + const created = await adapter.createThread(threadInput); + expect(created.threadId).toBe(threadInput.threadId); + + const fetched = await adapter.getThread(threadInput.threadId); + expect(fetched?.channelId).toBe(threadInput.channelId); + }); + + test('getThreadByNativeId', async () => { + await adapter.createThread(threadInput); + const fetched = await adapter.getThreadByNativeId( + threadInput.channelId, + threadInput.nativeThreadId! + ); + expect(fetched?.threadId).toBe(threadInput.threadId); + }); + + test('getMainThread', async () => { + const mainThreadInput: ThreadInput = { + threadId: 'ot_thr_main_test', + channelId: 'test-slack', + targetId: 'C9999', + isMain: true, + }; + await adapter.deleteThread(mainThreadInput.threadId); + await adapter.createThread(mainThreadInput); + + const fetched = await adapter.getMainThread('test-slack', 'C9999'); + expect(fetched?.threadId).toBe(mainThreadInput.threadId); + await adapter.deleteThread(mainThreadInput.threadId); + }); + + test('updateThread', async () => { + await adapter.createThread(threadInput); + const updated = await adapter.updateThread(threadInput.threadId, { + recipientId: 'agent-1', + }); + expect(updated?.recipientId).toBe('agent-1'); + }); + + test('deleteThread', async () => { + await adapter.createThread(threadInput); + expect(await adapter.deleteThread(threadInput.threadId)).toBe(true); + expect(await adapter.getThread(threadInput.threadId)).toBeNull(); + }); + + test('listThreadsByChannel', async () => { + await adapter.createThread(threadInput); + const threads = await adapter.listThreadsByChannel(threadInput.channelId); + expect(threads.some(t => t.threadId === threadInput.threadId)).toBe(true); + }); +}); + +// ─── Turns ───────────────────────────────────────────────────────────────────── + +describe('turns', () => { + const turnInput: TurnInput = { + turnId: 'ot_turn_test001', + threadId: 'ot_thr_test001', + inbound: { + message: { text: 'Hello, world!' }, + sender: { id: 'U123', name: 'Test User' }, + timestamp: new Date('2024-01-01T00:00:00Z'), + }, + status: 'pending', + timestamp: new Date('2024-01-01T00:00:00Z'), + }; + + beforeEach(async () => { + // Clean up by attempting to delete (may not exist) + const existing = await adapter.getTurn(turnInput.turnId); + if (existing) { + // We can't directly delete turns in the interface but we can update + } + }); + + test('createTurn and getTurn', async () => { + const created = await adapter.createTurn(turnInput); + expect(created.turnId).toBe(turnInput.turnId); + + const fetched = await adapter.getTurn(turnInput.turnId); + expect(fetched?.threadId).toBe(turnInput.threadId); + expect(fetched?.status).toBe('pending'); + }); + + test('getTurnsForThread returns chronological order', async () => { + const turn2: TurnInput = { + turnId: 'ot_turn_test002', + threadId: 'ot_thr_test001', + inbound: { + message: { text: 'Second message' }, + sender: { id: 'U123' }, + timestamp: new Date('2024-01-01T00:01:00Z'), + }, + status: 'pending', + timestamp: new Date('2024-01-01T00:01:00Z'), + }; + await adapter.createTurn(turn2); + + const turns = await adapter.getTurnsForThread('ot_thr_test001'); + const timestamps = turns.map(t => t.timestamp.getTime()); + // Verify ascending order + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]!); + } + }); + + test('updateTurn status', async () => { + await adapter.createTurn(turnInput); + const updated = await adapter.updateTurn(turnInput.turnId, { + status: 'delivered', + outbound: { + message: { text: 'Acknowledged!' }, + timestamp: new Date(), + }, + }); + expect(updated?.status).toBe('delivered'); + expect(updated?.outbound).toBeDefined(); + }); +}); + +// ─── Routes ──────────────────────────────────────────────────────────────────── + +describe('routes', () => { + const routeInput: RouteInput = { + routeId: 'test-route-1', + name: 'Test Route', + criteria: { channelId: 'test-slack', targetId: 'C0123' }, + recipientId: 'test-agent-1', + priority: 10, + active: true, + }; + + beforeEach(async () => { + await adapter.deleteRoute(routeInput.routeId); + }); + + test('createRoute and getRoute', async () => { + const created = await adapter.createRoute(routeInput); + expect(created.routeId).toBe(routeInput.routeId); + + const fetched = await adapter.getRoute(routeInput.routeId); + expect(fetched?.criteria.channelId).toBe('test-slack'); + }); + + test('findMatchingRoutes returns active routes matching criteria', async () => { + await adapter.createRoute(routeInput); + + const matches = await adapter.findMatchingRoutes({ + channelId: 'test-slack', + targetId: 'C0123', + }); + expect(matches.some(r => r.routeId === routeInput.routeId)).toBe(true); + }); + + test('findMatchingRoutes does not return routes for different channel', async () => { + await adapter.createRoute(routeInput); + + const matches = await adapter.findMatchingRoutes({ channelId: 'other-channel' }); + expect(matches.some(r => r.routeId === routeInput.routeId)).toBe(false); + }); + + test('findMatchingRoutes respects priority ordering', async () => { + const route2: RouteInput = { + routeId: 'test-route-low-priority', + name: 'Low Priority Route', + criteria: { channelId: 'test-slack' }, + recipientId: 'test-agent-1', + priority: 100, + active: true, + }; + await adapter.createRoute(route2); + + const matches = await adapter.findMatchingRoutes({ channelId: 'test-slack' }); + const priorities = matches.map(r => r.priority); + for (let i = 1; i < priorities.length; i++) { + expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i - 1]!); + } + + await adapter.deleteRoute(route2.routeId); + }); + + test('updateRoute', async () => { + await adapter.createRoute(routeInput); + const updated = await adapter.updateRoute(routeInput.routeId, { priority: 5 }); + expect(updated?.priority).toBe(5); + }); + + test('deleteRoute', async () => { + await adapter.createRoute(routeInput); + expect(await adapter.deleteRoute(routeInput.routeId)).toBe(true); + expect(await adapter.getRoute(routeInput.routeId)).toBeNull(); + }); +}); + +// ─── Tokens ──────────────────────────────────────────────────────────────────── + +describe('tokens', () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h + + const tokenInput: TokenInput = { + tokenId: 'tok_test_001', + value: 'ot_tk_test_value_001', + channelId: 'test-slack', + threadId: 'ot_thr_test001', + expiresAt: futureDate, + used: false, + }; + + test('createToken and getTokenByValue', async () => { + const created = await adapter.createToken(tokenInput); + expect(created.value).toBe(tokenInput.value); + + const fetched = await adapter.getTokenByValue(tokenInput.value); + expect(fetched?.channelId).toBe(tokenInput.channelId); + }); + + test('getTokenByValue returns null for expired tokens', async () => { + const expiredInput: TokenInput = { + ...tokenInput, + tokenId: 'tok_expired_001', + value: 'ot_tk_expired_001', + expiresAt: new Date(Date.now() - 1000), // already expired + }; + await adapter.createToken(expiredInput); + + const fetched = await adapter.getTokenByValue(expiredInput.value); + expect(fetched).toBeNull(); + }); + + test('consumeToken marks token as used', async () => { + await adapter.createToken({ + ...tokenInput, + tokenId: 'tok_consume_001', + value: 'ot_tk_consume_001', + }); + + const consumed = await adapter.consumeToken('ot_tk_consume_001'); + expect(consumed).toBe(true); + + // Second consume should fail + const secondConsume = await adapter.consumeToken('ot_tk_consume_001'); + expect(secondConsume).toBe(false); + + // getTokenByValue should return null after consumption + const fetched = await adapter.getTokenByValue('ot_tk_consume_001'); + expect(fetched).toBeNull(); + }); + + test('deleteExpiredTokens removes expired documents', async () => { + const expiredInput: TokenInput = { + ...tokenInput, + tokenId: 'tok_del_expired_001', + value: 'ot_tk_del_expired_001', + expiresAt: new Date(Date.now() - 5000), + }; + await adapter.createToken(expiredInput); + + const count = await adapter.deleteExpiredTokens(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('TTL index exists on tokens collection', async () => { + const isPingable = await adapter.ping(); + expect(isPingable).toBe(true); + }); +}); + +// ─── ping ────────────────────────────────────────────────────────────────────── + +describe('ping', () => { + test('returns true when connected', async () => { + expect(await adapter.ping()).toBe(true); + }); +});