refactor(db): add combo repository boundary (#8757)

Validated in local merge-train (tomni-proxmox-113)
This commit is contained in:
小妍儿 ✨
2026-08-07 06:11:57 +08:00
committed by GitHub
parent ebdbe3a38f
commit 5f471181fa
9 changed files with 1114 additions and 553 deletions

View File

@@ -0,0 +1,61 @@
export type ComboRecord = Record<string, unknown>;
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<ComboRecord[]>;
count(): Promise<number>;
findById(id: string): Promise<ComboRecord | null>;
findByName(name: string): Promise<ComboRecord | null>;
findByNameInsensitive(name: string): Promise<ComboRecord | null>;
create(data: ComboRecord): Promise<ComboRecord>;
update(id: string, data: ComboRecord): Promise<ComboUpdateResult | null>;
reorder(comboIds: string[]): Promise<ComboReorderResult>;
deleteById(id: string): Promise<boolean>;
}
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<CreateModelComboMappingInput>;
export interface ModelComboMappingPage {
items: ModelComboMapping[];
total: number;
}
export interface ModelComboMappingRepository {
list(options?: { limit?: number; offset?: number }): Promise<ModelComboMappingPage>;
findById(id: string): Promise<ModelComboMapping | null>;
create(data: CreateModelComboMappingInput): Promise<ModelComboMapping>;
update(id: string, data: UpdateModelComboMappingInput): Promise<ModelComboMapping | null>;
deleteById(id: string): Promise<boolean>;
resolveForModel(model: string): Promise<ComboRecord | null>;
}

View File

@@ -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<string, unknown>;
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<typeof getDbInstance>,
extraNames: string[] = []
): Set<string> {
const rows = db.prepare("SELECT name FROM combos").all();
const names = new Set<string>();
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<typeof getDbInstance>,
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<ComboRecord[]> {
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<ComboRecord | null> {
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<ComboRecord | null> {
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<ComboRecord | null> {
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<ComboRecord> {
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<ComboRecord | null> {
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<string>();
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<ComboRecord[]> {
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<boolean> {
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<boolean> {
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));

View File

@@ -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<ModelComboMappingPage> {
return repository.list(options);
}
/**
* Get a single mapping by ID.
*/
export async function getModelComboMappingById(id: string): Promise<ModelComboMapping | null> {
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<ModelComboMapping | null> {
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<ModelComboMapping> {
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<ModelComboMapping> {
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<ModelComboMapping | null> {
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<boolean> {
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<boolean> {
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<Record<string, unknown> | 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<string, unknown>;
if (combo.isActive === false) {
continue;
}
return combo;
} catch {
// Corrupted combo data — skip
continue;
}
}
}
return null;
export function resolveComboForModel(model: string): Promise<Record<string, unknown> | null> {
return repository.resolveForModel(model);
}

View File

@@ -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,
},
};

View File

@@ -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<string, unknown>;
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<typeof getDbInstance>,
extraNames: string[] = []
): Set<string> {
const rows = db.prepare("SELECT name FROM combos").all();
const names = new Set<string>();
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<typeof getDbInstance>,
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<ComboUpdateResult | null> {
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<ComboReorderResult> {
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<string>();
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,
};

View File

@@ -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<ModelComboMapping | null> {
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<ModelComboMapping> {
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<ModelComboMapping | null> {
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<boolean> {
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<Record<string, unknown> | 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<string, unknown>;
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,
};

View File

@@ -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<void>;
corruptComboPayload(comboId: string): Promise<void>;
}
export function registerComboRepositoryConformance(
createHarness: () => Promise<ComboRepositoryHarness>
): 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);
});
}

View File

@@ -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"
);
});

View File

@@ -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<void> {
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<void> {
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 });
});