UNPKG

lorehub

Version:

Capture and surface the collective wisdom of your codebase

1,103 lines (928 loc) 33.9 kB
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { eq, and, like, or, sql, desc } from 'drizzle-orm'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import BetterSqlite3 from 'better-sqlite3'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from '../utils/uuid.js'; import { realms, lores, loreRelations, workspaces, realmWorkspaces, syncState, type Realm as DbRealm, type Lore as DbLore, type LoreRelation as DbLoreRelation, type Workspace as DbWorkspace, type RealmWorkspace as DbRealmWorkspace, type SyncState as DbSyncState } from './schema.js'; import type { Lore, LoreType, LoreStatus, CreateLoreInput, UpdateLoreInput, Realm, CreateRealmInput, CreateRelationInput, Relation, LoreRelation, RelationType, Workspace, CreateWorkspaceInput, UpdateWorkspaceInput, SyncState } from '../core/types.js'; import * as sqliteVec from 'sqlite-vec'; import { EmbeddingService } from '../core/embeddings.js'; import { getSearchCache } from '../core/search-cache.js'; import { ChangeTracker } from '../sync/change-tracker.js'; // Raw SQL result type (snake_case fields) interface RawLoreRow { id: string; realm_id: string; content: string; why: string | null; type: string; provinces: string; sigils: string; confidence: number; origin: string; status: string; created_at: string; updated_at: string; } export class Database { sqlite: BetterSqlite3.Database; // Made public for migrate-embeddings command private db: ReturnType<typeof drizzle>; private embeddingService: EmbeddingService; private changeTracker: ChangeTracker; constructor(dbPath: string) { // Ensure directory exists const dbDir = dirname(dbPath); if (dbDir && !existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); } this.sqlite = new BetterSqlite3(dbPath); // Load sqlite-vec extension this.loadSqliteVec(); // Configure SQLite for better performance this.sqlite.pragma('journal_mode = WAL'); this.sqlite.pragma('synchronous = NORMAL'); this.sqlite.pragma('foreign_keys = ON'); this.db = drizzle(this.sqlite); // Initialize embedding service this.embeddingService = EmbeddingService.getInstance(); // Initialize change tracker this.changeTracker = ChangeTracker.getInstance(); this.changeTracker.initialize(this); // Run Drizzle migrations this.runMigrations(); } private loadSqliteVec(): void { try { // Load sqlite-vec extension sqliteVec.load(this.sqlite); } catch (error) { console.error('Failed to load sqlite-vec extension:', error); // Continue without vector support - graceful degradation } } private runMigrations(): void { try { // Get the directory where this file is located const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Try different locations for migrations let migrationsFolder = join(__dirname, '..', 'drizzle'); // dist/drizzle if (!existsSync(migrationsFolder)) { migrationsFolder = join(__dirname, '..', '..', '..', 'drizzle'); // realm root drizzle } // For in-memory databases in tests, we need special handling if (this.sqlite.name === ':memory:') { if (!existsSync(migrationsFolder)) { throw new Error( `Drizzle migrations folder not found for tests. Expected at: ${join(__dirname, '..', 'drizzle')} or ${join(__dirname, '..', '..', '..', 'drizzle')}` ); } // For in-memory databases, we need to run migrations synchronously this.runMigrationsForInMemory(migrationsFolder); return; } if (!existsSync(migrationsFolder)) { throw new Error( `Drizzle migrations folder not found. Expected at: ${join(__dirname, '..', 'drizzle')} or ${join(__dirname, '..', '..', '..', 'drizzle')}` ); } // Run migrations migrate(this.db, { migrationsFolder }); } catch (error) { // If migrations fail, it might be because tables already exist // This is okay for existing databases if (error instanceof Error && error.message.includes('already exists')) { // Tables exist, that's fine return; } throw error; } } private runMigrationsForInMemory(migrationsFolder: string): void { // For in-memory databases, we need to read and execute migrations directly const journalPath = join(migrationsFolder, 'meta', '_journal.json'); if (!existsSync(journalPath)) { throw new Error(`Migration journal not found at: ${journalPath}`); } const journal = JSON.parse(readFileSync(journalPath, 'utf-8')); // Create migrations table this.sqlite.exec(` CREATE TABLE IF NOT EXISTS __drizzle_migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL UNIQUE, created_at numeric ); `); // Run each migration in order for (const entry of journal.entries) { const migrationPath = join(migrationsFolder, `${entry.tag}.sql`); if (!existsSync(migrationPath)) { throw new Error(`Migration file not found: ${migrationPath}`); } // Check if migration was already applied const existing = this.sqlite.prepare( 'SELECT hash FROM __drizzle_migrations WHERE hash = ?' ).get(entry.tag); if (!existing) { // Read and execute migration const migrationSQL = readFileSync(migrationPath, 'utf-8'); // Split by statement breakpoint and execute each statement const statements = migrationSQL.split('--> statement-breakpoint'); for (const statement of statements) { const trimmed = statement.trim(); if (trimmed) { this.sqlite.exec(trimmed); } } // Record migration as applied this.sqlite.prepare( 'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)' ).run(entry.tag, Date.now()); } } } close(): void { this.sqlite.close(); } // Testing utility listTables(): string[] { const tables = this.sqlite.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name `).all() as { name: string }[]; return tables.map(t => t.name); } // Transaction support transaction<T>(fn: () => T): T { return this.sqlite.transaction(fn)(); } // Realm methods (was Realm) createRealm(input: CreateRealmInput): Realm { const id = input.id || uuidv4(); const now = new Date(); const realm = { id, name: input.name, path: input.path, gitRemote: input.gitRemote || null, isMonorepo: input.isMonorepo || false, provinces: JSON.stringify(input.provinces || []), lastSeen: (input.lastSeen || now).toISOString(), createdAt: (input.createdAt || now).toISOString(), }; this.db.insert(realms).values(realm).run(); return this.findRealm(id)!; } findRealm(id: string): Realm | null { const result = this.db.select().from(realms).where(eq(realms.id, id)).get(); return result ? this.dbRealmToRealm(result) : null; } findRealmByPath(path: string): Realm | null { const result = this.db.select().from(realms).where(eq(realms.path, path)).get(); return result ? this.dbRealmToRealm(result) : null; } updateRealmLastSeen(id: string): Realm | null { this.db.update(realms) .set({ lastSeen: new Date().toISOString() }) .where(eq(realms.id, id)) .run(); return this.findRealm(id); } listRealms(): Realm[] { const results = this.db.select().from(realms).orderBy(desc(realms.lastSeen)).all(); return results.map(r => this.dbRealmToRealm(r)); } getCurrentRealm(): Realm | null { return this.findRealmByPath(process.cwd()); } // Lore methods (was Lore) async createLore(input: CreateLoreInput): Promise<Lore> { const id = input.id || uuidv4(); const now = new Date(); const lore = { id, realmId: input.realmId, content: input.content, why: input.why || null, type: input.type, provinces: JSON.stringify(input.provinces || []), sigils: JSON.stringify(input.sigils || []), confidence: input.confidence || 80, origin: JSON.stringify(input.origin), status: input.status || 'living', createdAt: (input.createdAt || now).toISOString(), updatedAt: (input.updatedAt || now).toISOString(), }; this.db.insert(lores).values(lore).run(); // Generate embedding for the new lore try { await this.generateLoreEmbedding(id); } catch (error) { console.error(`Failed to generate embedding for new lore ${id}:`, error); // Don't fail lore creation if embedding generation fails } const createdLore = this.findLore(id)!; // Track the change await this.changeTracker.recordLoreChange('create', id, input.realmId, createdLore); return createdLore; } findLore(id: string): Lore | null { const result = this.db.select().from(lores).where(eq(lores.id, id)).get(); return result ? this.dbLoreToLore(result) : null; } async updateLore(id: string, input: UpdateLoreInput): Promise<Lore | null> { const existingLore = this.findLore(id); if (!existingLore) return null; const updates: any = { updatedAt: new Date().toISOString(), }; if (input.content !== undefined) updates.content = input.content; if (input.why !== undefined) updates.why = input.why; if (input.type !== undefined) updates.type = input.type; if (input.provinces !== undefined) updates.provinces = JSON.stringify(input.provinces); if (input.sigils !== undefined) updates.sigils = JSON.stringify(input.sigils); if (input.confidence !== undefined) updates.confidence = input.confidence; if (input.origin !== undefined) updates.origin = JSON.stringify(input.origin); if (input.status !== undefined) updates.status = input.status; this.db.update(lores).set(updates).where(eq(lores.id, id)).run(); const updatedLore = this.findLore(id); // Track the change if (updatedLore) { await this.changeTracker.recordLoreChange('update', id, existingLore.realmId, updatedLore); } return updatedLore; } async deleteLore(id: string): Promise<void> { const lore = this.findLore(id); if (!lore) return; this.db.delete(lores).where(eq(lores.id, id)).run(); // Track the change await this.changeTracker.recordLoreChange('delete', id, lore.realmId, { id }); } async softDeleteLore(id: string): Promise<void> { const lore = this.findLore(id); if (!lore) return; this.db.update(lores) .set({ status: 'archived', updatedAt: new Date().toISOString() }) .where(eq(lores.id, id)) .run(); // Track the change await this.changeTracker.recordLoreChange('archive', id, lore.realmId, { id, status: 'archived' }); } restoreLore(id: string): void { this.db.update(lores) .set({ status: 'active', updatedAt: new Date().toISOString() }) .where(eq(lores.id, id)) .run(); } listLoresByRealm(realmId: string): Lore[] { const results = this.db.select() .from(lores) .where(eq(lores.realmId, realmId)) .orderBy(desc(lores.createdAt)) .all(); return results.map(l => this.dbLoreToLore(l)); } listLoresByType(realmId: string, type: LoreType): Lore[] { // Convert old type names to new ones if needed let queryType = type as string; if (queryType === 'decision') queryType = 'decree'; else if (queryType === 'learning') queryType = 'lesson'; else if (queryType === 'todo') queryType = 'quest'; const results = this.db.select() .from(lores) .where(and(eq(lores.realmId, realmId), eq(lores.type, queryType))) .orderBy(desc(lores.createdAt)) .all(); return results.map(l => this.dbLoreToLore(l)); } listLoresByProvince(realmId: string, province: string): Lore[] { // Use SQL LIKE with JSON array search pattern const results = this.db.select() .from(lores) .where(and( eq(lores.realmId, realmId), like(lores.provinces, `%"${province}"%`) )) .orderBy(desc(lores.createdAt)) .all(); return results.map(l => this.dbLoreToLore(l)); } searchLores(realmId: string, query: string, limit?: number): Lore[] { // Convert wildcards to SQL patterns const pattern = `%${query.replace(/\*/g, '%').replace(/\?/g, '_')}%`; const baseQuery = this.db.select() .from(lores) .where(and( eq(lores.realmId, realmId), or( like(lores.content, pattern), like(lores.sigils, pattern) ) )) .orderBy(desc(lores.createdAt)); const results = limit ? baseQuery.limit(limit).all() : baseQuery.all(); return results.map(l => this.dbLoreToLore(l)); } searchLoresGlobal(query: string, limit?: number): Lore[] { // Convert wildcards to SQL patterns const pattern = `%${query.replace(/\*/g, '%').replace(/\?/g, '_')}%`; const baseQuery = this.db.select() .from(lores) .where(or( like(lores.content, pattern), like(lores.sigils, pattern) )) .orderBy(desc(lores.createdAt)); const results = limit ? baseQuery.limit(limit).all() : baseQuery.all(); return results.map(l => this.dbLoreToLore(l)); } getRealmLoreCount(realmId: string): number { const result = this.db.select({ count: sql<number>`count(*)` }) .from(lores) .where(eq(lores.realmId, realmId)) .get(); return result?.count || 0; } // Vector/Semantic Search methods async semanticSearchLores( query: string, options: { realmId?: string; threshold?: number; limit?: number; includeScore?: boolean; } = {} ): Promise<(Lore & { similarity?: number })[]> { try { const cache = getSearchCache(); // Check cache for results const cachedResults = cache.getResults(query, options); if (cachedResults) { return cachedResults; } // Check cache for embedding let queryEmbedding = cache.getEmbedding(query); if (!queryEmbedding) { // Generate embedding for query queryEmbedding = await this.embeddingService.generateEmbedding(query); cache.setEmbedding(query, queryEmbedding); } // Build the query based on options let sqlQuery = ` SELECT l.*, vec_distance_l2(v.embedding, ?) as distance FROM lores l JOIN lores_vec v ON l.id = v.lore_id WHERE 1=1 `; const params: any[] = [queryEmbedding]; if (options.realmId) { sqlQuery += ` AND l.realm_id = ?`; params.push(options.realmId); } // Add threshold filter if specified if (options.threshold !== undefined) { sqlQuery += ` AND vec_distance_l2(v.embedding, ?) < ?`; params.push(queryEmbedding, options.threshold); } sqlQuery += ` ORDER BY distance ASC`; if (options.limit) { sqlQuery += ` LIMIT ?`; params.push(options.limit); } const results = this.sqlite.prepare(sqlQuery).all(...params) as (RawLoreRow & { distance: number })[]; const mappedResults = results.map(row => { const lore = this.dbLoreToLore(row); if (options.includeScore) { // Convert distance to similarity score (0-1, where 1 is most similar) // Using a simple inverse distance formula const similarity = 1 / (1 + row.distance); return { ...lore, similarity }; } return lore; }); // Cache the results cache.setResults(query, options, mappedResults); return mappedResults; } catch (error) { console.error('Semantic search failed:', error); // Fallback to regular search return this.searchLores(options.realmId || '', query, options.limit); } } async findSimilarLores( loreId: string, options: { limit?: number; threshold?: number } = {} ): Promise<(Lore & { similarity: number })[]> { const lore = this.findLore(loreId); if (!lore) return []; // Format lore for embedding const loreText = this.embeddingService.formatLoreForEmbedding({ content: lore.content, why: lore.why, sigils: lore.sigils, type: lore.type }); // Search for similar lores, excluding the original const results = await this.semanticSearchLores(loreText, { realmId: lore.realmId, limit: (options.limit || 10) + 1, // Get one extra to exclude self includeScore: true }); // Filter out the original lore and apply threshold const threshold = options.threshold || 0; return results .filter(l => l.id !== loreId && l.similarity !== undefined && l.similarity >= threshold) .slice(0, options.limit || 10) as (Lore & { similarity: number })[]; } async generateLoreEmbedding(loreId: string): Promise<void> { const lore = this.findLore(loreId); if (!lore) { throw new Error(`Lore ${loreId} not found`); } // Format lore for embedding const loreText = this.embeddingService.formatLoreForEmbedding({ content: lore.content, why: lore.why, sigils: lore.sigils, type: lore.type }); // Generate embedding const embedding = await this.embeddingService.generateEmbedding(loreText); // Store embedding try { // First try to delete existing embedding (for virtual tables, REPLACE might not work) this.sqlite.prepare(`DELETE FROM lores_vec WHERE lore_id = ?`).run(loreId); // Then insert the new embedding this.sqlite.prepare(` INSERT INTO lores_vec (lore_id, embedding) VALUES (?, ?) `).run(loreId, embedding); // Note: We no longer track embedding_generated in the lores table // The presence of a record in lores_vec indicates an embedding exists } catch (error) { console.error(`Failed to store embedding for lore ${loreId}:`, error); throw error; } } recreateLoresVecTable(dimensions: number): void { try { // Drop the existing table this.sqlite.exec('DROP TABLE IF EXISTS lores_vec'); // Recreate with new dimensions this.sqlite.exec(` CREATE VIRTUAL TABLE lores_vec USING vec0( lore_id text PRIMARY KEY, embedding float[${dimensions}] ) `); console.log(`✓ Recreated lores_vec table with ${dimensions} dimensions`); } catch (error) { console.error('Failed to recreate lores_vec table:', error); throw error; } } async generateMissingEmbeddings(realmId?: string, batchSize: number = 50, force: boolean = false): Promise<number> { let query: string; if (force) { // When force is true, get all lores regardless of existing embeddings query = `SELECT l.* FROM lores l WHERE 1=1`; } else { // Only get lores without embeddings query = ` SELECT l.* FROM lores l LEFT JOIN lores_vec v ON l.id = v.lore_id WHERE v.lore_id IS NULL `; } const params: any[] = []; if (realmId) { query += ` AND l.realm_id = ?`; params.push(realmId); } query += ` LIMIT ?`; params.push(batchSize); const loresToEmbed = this.sqlite.prepare(query).all(...params) as DbLore[]; let count = 0; for (const dbLore of loresToEmbed) { try { await this.generateLoreEmbedding(dbLore.id); count++; } catch (error) { console.error(`Failed to generate embedding for lore ${dbLore.id}:`, error); } } return count; } // Check if a lore might be a duplicate async checkForDuplicates( content: string, realmId: string, threshold: number = 0.85 ): Promise<(Lore & { similarity: number })[]> { const results = await this.semanticSearchLores(content, { realmId, threshold: 1 - threshold, // Convert similarity to distance threshold limit: 5, includeScore: true }); return results.filter(l => l.similarity !== undefined && l.similarity >= threshold) as (Lore & { similarity: number })[]; } // Relation methods async createRelation(input: CreateRelationInput): Promise<Relation> { const relation = { fromLoreId: input.fromLoreId, toLoreId: input.toLoreId, type: input.type, strength: input.strength || 1.0, metadata: input.metadata ? JSON.stringify(input.metadata) : null, createdAt: (input.createdAt || new Date()).toISOString(), }; this.db.insert(loreRelations).values(relation).run(); // Get realm from the fromLore const fromLore = this.findLore(input.fromLoreId); if (fromLore) { await this.changeTracker.recordRelationChange( 'create', input.fromLoreId, input.toLoreId, input.type, fromLore.realmId, relation ); } return this.dbRelationToRelation(relation as DbLoreRelation); } listRelationsByLore(loreId: string): LoreRelation[] { const results = this.db.select() .from(loreRelations) .where(or( eq(loreRelations.fromLoreId, loreId), eq(loreRelations.toLoreId, loreId) )) .all(); return results.map(r => this.dbRelationToRelation(r)); } async deleteRelation(fromLoreId: string, toLoreId: string, type: string): Promise<void> { // Get realm from the fromLore const fromLore = this.findLore(fromLoreId); this.db.delete(loreRelations) .where(and( eq(loreRelations.fromLoreId, fromLoreId), eq(loreRelations.toLoreId, toLoreId), eq(loreRelations.type, type) )) .run(); if (fromLore) { await this.changeTracker.recordRelationChange( 'delete', fromLoreId, toLoreId, type, fromLore.realmId ); } } // Export/Import methods exportData(realmId?: string): { realms: Realm[]; lores: Lore[]; relations: Relation[]; } { if (realmId) { const realm = this.findRealm(realmId); if (!realm) { throw new Error(`Realm ${realmId} not found`); } const realmLores = this.listLoresByRealm(realmId); const loreIds = new Set(realmLores.map(l => l.id)); const relevantRelations = this.db.select() .from(loreRelations) .all() .filter(r => loreIds.has(r.fromLoreId) || loreIds.has(r.toLoreId)) .map(r => this.dbRelationToRelation(r)); return { realms: [realm], lores: realmLores, relations: relevantRelations, }; } return { realms: this.listRealms(), lores: this.searchLoresGlobal('*'), relations: this.db.select().from(loreRelations).all().map(r => this.dbRelationToRelation(r)), }; } async importData( data: { realms: CreateRealmInput[]; lores: CreateLoreInput[]; relations: CreateRelationInput[]; }, mode: 'replace' | 'merge' = 'replace' ): Promise<void> { // Disable change tracking during import this.changeTracker.disable(); try { if (mode === 'replace') { // Clear existing data this.db.delete(loreRelations).run(); this.db.delete(lores).run(); this.db.delete(realms).run(); } // Import realms for (const realm of data.realms) { if (mode === 'merge') { const existing = this.findRealm(realm.id!); if (existing) continue; } this.createRealm(realm); } // Import lores for (const lore of data.lores) { if (mode === 'merge') { const existing = this.findLore(lore.id!); if (existing) continue; } await this.createLore(lore); } // Import relations for (const relation of data.relations) { if (mode === 'merge') { // Check if relation already exists const existing = this.db.select() .from(loreRelations) .where(and( eq(loreRelations.fromLoreId, relation.fromLoreId), eq(loreRelations.toLoreId, relation.toLoreId), eq(loreRelations.type, relation.type) )) .get(); if (existing) continue; } await this.createRelation(relation); } } finally { // Re-enable change tracking this.changeTracker.enable(); } } // Helper methods to convert between DB and domain types private dbRealmToRealm(dbRealm: DbRealm): Realm { return { id: dbRealm.id, name: dbRealm.name, path: dbRealm.path, gitRemote: dbRealm.gitRemote || undefined, isMonorepo: dbRealm.isMonorepo, provinces: JSON.parse(dbRealm.provinces), lastSeen: new Date(dbRealm.lastSeen), createdAt: new Date(dbRealm.createdAt), }; } private dbLoreToLore(dbLore: DbLore | RawLoreRow): Lore { // Check if it's a raw SQL result (has snake_case fields) const isRawRow = 'realm_id' in dbLore; return { id: dbLore.id, realmId: isRawRow ? (dbLore as RawLoreRow).realm_id : (dbLore as DbLore).realmId, content: dbLore.content, why: dbLore.why || undefined, type: dbLore.type as LoreType, provinces: JSON.parse(dbLore.provinces), sigils: JSON.parse(dbLore.sigils), confidence: dbLore.confidence, origin: JSON.parse(dbLore.origin), status: dbLore.status as LoreStatus, createdAt: new Date(isRawRow ? (dbLore as RawLoreRow).created_at : (dbLore as DbLore).createdAt), updatedAt: new Date(isRawRow ? (dbLore as RawLoreRow).updated_at : (dbLore as DbLore).updatedAt), }; } private dbRelationToRelation(dbRelation: DbLoreRelation): LoreRelation { return { fromLoreId: dbRelation.fromLoreId, toLoreId: dbRelation.toLoreId, type: dbRelation.type as RelationType, strength: dbRelation.strength, metadata: dbRelation.metadata ? JSON.parse(dbRelation.metadata) : undefined, createdAt: new Date(dbRelation.createdAt), }; } // Workspace methods createWorkspace(input: CreateWorkspaceInput): Workspace { const id = input.id || uuidv4(); const now = new Date(); // Check if name already exists const existing = this.db.select().from(workspaces) .where(eq(workspaces.name, input.name)) .get(); if (existing) { throw new Error(`Workspace with name '${input.name}' already exists`); } // If this is the first workspace or isDefault is true, set it as default const workspaceCount = this.db.select({ count: sql<number>`count(*)` }) .from(workspaces) .get()?.count || 0; const shouldBeDefault = input.isDefault || workspaceCount === 0; // If setting as default, unset any existing default if (shouldBeDefault) { this.db.update(workspaces) .set({ isDefault: false }) .where(eq(workspaces.isDefault, true)) .run(); } const workspace = { id, name: input.name, syncEnabled: input.syncEnabled || false, syncRepo: input.syncRepo || null, syncBranch: input.syncBranch || 'main', autoSync: input.autoSync ?? true, syncInterval: input.syncInterval || 300, filters: input.filters ? JSON.stringify(input.filters) : null, isDefault: shouldBeDefault, createdAt: (input.createdAt || now).toISOString(), updatedAt: (input.updatedAt || now).toISOString(), }; this.db.insert(workspaces).values(workspace).run(); return this.findWorkspace(id)!; } findWorkspace(id: string): Workspace | null { const result = this.db.select().from(workspaces).where(eq(workspaces.id, id)).get(); return result ? this.dbWorkspaceToWorkspace(result) : null; } findWorkspaceByName(name: string): Workspace | null { const result = this.db.select().from(workspaces).where(eq(workspaces.name, name)).get(); return result ? this.dbWorkspaceToWorkspace(result) : null; } getDefaultWorkspace(): Workspace | null { const result = this.db.select().from(workspaces).where(eq(workspaces.isDefault, true)).get(); return result ? this.dbWorkspaceToWorkspace(result) : null; } listWorkspaces(): Workspace[] { return this.db.select().from(workspaces) .orderBy(desc(workspaces.isDefault), workspaces.name) .all() .map(w => this.dbWorkspaceToWorkspace(w)); } updateWorkspace(id: string, input: UpdateWorkspaceInput): Workspace | null { const updates: any = { updatedAt: new Date().toISOString(), }; if (input.name !== undefined) updates.name = input.name; if (input.syncEnabled !== undefined) updates.syncEnabled = input.syncEnabled; if (input.syncRepo !== undefined) updates.syncRepo = input.syncRepo; if (input.syncBranch !== undefined) updates.syncBranch = input.syncBranch; if (input.autoSync !== undefined) updates.autoSync = input.autoSync; if (input.syncInterval !== undefined) updates.syncInterval = input.syncInterval; if (input.filters !== undefined) updates.filters = JSON.stringify(input.filters); if (input.isDefault !== undefined) { updates.isDefault = input.isDefault; // If setting as default, unset any existing default if (input.isDefault) { this.db.update(workspaces) .set({ isDefault: false }) .where(and(eq(workspaces.isDefault, true), sql`id != ${id}`)) .run(); } } this.db.update(workspaces).set(updates).where(eq(workspaces.id, id)).run(); return this.findWorkspace(id); } deleteWorkspace(id: string): void { // Check if it's the default workspace const workspace = this.findWorkspace(id); if (!workspace) { throw new Error(`Workspace ${id} not found`); } if (workspace.isDefault) { // Set another workspace as default if exists const otherWorkspace = this.db.select().from(workspaces) .where(sql`id != ${id}`) .get(); if (otherWorkspace) { this.db.update(workspaces) .set({ isDefault: true }) .where(eq(workspaces.id, otherWorkspace.id)) .run(); } } this.db.delete(workspaces).where(eq(workspaces.id, id)).run(); } // Realm-Workspace associations linkRealmToWorkspace(realmId: string, workspaceId: string): void { // Verify both exist if (!this.findRealm(realmId)) { throw new Error(`Realm ${realmId} not found`); } if (!this.findWorkspace(workspaceId)) { throw new Error(`Workspace ${workspaceId} not found`); } // Check if already linked const existing = this.db.select().from(realmWorkspaces) .where(and( eq(realmWorkspaces.realmId, realmId), eq(realmWorkspaces.workspaceId, workspaceId) )) .get(); if (existing) { return; // Already linked } this.db.insert(realmWorkspaces).values({ realmId, workspaceId, createdAt: new Date().toISOString(), }).run(); } unlinkRealmFromWorkspace(realmId: string, workspaceId: string): void { this.db.delete(realmWorkspaces) .where(and( eq(realmWorkspaces.realmId, realmId), eq(realmWorkspaces.workspaceId, workspaceId) )) .run(); } getRealmWorkspaces(realmId: string): Workspace[] { const results = this.db.select({ workspace: workspaces }) .from(realmWorkspaces) .innerJoin(workspaces, eq(realmWorkspaces.workspaceId, workspaces.id)) .where(eq(realmWorkspaces.realmId, realmId)) .all(); return results.map(r => this.dbWorkspaceToWorkspace(r.workspace)); } getWorkspaceRealms(workspaceId: string): Realm[] { const results = this.db.select({ realm: realms }) .from(realmWorkspaces) .innerJoin(realms, eq(realmWorkspaces.realmId, realms.id)) .where(eq(realmWorkspaces.workspaceId, workspaceId)) .all(); return results.map(r => this.dbRealmToRealm(r.realm)); } // Helper method to get or create default workspace ensureDefaultWorkspace(): Workspace { let defaultWorkspace = this.getDefaultWorkspace(); if (!defaultWorkspace) { // Create a default workspace defaultWorkspace = this.createWorkspace({ name: 'main', isDefault: true, syncEnabled: false, }); } return defaultWorkspace; } private dbWorkspaceToWorkspace(dbWorkspace: DbWorkspace): Workspace { return { id: dbWorkspace.id, name: dbWorkspace.name, syncEnabled: dbWorkspace.syncEnabled, syncRepo: dbWorkspace.syncRepo || undefined, syncBranch: dbWorkspace.syncBranch || 'main', autoSync: dbWorkspace.autoSync, syncInterval: dbWorkspace.syncInterval || 300, filters: dbWorkspace.filters ? JSON.parse(dbWorkspace.filters) : undefined, isDefault: dbWorkspace.isDefault, createdAt: new Date(dbWorkspace.createdAt), updatedAt: new Date(dbWorkspace.updatedAt), }; } }