From 5f471181fabe4367ea4777aa4d427fbe58162287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A6=8D=E5=84=BF=20=E2=9C=A8?= Date: Fri, 7 Aug 2026 06:11:57 +0800 Subject: [PATCH] refactor(db): add combo repository boundary (#8757) Validated in local merge-train (tomni-proxmox-113) --- src/domain/persistence/comboRepositories.ts | 61 +++ src/lib/db/combos.ts | 356 +++--------------- src/lib/db/modelComboMappings.ts | 254 ++----------- .../repositories/routingConfigRepositories.ts | 32 ++ .../db/repositories/sqliteComboRepository.ts | 348 +++++++++++++++++ .../sqliteModelComboMappingRepository.ts | 244 ++++++++++++ .../persistence/comboRepositoryConformance.ts | 240 ++++++++++++ tests/unit/combo-cache-invalidation.test.ts | 86 ++++- .../sqliteComboRepositories.test.ts | 46 +++ 9 files changed, 1114 insertions(+), 553 deletions(-) create mode 100644 src/domain/persistence/comboRepositories.ts create mode 100644 src/lib/db/repositories/routingConfigRepositories.ts create mode 100644 src/lib/db/repositories/sqliteComboRepository.ts create mode 100644 src/lib/db/repositories/sqliteModelComboMappingRepository.ts create mode 100644 tests/helpers/persistence/comboRepositoryConformance.ts create mode 100644 tests/unit/db/repositories/sqliteComboRepositories.test.ts diff --git a/src/domain/persistence/comboRepositories.ts b/src/domain/persistence/comboRepositories.ts new file mode 100644 index 0000000000..00750a318c --- /dev/null +++ b/src/domain/persistence/comboRepositories.ts @@ -0,0 +1,61 @@ +export type ComboRecord = Record; + +export interface ComboUpdateResult { + combo: ComboRecord; + previousName: string; + currentName: string; + modelsFieldProvided: boolean; +} + +export interface ComboReorderResult { + combos: ComboRecord[]; + rowsReordered: number; +} + +export interface ComboRepository { + list(limit?: number, offset?: number): Promise; + count(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameInsensitive(name: string): Promise; + create(data: ComboRecord): Promise; + update(id: string, data: ComboRecord): Promise; + reorder(comboIds: string[]): Promise; + deleteById(id: string): Promise; +} + +export interface ModelComboMapping { + id: string; + pattern: string; + comboId: string; + comboName?: string; + priority: number; + enabled: boolean; + description: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateModelComboMappingInput { + pattern: string; + comboId: string; + priority?: number; + enabled?: boolean; + description?: string; +} + +export type UpdateModelComboMappingInput = Partial; + +export interface ModelComboMappingPage { + items: ModelComboMapping[]; + total: number; +} + +export interface ModelComboMappingRepository { + list(options?: { limit?: number; offset?: number }): Promise; + findById(id: string): Promise; + create(data: CreateModelComboMappingInput): Promise; + update(id: string, data: UpdateModelComboMappingInput): Promise; + deleteById(id: string): Promise; + resolveForModel(model: string): Promise; +} diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index 5e5564bb61..6cdebbd85b 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -1,359 +1,91 @@ /** - * db/combos.js — Combo CRUD operations. + * Compatibility facade for combo persistence. + * + * Application code keeps the existing function-level API while persistence is + * delegated through the domain repository contract. Cross-cutting write effects + * remain here instead of becoming part of the portable repository surface. */ -import { v4 as uuidv4 } from "uuid"; -import { getDbInstance } from "./core"; +import type { ComboRecord } from "@/domain/persistence/comboRepositories"; import { backupDbFile } from "./backup"; +import { clearSessionModelHistoryForCombo } from "./contextHandoffs"; +import { getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; -import { normalizeComboRecord } from "@/lib/combos/steps"; -import { clearSessionModelHistoryForCombo } from "./contextHandoffs"; -import { validateComboInvariant } from "@/lib/combos/invariants"; +import { routingConfigRepositories } from "./repositories/routingConfigRepositories"; -type JsonRecord = Record; +const repository = routingConfigRepositories.combos; -function asRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -function getSerializedData(value: unknown): string | null { - const row = asRecord(value); - return typeof row.data === "string" ? row.data : null; -} - -function getSortOrder(value: unknown): number | null { - const row = asRecord(value); - return typeof row.sort_order === "number" ? row.sort_order : null; -} - -function withSortOrder(payload: string, sortOrder: number | null): JsonRecord { - const parsed = JSON.parse(payload) as JsonRecord; - if (typeof sortOrder === "number") { - parsed.sortOrder = sortOrder; - } - return parsed; -} - -function getComboNameSet( - db: ReturnType, - extraNames: string[] = [] -): Set { - const rows = db.prepare("SELECT name FROM combos").all(); - const names = new Set(); - - for (const row of rows) { - const record = asRecord(row); - if (typeof record.name === "string" && record.name.trim().length > 0) { - names.add(record.name.trim()); - } - } - - for (const name of extraNames) { - if (typeof name === "string" && name.trim().length > 0) { - names.add(name.trim()); - } - } - - return names; -} - -function normalizeStoredCombo( - combo: JsonRecord, - db: ReturnType, - extraNames: string[] = [] -) { - return normalizeComboRecord(combo, { - allCombos: getComboNameSet(db, extraNames), - }); -} - -function parseComboRow(row: unknown): JsonRecord | null { - const payload = getSerializedData(row); - if (!payload) return null; - const parsed = withSortOrder(payload, getSortOrder(row)); - // Merge deduplicated column values back into the record - const record = asRecord(row); - if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) { - // Column is authoritative when explicitly enabled (1). - // When column is 0 (unset default) preserve the JSON blob value - // to avoid silently disabling the feature on pre-migration rows. - if (record.context_cache_protection === 1) { - parsed.context_cache_protection = true; - } - // Column is 0 — keep existing JSON blob value - } - return parsed; -} - -function getNextSortOrder() { - const db = getDbInstance(); - const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get(); - const sortOrder = getSortOrder(row); - return (sortOrder ?? 0) + 1; -} - -export async function getCombos(limit?: number, offset?: number) { - const db = getDbInstance(); - let sql = - "SELECT id, data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"; - const params: unknown[] = []; - if (limit !== undefined) { - sql += " LIMIT ? OFFSET ?"; - params.push(limit, offset ?? 0); - } - const rawCombos = db - .prepare(sql) - .all(...params) - .map((row) => parseComboRow(row)) - .filter((row): row is JsonRecord => row !== null); - - const comboNames = rawCombos - .map((combo) => (typeof combo.name === "string" ? combo.name.trim() : "")) - .filter((name): name is string => name.length > 0); - - return rawCombos.map((combo) => - normalizeComboRecord(combo, { - allCombos: comboNames, - }) - ); +export function getCombos(limit?: number, offset?: number): Promise { + return repository.list(limit, offset); } +/** Keep the existing synchronous facade contract while repository APIs become async. */ export function getCombosCount(): number { - const db = getDbInstance(); - const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number }; - return row.cnt; + return routingConfigRepositories.legacySync.getCombosCount(); } -export async function getComboById(id: string) { - const db = getDbInstance(); - const row = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") - .get(id); - const combo = parseComboRow(row); - if (!combo) return null; - return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []); +export function getComboById(id: string): Promise { + return repository.findById(id); } -export async function getComboByName(name: string) { - const db = getDbInstance(); - const row = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?") - .get(name); - const combo = parseComboRow(row); - if (!combo) return null; - return normalizeStoredCombo(combo, db, [name]); +export function getComboByName(name: string): Promise { + return repository.findByName(name); } -// #4446: case-insensitive name lookup. The opencode dispatch path forwards a -// lowercased combo slug (e.g. "master-light") for a combo provisioned as -// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it. -// Used only as a fallback after the exact match fails, so it cannot change the -// resolution of any combo that already resolves today. -export async function getComboByNameInsensitive(name: string) { - const db = getDbInstance(); - const row = db - .prepare( - "SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE" - ) - .get(name); - const combo = parseComboRow(row); - if (!combo) return null; - const storedName = typeof combo.name === "string" ? combo.name : name; - return normalizeStoredCombo(combo, db, [storedName]); +export function getComboByNameInsensitive(name: string): Promise { + return repository.findByNameInsensitive(name); } -export async function createCombo(data: JsonRecord) { - const db = getDbInstance(); - const now = new Date().toISOString(); - const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder(); - const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4(); - const combo = normalizeStoredCombo( - { - ...data, - id: comboId, - name: data.name, - models: data.models || [], - strategy: data.strategy || "priority", - config: data.config || {}, - isHidden: Boolean(data.isHidden), - sortOrder, - createdAt: now, - updatedAt: now, - }, - db, - typeof data.name === "string" ? [data.name] : [] - ); - - validateComboInvariant(combo); - const contextCache = data.context_cache_protection ? 1 : 0; - db.prepare( - "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)" - ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache); - +export async function createCombo(data: ComboRecord): Promise { + const combo = await repository.create(data); invalidateDbCache("combos"); backupDbFile("pre-write"); return combo; } -export async function updateCombo(id: string, data: JsonRecord) { - const db = getDbInstance(); - const existing = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") - .get(id); - if (!existing) return null; +export async function updateCombo(id: string, data: ComboRecord): Promise { + const result = await repository.update(id, data); + if (!result) return null; - const current = parseComboRow(existing); - if (!current) return null; - const sortOrder = - typeof data.sortOrder === "number" - ? data.sortOrder - : typeof current.sortOrder === "number" - ? current.sortOrder - : getNextSortOrder(); - const merged: JsonRecord = { - ...current, - ...data, - sortOrder, - updatedAt: new Date().toISOString(), - }; - // Remove fields explicitly set to null (for deletion support) - for (const key of Object.keys(data)) { - if (data[key] === null) { - delete merged[key]; - } - } - const currentName = typeof current.name === "string" ? current.name : ""; - const nextName = - typeof merged["name"] === "string" && merged["name"].trim().length > 0 - ? merged["name"] - : currentName; - const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]); - validateComboInvariant({ - ...normalizedMerged, - ...data, - name: nextName, - models: normalizedMerged.models, - }); - const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0; - - db.prepare( - "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?" - ).run( - nextName, - JSON.stringify(normalizedMerged), - sortOrder, - normalizedMerged.updatedAt, - contextCacheProtection, - id - ); - - // Invalidate stale context-cache pins when combo targets change. - // Without this, sessions pinned to removed models keep routing there forever. - if (data.models !== undefined) { - const cleared = clearSessionModelHistoryForCombo(currentName); - if (cleared > 0) { - // Also clear under the new name if the combo was renamed - if (nextName !== currentName) { - clearSessionModelHistoryForCombo(nextName); - } + if (result.modelsFieldProvided) { + const cleared = clearSessionModelHistoryForCombo(result.previousName); + if (cleared > 0 && result.currentName !== result.previousName) { + clearSessionModelHistoryForCombo(result.currentName); } } invalidateDbCache("combos"); backupDbFile("pre-write"); - return normalizedMerged; + return result.combo; } -export async function reorderCombos(comboIds: string[]) { - const db = getDbInstance(); - const rows = db - .prepare( - "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC" - ) - .all(); - if (rows.length === 0) return []; - - const existingIds = new Set( - rows - .map((row) => { - const record = asRecord(row); - return typeof record.id === "string" ? record.id : null; - }) - .filter((id): id is string => id !== null) - ); - - const seen = new Set(); - const requestedIds = comboIds.filter((id) => { - if (!existingIds.has(id) || seen.has(id)) return false; - seen.add(id); - return true; - }); - - const orderedIds = [ - ...requestedIds, - ...rows - .map((row) => { - const record = asRecord(row); - return typeof record.id === "string" ? record.id : null; - }) - .filter((id): id is string => id !== null && !seen.has(id)), - ]; - - const update = db.prepare( - "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?" - ); - const now = new Date().toISOString(); - const rowById = new Map( - rows.map((row) => { - const record = asRecord(row); - return [String(record.id), row]; - }) - ); - const comboNames = rows - .map((row) => { - const combo = parseComboRow(row); - return combo && typeof combo.name === "string" ? combo.name.trim() : ""; - }) - .filter((name): name is string => name.length > 0); - - const reorderTransaction = db.transaction(() => { - orderedIds.forEach((id, index) => { - const row = rowById.get(id); - const combo = row ? parseComboRow(row) : null; - if (!combo) return; - const sortOrder = index + 1; - const updatedCombo = normalizeComboRecord( - { ...combo, sortOrder, updatedAt: now }, - { allCombos: comboNames } - ); - update.run(JSON.stringify(updatedCombo), sortOrder, now, id); - }); - }); - - reorderTransaction(); - invalidateDbCache("combos"); - backupDbFile("pre-write"); - return getCombos(); +export async function reorderCombos(comboIds: string[]): Promise { + const result = await repository.reorder(comboIds); + if (result.rowsReordered > 0) { + invalidateDbCache("combos"); + backupDbFile("pre-write"); + } + return result.combos; } -export async function deleteCombo(id: string) { - const db = getDbInstance(); - const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); - if (result.changes === 0) return false; +export async function deleteCombo(id: string): Promise { + const deleted = await repository.deleteById(id); + if (!deleted) return false; + invalidateDbCache("combos"); invalidateReasoningRoutingRuleCache(); backupDbFile("pre-write"); return true; } -export async function deleteComboByName(name: string) { - const combo = await getComboByName(name); +export async function deleteComboByName(name: string): Promise { + const combo = await repository.findByName(name); if (!combo || typeof combo.id !== "string") return false; return deleteCombo(combo.id); } -export function setActiveCombo(name: string, db = getDbInstance()) { +export function setActiveCombo(name: string, db = getDbInstance()): void { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)" ).run(JSON.stringify(name)); diff --git a/src/lib/db/modelComboMappings.ts b/src/lib/db/modelComboMappings.ts index 41d9f1fe45..d3fc1c541c 100644 --- a/src/lib/db/modelComboMappings.ts +++ b/src/lib/db/modelComboMappings.ts @@ -1,249 +1,47 @@ /** - * db/modelComboMappings.ts — Per-model combo mapping CRUD + resolution. - * - * Maps model name patterns (glob-style wildcards) to specific combos. - * When a request arrives for a model string like "claude-sonnet-4", - * the resolver checks all enabled mappings (highest priority first) - * and returns the first matching combo. + * Compatibility facade for model-to-combo mapping persistence. */ -import { v4 as uuidv4 } from "uuid"; -import { getDbInstance } from "./core"; -import { globToRegex } from "@/shared/utils/globPattern"; +import type { + CreateModelComboMappingInput, + ModelComboMapping, + ModelComboMappingPage, + UpdateModelComboMappingInput, +} from "@/domain/persistence/comboRepositories"; +import { routingConfigRepositories } from "./repositories/routingConfigRepositories"; -// ────────────────────────────────────────────────────────── -// Types -// ────────────────────────────────────────────────────────── +export type { ModelComboMapping } from "@/domain/persistence/comboRepositories"; -export interface ModelComboMapping { - id: string; - pattern: string; - comboId: string; - comboName?: string; - priority: number; - enabled: boolean; - description: string; - createdAt: string; - updatedAt: string; -} +const repository = routingConfigRepositories.modelComboMappings; -interface MappingRow { - id: string; - pattern: string; - combo_id: string; - combo_name?: string; - priority: number; - enabled: number; - description: string; - created_at: string; - updated_at: string; -} - -// ────────────────────────────────────────────────────────── -// Row mapping -// ────────────────────────────────────────────────────────── - -function rowToMapping(row: MappingRow): ModelComboMapping { - return { - id: row.id, - pattern: row.pattern, - comboId: row.combo_id, - comboName: row.combo_name || undefined, - priority: row.priority, - enabled: row.enabled === 1, - description: row.description || "", - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -// ────────────────────────────────────────────────────────── -// CRUD -// ────────────────────────────────────────────────────────── - -/** - * List all model-combo mappings, joined with combo name. - * Ordered by priority descending (highest first). - */ -export async function getModelComboMappings(options?: { +export function getModelComboMappings(options?: { limit?: number; offset?: number; -}): Promise<{ items: ModelComboMapping[]; total: number }> { - const db = getDbInstance(); - const limit = options?.limit; - const offset = options?.offset ?? 0; - let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, - m.priority, m.enabled, m.description, - m.created_at, m.updated_at - FROM model_combo_mappings m - LEFT JOIN combos c ON c.id = m.combo_id - ORDER BY m.priority DESC, m.created_at ASC`; - const params: unknown[] = []; - if (limit !== undefined) { - sql += " LIMIT ? OFFSET ?"; - params.push(limit, offset); - } - const rows = db.prepare(sql).all(...params) as MappingRow[]; - const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as { - cnt: number; - }; - return { items: rows.map(rowToMapping), total: totalRow.cnt }; +}): Promise { + return repository.list(options); } -/** - * Get a single mapping by ID. - */ -export async function getModelComboMappingById(id: string): Promise { - const db = getDbInstance(); - const row = db - .prepare( - `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, - m.priority, m.enabled, m.description, - m.created_at, m.updated_at - FROM model_combo_mappings m - LEFT JOIN combos c ON c.id = m.combo_id - WHERE m.id = ?` - ) - .get(id) as MappingRow | undefined; - return row ? rowToMapping(row) : null; +export function getModelComboMappingById(id: string): Promise { + return repository.findById(id); } -/** - * Create a new model-combo mapping. - */ -export async function createModelComboMapping(data: { - pattern: string; - comboId: string; - priority?: number; - enabled?: boolean; - description?: string; -}): Promise { - const db = getDbInstance(); - const now = new Date().toISOString(); - const id = uuidv4(); - - db.prepare( - `INSERT INTO model_combo_mappings - (id, pattern, combo_id, priority, enabled, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - data.pattern, - data.comboId, - data.priority ?? 0, - data.enabled !== false ? 1 : 0, - data.description || "", - now, - now - ); - - return { - id, - pattern: data.pattern, - comboId: data.comboId, - priority: data.priority ?? 0, - enabled: data.enabled !== false, - description: data.description || "", - createdAt: now, - updatedAt: now, - }; +export function createModelComboMapping( + data: CreateModelComboMappingInput +): Promise { + return repository.create(data); } -/** - * Update an existing model-combo mapping. - */ -export async function updateModelComboMapping( +export function updateModelComboMapping( id: string, - data: Partial<{ - pattern: string; - comboId: string; - priority: number; - enabled: boolean; - description: string; - }> + data: UpdateModelComboMappingInput ): Promise { - const existing = await getModelComboMappingById(id); - if (!existing) return null; - - const db = getDbInstance(); - const now = new Date().toISOString(); - const updated = { - pattern: data.pattern ?? existing.pattern, - combo_id: data.comboId ?? existing.comboId, - priority: data.priority ?? existing.priority, - enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0, - description: data.description ?? existing.description, - }; - - db.prepare( - `UPDATE model_combo_mappings - SET pattern = ?, combo_id = ?, priority = ?, enabled = ?, - description = ?, updated_at = ? - WHERE id = ?` - ).run( - updated.pattern, - updated.combo_id, - updated.priority, - updated.enabled, - updated.description, - now, - id - ); - - return getModelComboMappingById(id); + return repository.update(id, data); } -/** - * Delete a model-combo mapping. - */ -export async function deleteModelComboMapping(id: string): Promise { - const db = getDbInstance(); - const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id); - return (result.changes ?? 0) > 0; +export function deleteModelComboMapping(id: string): Promise { + return repository.deleteById(id); } -// ────────────────────────────────────────────────────────── -// Core: Resolve combo for a model string -// ────────────────────────────────────────────────────────── - -/** - * Check if a model string matches any enabled model-combo mapping. - * Returns the full combo object if a match is found, null otherwise. - * - * Mappings are checked in priority order (highest first). - * Uses glob-style pattern matching (* = any chars, ? = single char). - */ -export async function resolveComboForModel( - modelStr: string -): Promise | null> { - const db = getDbInstance(); - - // Fetch enabled mappings, ordered by priority (highest first) - const rows = db - .prepare( - `SELECT m.pattern, m.combo_id, c.data AS combo_data - FROM model_combo_mappings m - JOIN combos c ON c.id = m.combo_id - WHERE m.enabled = 1 - ORDER BY m.priority DESC, m.created_at ASC` - ) - .all() as Array<{ pattern: string; combo_id: string; combo_data: string }>; - - for (const row of rows) { - const regex = globToRegex(row.pattern); - if (regex.test(modelStr)) { - try { - const combo = JSON.parse(row.combo_data) as Record; - if (combo.isActive === false) { - continue; - } - return combo; - } catch { - // Corrupted combo data — skip - continue; - } - } - } - - return null; +export function resolveComboForModel(model: string): Promise | null> { + return repository.resolveForModel(model); } diff --git a/src/lib/db/repositories/routingConfigRepositories.ts b/src/lib/db/repositories/routingConfigRepositories.ts new file mode 100644 index 0000000000..0b44fb9fa6 --- /dev/null +++ b/src/lib/db/repositories/routingConfigRepositories.ts @@ -0,0 +1,32 @@ +import type { + ComboRepository, + ModelComboMappingRepository, +} from "@/domain/persistence/comboRepositories"; +import { + getCombosCount as getSqliteCombosCount, + sqliteComboRepository, +} from "./sqliteComboRepository"; +import { sqliteModelComboMappingRepository } from "./sqliteModelComboMappingRepository"; + +export interface RoutingConfigRepositories { + combos: ComboRepository; + modelComboMappings: ModelComboMappingRepository; + legacySync: { + getCombosCount(): number; + }; +} + +/** + * SQLite-only composition root for the first repository slice. + * + * Backend selection deliberately does not exist yet. Keeping the binding in one + * place prevents compatibility facades from constructing or reaching through a + * concrete driver when a later, separately approved backend is introduced. + */ +export const routingConfigRepositories: RoutingConfigRepositories = { + combos: sqliteComboRepository, + modelComboMappings: sqliteModelComboMappingRepository, + legacySync: { + getCombosCount: getSqliteCombosCount, + }, +}; diff --git a/src/lib/db/repositories/sqliteComboRepository.ts b/src/lib/db/repositories/sqliteComboRepository.ts new file mode 100644 index 0000000000..e7fb36c59b --- /dev/null +++ b/src/lib/db/repositories/sqliteComboRepository.ts @@ -0,0 +1,348 @@ +/** + * SQLite implementation of the combo repository contract. + */ + +import { v4 as uuidv4 } from "uuid"; +import type { + ComboReorderResult, + ComboRepository, + ComboUpdateResult, +} from "@/domain/persistence/comboRepositories"; +import { normalizeComboRecord } from "@/lib/combos/steps"; +import { validateComboInvariant } from "@/lib/combos/invariants"; +import { getDbInstance } from "../core"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function getSerializedData(value: unknown): string | null { + const row = asRecord(value); + return typeof row.data === "string" ? row.data : null; +} + +function getSortOrder(value: unknown): number | null { + const row = asRecord(value); + return typeof row.sort_order === "number" ? row.sort_order : null; +} + +function withSortOrder(payload: string, sortOrder: number | null): JsonRecord { + const parsed = JSON.parse(payload) as JsonRecord; + if (typeof sortOrder === "number") { + parsed.sortOrder = sortOrder; + } + return parsed; +} + +function getComboNameSet( + db: ReturnType, + extraNames: string[] = [] +): Set { + const rows = db.prepare("SELECT name FROM combos").all(); + const names = new Set(); + + for (const row of rows) { + const record = asRecord(row); + if (typeof record.name === "string" && record.name.trim().length > 0) { + names.add(record.name.trim()); + } + } + + for (const name of extraNames) { + if (typeof name === "string" && name.trim().length > 0) { + names.add(name.trim()); + } + } + + return names; +} + +function normalizeStoredCombo( + combo: JsonRecord, + db: ReturnType, + extraNames: string[] = [] +): JsonRecord { + return normalizeComboRecord(combo, { + allCombos: getComboNameSet(db, extraNames), + }) as JsonRecord; +} + +function parseComboRow(row: unknown): JsonRecord | null { + const payload = getSerializedData(row); + if (!payload) return null; + const parsed = withSortOrder(payload, getSortOrder(row)); + // Merge deduplicated column values back into the record + const record = asRecord(row); + if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) { + // Column is authoritative when explicitly enabled (1). + // When column is 0 (unset default) preserve the JSON blob value + // to avoid silently disabling the feature on pre-migration rows. + if (record.context_cache_protection === 1) { + parsed.context_cache_protection = true; + } + // Column is 0 — keep existing JSON blob value + } + return parsed; +} + +function getNextSortOrder() { + const db = getDbInstance(); + const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get(); + const sortOrder = getSortOrder(row); + return (sortOrder ?? 0) + 1; +} + +export async function getCombos(limit?: number, offset?: number) { + const db = getDbInstance(); + let sql = + "SELECT data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"; + const params: unknown[] = []; + if (limit !== undefined) { + sql += " LIMIT ? OFFSET ?"; + params.push(limit, offset ?? 0); + } + const rawCombos = db + .prepare(sql) + .all(...params) + .map((row) => parseComboRow(row)) + .filter((row): row is JsonRecord => row !== null); + + const comboNames = rawCombos + .map((combo) => (typeof combo.name === "string" ? combo.name.trim() : "")) + .filter((name): name is string => name.length > 0); + + return rawCombos.map((combo) => + normalizeComboRecord(combo, { + allCombos: comboNames, + }) + ); +} + +export function getCombosCount(): number { + const db = getDbInstance(); + const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number }; + return row.cnt; +} + +export async function getComboById(id: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") + .get(id); + const combo = parseComboRow(row); + if (!combo) return null; + return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []); +} + +export async function getComboByName(name: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?") + .get(name); + const combo = parseComboRow(row); + if (!combo) return null; + return normalizeStoredCombo(combo, db, [name]); +} + +// #4446: case-insensitive name lookup. The opencode dispatch path forwards a +// lowercased combo slug (e.g. "master-light") for a combo provisioned as +// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it. +// Used only as a fallback after the exact match fails, so it cannot change the +// resolution of any combo that already resolves today. +export async function getComboByNameInsensitive(name: string) { + const db = getDbInstance(); + const row = db + .prepare( + "SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE" + ) + .get(name); + const combo = parseComboRow(row); + if (!combo) return null; + const storedName = typeof combo.name === "string" ? combo.name : name; + return normalizeStoredCombo(combo, db, [storedName]); +} + +export async function createCombo(data: JsonRecord) { + const db = getDbInstance(); + const now = new Date().toISOString(); + const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder(); + const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4(); + const combo = normalizeStoredCombo( + { + ...data, + id: comboId, + name: data.name, + models: data.models || [], + strategy: data.strategy || "priority", + config: data.config || {}, + isHidden: Boolean(data.isHidden), + sortOrder, + createdAt: now, + updatedAt: now, + }, + db, + typeof data.name === "string" ? [data.name] : [] + ); + + validateComboInvariant(combo); + const contextCache = data.context_cache_protection ? 1 : 0; + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)" + ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache); + + return combo; +} + +export async function updateCombo(id: string, data: JsonRecord): Promise { + const db = getDbInstance(); + const existing = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") + .get(id); + if (!existing) return null; + + const current = parseComboRow(existing); + if (!current) return null; + const sortOrder = + typeof data.sortOrder === "number" + ? data.sortOrder + : typeof current.sortOrder === "number" + ? current.sortOrder + : getNextSortOrder(); + const merged: JsonRecord = { + ...current, + ...data, + sortOrder, + updatedAt: new Date().toISOString(), + }; + // Remove fields explicitly set to null (for deletion support) + for (const key of Object.keys(data)) { + if (data[key] === null) { + delete merged[key]; + } + } + const currentName = typeof current.name === "string" ? current.name : ""; + const nextName = + typeof merged["name"] === "string" && merged["name"].trim().length > 0 + ? merged["name"] + : currentName; + const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]); + validateComboInvariant({ + ...normalizedMerged, + ...data, + name: nextName, + models: normalizedMerged.models, + }); + const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0; + + db.prepare( + "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?" + ).run( + nextName, + JSON.stringify(normalizedMerged), + sortOrder, + normalizedMerged.updatedAt, + contextCacheProtection, + id + ); + + return { + combo: normalizedMerged, + previousName: currentName, + currentName: nextName, + modelsFieldProvided: data.models !== undefined, + }; +} + +export async function reorderCombos(comboIds: string[]): Promise { + const db = getDbInstance(); + const rows = db + .prepare( + "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC" + ) + .all(); + if (rows.length === 0) return { combos: [], rowsReordered: 0 }; + + const existingIds = new Set( + rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null) + ); + + const seen = new Set(); + const requestedIds = comboIds.filter((id) => { + if (!existingIds.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); + + const orderedIds = [ + ...requestedIds, + ...rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null && !seen.has(id)), + ]; + + const update = db.prepare( + "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?" + ); + const now = new Date().toISOString(); + const rowById = new Map( + rows.map((row) => { + const record = asRecord(row); + return [String(record.id), row]; + }) + ); + const comboNames = rows + .map((row) => { + const combo = parseComboRow(row); + return combo && typeof combo.name === "string" ? combo.name.trim() : ""; + }) + .filter((name): name is string => name.length > 0); + + const reorderTransaction = db.transaction(() => { + orderedIds.forEach((id, index) => { + const row = rowById.get(id); + const combo = row ? parseComboRow(row) : null; + if (!combo) return; + const sortOrder = index + 1; + const updatedCombo = normalizeComboRecord( + { ...combo, sortOrder, updatedAt: now }, + { allCombos: comboNames } + ); + update.run(JSON.stringify(updatedCombo), sortOrder, now, id); + }); + }); + + reorderTransaction(); + return { + combos: await getCombos(), + rowsReordered: orderedIds.length, + }; +} + +export async function deleteCombo(id: string) { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); + if (result.changes === 0) return false; + return true; +} + +export const sqliteComboRepository: ComboRepository = { + list: getCombos, + count: async () => getCombosCount(), + findById: getComboById, + findByName: getComboByName, + findByNameInsensitive: getComboByNameInsensitive, + create: createCombo, + update: updateCombo, + reorder: reorderCombos, + deleteById: deleteCombo, +}; diff --git a/src/lib/db/repositories/sqliteModelComboMappingRepository.ts b/src/lib/db/repositories/sqliteModelComboMappingRepository.ts new file mode 100644 index 0000000000..d9aa223415 --- /dev/null +++ b/src/lib/db/repositories/sqliteModelComboMappingRepository.ts @@ -0,0 +1,244 @@ +/** + * SQLite implementation of per-model combo mapping persistence and resolution. + * + * Maps model name patterns (glob-style wildcards) to specific combos. + * When a request arrives for a model string like "claude-sonnet-4", + * the resolver checks all enabled mappings (highest priority first) + * and returns the first matching combo. + */ + +import { v4 as uuidv4 } from "uuid"; +import type { + CreateModelComboMappingInput, + ModelComboMapping, + ModelComboMappingRepository, + UpdateModelComboMappingInput, +} from "@/domain/persistence/comboRepositories"; +import { globToRegex } from "@/shared/utils/globPattern"; +import { getDbInstance } from "../core"; + +export type { ModelComboMapping } from "@/domain/persistence/comboRepositories"; + +// ────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────── + +interface MappingRow { + id: string; + pattern: string; + combo_id: string; + combo_name?: string; + priority: number; + enabled: number; + description: string; + created_at: string; + updated_at: string; +} + +// ────────────────────────────────────────────────────────── +// Row mapping +// ────────────────────────────────────────────────────────── + +function rowToMapping(row: MappingRow): ModelComboMapping { + return { + id: row.id, + pattern: row.pattern, + comboId: row.combo_id, + comboName: row.combo_name || undefined, + priority: row.priority, + enabled: row.enabled === 1, + description: row.description || "", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ────────────────────────────────────────────────────────── +// CRUD +// ────────────────────────────────────────────────────────── + +/** + * List all model-combo mappings, joined with combo name. + * Ordered by priority descending (highest first). + */ +export async function getModelComboMappings(options?: { + limit?: number; + offset?: number; +}): Promise<{ items: ModelComboMapping[]; total: number }> { + const db = getDbInstance(); + const limit = options?.limit; + const offset = options?.offset ?? 0; + let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + ORDER BY m.priority DESC, m.created_at ASC`; + const params: unknown[] = []; + if (limit !== undefined) { + sql += " LIMIT ? OFFSET ?"; + params.push(limit, offset); + } + const rows = db.prepare(sql).all(...params) as MappingRow[]; + const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as { + cnt: number; + }; + return { items: rows.map(rowToMapping), total: totalRow.cnt }; +} + +/** + * Get a single mapping by ID. + */ +export async function getModelComboMappingById(id: string): Promise { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + WHERE m.id = ?` + ) + .get(id) as MappingRow | undefined; + return row ? rowToMapping(row) : null; +} + +/** + * Create a new model-combo mapping. + */ +export async function createModelComboMapping( + data: CreateModelComboMappingInput +): Promise { + const db = getDbInstance(); + const now = new Date().toISOString(); + const id = uuidv4(); + + db.prepare( + `INSERT INTO model_combo_mappings + (id, pattern, combo_id, priority, enabled, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data.pattern, + data.comboId, + data.priority ?? 0, + data.enabled !== false ? 1 : 0, + data.description || "", + now, + now + ); + + return { + id, + pattern: data.pattern, + comboId: data.comboId, + priority: data.priority ?? 0, + enabled: data.enabled !== false, + description: data.description || "", + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update an existing model-combo mapping. + */ +export async function updateModelComboMapping( + id: string, + data: UpdateModelComboMappingInput +): Promise { + const existing = await getModelComboMappingById(id); + if (!existing) return null; + + const db = getDbInstance(); + const now = new Date().toISOString(); + const updated = { + pattern: data.pattern ?? existing.pattern, + combo_id: data.comboId ?? existing.comboId, + priority: data.priority ?? existing.priority, + enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0, + description: data.description ?? existing.description, + }; + + db.prepare( + `UPDATE model_combo_mappings + SET pattern = ?, combo_id = ?, priority = ?, enabled = ?, + description = ?, updated_at = ? + WHERE id = ?` + ).run( + updated.pattern, + updated.combo_id, + updated.priority, + updated.enabled, + updated.description, + now, + id + ); + + return getModelComboMappingById(id); +} + +/** + * Delete a model-combo mapping. + */ +export async function deleteModelComboMapping(id: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id); + return (result.changes ?? 0) > 0; +} + +// ────────────────────────────────────────────────────────── +// Core: Resolve combo for a model string +// ────────────────────────────────────────────────────────── + +/** + * Check if a model string matches any enabled model-combo mapping. + * Returns the full combo object if a match is found, null otherwise. + * + * Mappings are checked in priority order (highest first). + * Uses glob-style pattern matching (* = any chars, ? = single char). + */ +export async function resolveComboForModel( + modelStr: string +): Promise | null> { + const db = getDbInstance(); + + // Fetch enabled mappings, ordered by priority (highest first) + const rows = db + .prepare( + `SELECT m.pattern, m.combo_id, c.data AS combo_data + FROM model_combo_mappings m + JOIN combos c ON c.id = m.combo_id + WHERE m.enabled = 1 + ORDER BY m.priority DESC, m.created_at ASC` + ) + .all() as Array<{ pattern: string; combo_id: string; combo_data: string }>; + + for (const row of rows) { + const regex = globToRegex(row.pattern); + if (regex.test(modelStr)) { + try { + const combo = JSON.parse(row.combo_data) as Record; + if (combo.isActive === false) { + continue; + } + return combo; + } catch { + // Corrupted combo data — skip + continue; + } + } + } + + return null; +} + +export const sqliteModelComboMappingRepository: ModelComboMappingRepository = { + list: getModelComboMappings, + findById: getModelComboMappingById, + create: createModelComboMapping, + update: updateModelComboMapping, + deleteById: deleteModelComboMapping, + resolveForModel: resolveComboForModel, +}; diff --git a/tests/helpers/persistence/comboRepositoryConformance.ts b/tests/helpers/persistence/comboRepositoryConformance.ts new file mode 100644 index 0000000000..e80af670fd --- /dev/null +++ b/tests/helpers/persistence/comboRepositoryConformance.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + ComboRepository, + ModelComboMappingRepository, +} from "../../../src/domain/persistence/comboRepositories.ts"; + +export interface ComboRepositoryHarness { + combos: ComboRepository; + mappings: ModelComboMappingRepository; + reset(): Promise; + corruptComboPayload(comboId: string): Promise; +} + +export function registerComboRepositoryConformance( + createHarness: () => Promise +): void { + test("combo repository: CRUD, defaults, lookup, count, and pagination", async () => { + const harness = await createHarness(); + await harness.reset(); + + const zulu = await harness.combos.create({ + name: "Zulu", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + const alpha = await harness.combos.create({ + name: "Alpha", + models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }], + }); + + assert.equal(zulu.version, 2); + assert.equal(zulu.strategy, "priority"); + assert.equal(zulu.sortOrder, 1); + assert.equal(alpha.sortOrder, 2); + assert.equal(await harness.combos.count(), 2); + assert.deepEqual(await harness.combos.findById(String(zulu.id)), zulu); + assert.deepEqual(await harness.combos.findByName("Zulu"), zulu); + assert.equal(await harness.combos.findByName("zulu"), null); + assert.deepEqual(await harness.combos.findByNameInsensitive("zulu"), zulu); + + const page = await harness.combos.list(1, 1); + assert.deepEqual( + page.map((combo) => combo.name), + ["Alpha"] + ); + }); + + test("combo repository: partial update, explicit null deletion, and missing rows", async () => { + const harness = await createHarness(); + await harness.reset(); + + const created = await harness.combos.create({ + name: "Mutable", + description: "remove me", + models: [{ provider: "openai", model: "gpt-4.1" }], + config: { retries: 1 }, + }); + + const updateResult = await harness.combos.update(String(created.id), { + description: null, + strategy: "round-robin", + config: { retries: 3 }, + }); + + assert.ok(updateResult); + const updated = updateResult.combo; + assert.equal(updated.id, created.id); + assert.equal(updated.name, "Mutable"); + assert.equal("description" in updated, false); + assert.equal(updated.strategy, "round-robin"); + assert.deepEqual(updated.config, { retries: 3 }); + assert.equal(updateResult.previousName, "Mutable"); + assert.equal(updateResult.currentName, "Mutable"); + assert.equal(updateResult.modelsFieldProvided, false); + assert.equal(await harness.combos.update("missing", { strategy: "priority" }), null); + }); + + test("combo repository: reorder is atomic and delete reports affected-row semantics", async () => { + const harness = await createHarness(); + await harness.reset(); + + const alpha = await harness.combos.create({ + name: "Alpha", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + const bravo = await harness.combos.create({ + name: "Bravo", + models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }], + }); + const charlie = await harness.combos.create({ + name: "Charlie", + models: [{ provider: "google", model: "gemini-2.5-pro" }], + }); + + const reorderResult = await harness.combos.reorder([ + String(charlie.id), + "unknown", + String(charlie.id), + String(alpha.id), + ]); + const reordered = reorderResult.combos; + assert.equal(reorderResult.rowsReordered, 3); + assert.deepEqual( + reordered.map((combo) => combo.name), + ["Charlie", "Alpha", "Bravo"] + ); + assert.deepEqual( + reordered.map((combo) => combo.sortOrder), + [1, 2, 3] + ); + + assert.equal(await harness.combos.deleteById("missing"), false); + assert.equal(await harness.combos.deleteById(String(bravo.id)), true); + assert.equal(await harness.combos.deleteById(String(bravo.id)), false); + + await harness.corruptComboPayload(String(alpha.id)); + await harness.corruptComboPayload(String(charlie.id)); + const corruptResult = await harness.combos.reorder([String(alpha.id), String(charlie.id)]); + assert.equal(corruptResult.rowsReordered, 2); + assert.deepEqual(corruptResult.combos, []); + }); + + test("model mapping repository: CRUD, ordering, pagination, and atomic cascade", async () => { + const harness = await createHarness(); + await harness.reset(); + + const comboA = await harness.combos.create({ + name: "alpha", + models: [{ provider: "openai", model: "gpt-4o" }], + }); + const comboB = await harness.combos.create({ + name: "beta", + models: [{ provider: "openai", model: "gpt-4o-mini" }], + }); + + const first = await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(comboA.id), + priority: 20, + description: "primary", + }); + const second = await harness.mappings.create({ + pattern: "claude-*", + comboId: String(comboB.id), + priority: 10, + enabled: false, + }); + + const all = await harness.mappings.list(); + assert.equal(all.total, 2); + assert.deepEqual( + all.items.map((mapping) => mapping.id), + [first.id, second.id] + ); + assert.equal(all.items[0].comboName, "alpha"); + assert.equal(all.items[0].enabled, true); + assert.equal(all.items[1].comboName, "beta"); + assert.equal(all.items[1].enabled, false); + + const page = await harness.mappings.list({ limit: 1, offset: 1 }); + assert.equal(page.total, 2); + assert.deepEqual( + page.items.map((mapping) => mapping.id), + [second.id] + ); + + const updated = await harness.mappings.update(first.id, { + pattern: "openai/*", + comboId: String(comboB.id), + enabled: false, + description: "rerouted", + }); + assert.ok(updated); + assert.equal(updated.pattern, "openai/*"); + assert.equal(updated.comboId, comboB.id); + assert.equal(updated.comboName, "beta"); + assert.equal(updated.enabled, false); + assert.equal(updated.description, "rerouted"); + assert.equal(await harness.mappings.update("missing", { pattern: "*" }), null); + + assert.equal(await harness.mappings.deleteById(first.id), true); + assert.equal(await harness.mappings.deleteById(first.id), false); + + // The SQLite foreign key performs the combo + related mapping removal in + // one statement/transaction; portable backends must preserve that behavior. + assert.equal(await harness.combos.deleteById(String(comboB.id)), true); + assert.equal(await harness.mappings.findById(second.id), null); + assert.equal((await harness.mappings.list()).total, 0); + }); + + test("model mapping repository: resolution skips disabled, inactive, and corrupt combos", async () => { + const harness = await createHarness(); + await harness.reset(); + + const broken = await harness.combos.create({ + name: "broken", + models: [{ provider: "openai", model: "gpt-4o" }], + }); + const inactive = await harness.combos.create({ + name: "inactive", + models: [{ provider: "openai", model: "gpt-4.1" }], + isActive: false, + }); + const selected = await harness.combos.create({ + name: "selected", + models: [{ provider: "openai", model: "gpt-4o-mini" }], + }); + + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(broken.id), + priority: 30, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(inactive.id), + priority: 20, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(selected.id), + priority: 10, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(selected.id), + priority: 100, + enabled: false, + }); + + assert.ok(harness.corruptComboPayload); + await harness.corruptComboPayload(String(broken.id)); + + const resolved = await harness.mappings.resolveForModel("gpt-4o"); + assert.ok(resolved); + assert.equal(resolved.name, "selected"); + assert.equal(await harness.mappings.resolveForModel("claude-sonnet"), null); + }); +} diff --git a/tests/unit/combo-cache-invalidation.test.ts b/tests/unit/combo-cache-invalidation.test.ts index 30b1b8ffb6..5291c5c76a 100644 --- a/tests/unit/combo-cache-invalidation.test.ts +++ b/tests/unit/combo-cache-invalidation.test.ts @@ -27,6 +27,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); +const contextHandoffsDb = await import("../../src/lib/db/contextHandoffs.ts"); const readCache = await import("../../src/lib/db/readCache.ts"); async function resetStorage() { @@ -38,8 +39,9 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error: any) { - if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { throw error; @@ -113,7 +115,7 @@ test("editing a combo invalidates the nested-expansion cache within the 10s wind const freshVersion = readCache.getCombosCacheVersion(); assert.equal(cacheStillValid(freshTs, freshVersion), true); - await combosDb.updateCombo((parent as any).id, { strategy: "round-robin" }); + await combosDb.updateCombo(String(parent.id), { strategy: "round-robin" }); assert.equal( cacheStillValid(freshTs, freshVersion), false, @@ -133,19 +135,77 @@ test("deleteCombo and reorderCombos also invalidate the cache", async () => { let ts = Date.now(); let version = readCache.getCombosCacheVersion(); - await combosDb.reorderCombos([(b as any).id, (a as any).id]); - assert.equal( - cacheStillValid(ts, version), - false, - "reorderCombos must invalidate the cache" - ); + await combosDb.reorderCombos([String(b.id), String(a.id)]); + assert.equal(cacheStillValid(ts, version), false, "reorderCombos must invalidate the cache"); ts = Date.now(); version = readCache.getCombosCacheVersion(); - await combosDb.deleteCombo((a as any).id); + await combosDb.deleteCombo(String(a.id)); + assert.equal(cacheStillValid(ts, version), false, "deleteCombo must invalidate the cache"); +}); + +test("reorderCombo side effects follow physical writes even when stored JSON is corrupt", async () => { + const combo = await combosDb.createCombo({ + name: "Corrupt Payload", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + core.getDbInstance().prepare("UPDATE combos SET data = '' WHERE id = ?").run(String(combo.id)); + + const before = readCache.getCombosCacheVersion(); + const reordered = await combosDb.reorderCombos([String(combo.id)]); + + assert.deepEqual(reordered, []); + assert.notEqual( + readCache.getCombosCacheVersion(), + before, + "a physical reorder write must preserve the legacy invalidation side effect" + ); +}); + +test("updateCombo preserves the existing session-pin cleanup contract", async () => { + const combo = await combosDb.createCombo({ + name: "Before Rename", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + + contextHandoffsDb.recordSessionModelUsage( + "session-before", + "Before Rename", + "openai/gpt-4.1", + "openai" + ); + contextHandoffsDb.recordSessionModelUsage( + "session-after", + "After Rename", + "openai/gpt-4.1-mini", + "openai" + ); + + await combosDb.updateCombo(String(combo.id), { + name: "After Rename", + models: [{ provider: "openai", model: "gpt-4.1-mini" }], + }); + + assert.equal(contextHandoffsDb.getLastSessionModel("session-before", "Before Rename"), null); + assert.equal(contextHandoffsDb.getLastSessionModel("session-after", "After Rename"), null); +}); + +test("updateCombo does not clear session pins when models are omitted", async () => { + const combo = await combosDb.createCombo({ + name: "Metadata Only", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + contextHandoffsDb.recordSessionModelUsage( + "session-metadata", + "Metadata Only", + "openai/gpt-4.1", + "openai" + ); + + await combosDb.updateCombo(String(combo.id), { description: "metadata change" }); + assert.equal( - cacheStillValid(ts, version), - false, - "deleteCombo must invalidate the cache" + contextHandoffsDb.getLastSessionModel("session-metadata", "Metadata Only"), + "openai/gpt-4.1" ); }); diff --git a/tests/unit/db/repositories/sqliteComboRepositories.test.ts b/tests/unit/db/repositories/sqliteComboRepositories.test.ts new file mode 100644 index 0000000000..a9d9181234 --- /dev/null +++ b/tests/unit/db/repositories/sqliteComboRepositories.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import assert from "node:assert/strict"; +import test from "node:test"; + +import { registerComboRepositoryConformance } from "../../../helpers/persistence/comboRepositoryConformance.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repository-contract-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../../src/lib/db/core.ts"); +const { sqliteComboRepository } = + await import("../../../../src/lib/db/repositories/sqliteComboRepository.ts"); +const { sqliteModelComboMappingRepository } = + await import("../../../../src/lib/db/repositories/sqliteModelComboMappingRepository.ts"); +const combosDb = await import("../../../../src/lib/db/combos.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +registerComboRepositoryConformance(async () => ({ + combos: sqliteComboRepository, + mappings: sqliteModelComboMappingRepository, + reset: resetStorage, + async corruptComboPayload(comboId: string): Promise { + core.getDbInstance().prepare("UPDATE combos SET data = ? WHERE id = ?").run("", comboId); + }, +})); + +test("legacy combo count facade remains synchronous", async () => { + await resetStorage(); + + assert.equal(typeof combosDb.getCombosCount(), "number"); + assert.equal(combosDb.getCombosCount(), 0); + await sqliteComboRepository.create({ name: "Counted", models: [] }); + assert.equal(combosDb.getCombosCount(), 1); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +});