mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
Merge remote-tracking branch 'origin/release/v3.8.50' into babysit/pr-9631-push
This commit is contained in:
@@ -217,11 +217,15 @@ export async function POST(request: Request) {
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
};
|
||||
const connection: any = await upsertImportedKiroConnection(targetProvider, record, {
|
||||
profileArn: resolvedProfileArn,
|
||||
clientId: providerSpecificData.clientId,
|
||||
email,
|
||||
});
|
||||
// Only include clientId in the identity for IDC imports where it is genuinely
|
||||
// unique per account (#2059). For Builder ID / social imports the OIDC clientId
|
||||
// comes from a machine-wide cached OIDC registration (shared across all accounts
|
||||
// on the same machine), so using it for identity matching would cause different
|
||||
// accounts to overwrite each other (#9435). Without clientId, the identity
|
||||
// matching falls through to the email field, which correctly distinguishes imports.
|
||||
const identity: Record<string, unknown> = { profileArn: resolvedProfileArn, email };
|
||||
if (isIdc) identity.clientId = providerSpecificData.clientId;
|
||||
const connection: any = await upsertImportedKiroConnection(targetProvider, record, identity);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
61
src/domain/persistence/comboRepositories.ts
Normal file
61
src/domain/persistence/comboRepositories.ts
Normal 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>;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
openDatabaseAsync,
|
||||
} from "./adapters/driverFactory";
|
||||
import path from "path";
|
||||
import { retryProbeIfTransient } from "./probeUtils";
|
||||
import fs from "fs";
|
||||
import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths";
|
||||
import { runMigrations } from "./migrationRunner";
|
||||
@@ -1142,18 +1143,19 @@ export function getDbInstance(): SqliteDatabase {
|
||||
`Original error: ${message}`
|
||||
);
|
||||
}
|
||||
preservedCriticalState = captureCriticalDbState(sqliteFile);
|
||||
|
||||
// SAFETY: Never delete the database — rename to backup so data can be recovered.
|
||||
// The old code would silently destroy all user data on any probe failure.
|
||||
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
|
||||
try {
|
||||
fs.renameSync(sqliteFile, failedPath);
|
||||
console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`);
|
||||
failedProbePath = failedPath;
|
||||
failedProbeMessage = message;
|
||||
} catch {
|
||||
/* ok */
|
||||
if (!retryProbeIfTransient(sqliteFile, e, openSqliteDatabase, closeProbeIfSafe)) {
|
||||
preservedCriticalState = captureCriticalDbState(sqliteFile);
|
||||
// SAFETY: Never delete the database — rename to backup so data can be recovered.
|
||||
// The old code would silently destroy all user data on any probe failure.
|
||||
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
|
||||
try {
|
||||
fs.renameSync(sqliteFile, failedPath);
|
||||
console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`);
|
||||
failedProbePath = failedPath;
|
||||
failedProbeMessage = message;
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
96
src/lib/db/probeUtils.ts
Normal file
96
src/lib/db/probeUtils.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Probe-retry utilities for the SQLite corruption-probe path in getDbInstance().
|
||||
*
|
||||
* Transient probe errors (SQLITE_BUSY, ENOENT, SQLITE_PROTOCOL, SQLITE_IOERR)
|
||||
* should be retried with backoff instead of immediately renaming the DB away
|
||||
* and creating an empty one (data loss under concurrent load, #9541).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Identifies transient SQLite/OS probe errors that should be retried instead of
|
||||
* triggering the corruption-rename path.
|
||||
*
|
||||
* Transient errors are conditions that can self-resolve within milliseconds:
|
||||
* - SQLITE_BUSY: database is locked by another connection
|
||||
* - SQLITE_PROTOCOL: locking protocol violation
|
||||
* - SQLITE_IOERR: disk I/O error (can be transient under load)
|
||||
* - ENOENT: file disappeared (race with another process/worker deleting it)
|
||||
*
|
||||
* Fatal errors (native load failures, OOM, module-not-found) are NOT transient.
|
||||
*/
|
||||
export function isTransientProbeError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous sleep that blocks the event loop for `ms` milliseconds.
|
||||
* Only used in the transient-probe-error retry path where we are already in
|
||||
* a synchronous context (better-sqlite3). Uses `Atomics.wait` which yields to
|
||||
* the OS scheduler during the wait, falling back to a busy-wait on runtimes
|
||||
* where Atomics.wait is restricted.
|
||||
*/
|
||||
function syncSleep(ms: number): void {
|
||||
if (typeof SharedArrayBuffer !== "undefined" && typeof Atomics !== "undefined") {
|
||||
try {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
return;
|
||||
} catch {
|
||||
// Atomics.wait may throw on restricted runtimes — fall through to busy-wait
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
/* busy-wait */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for openSqliteDatabase callback — avoids importing the full SQLite adapter type.
|
||||
*/
|
||||
type OpenDbFn = (
|
||||
filePath: string,
|
||||
options?: Record<string, unknown>
|
||||
) => {
|
||||
driver: string;
|
||||
open: boolean;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retries opening a SQLite database probe when the initial attempt fails with
|
||||
* a transient error. Uses exponential backoff (500ms, 1000ms, 2000ms).
|
||||
*
|
||||
* @param sqliteFile - Path to the SQLite database file
|
||||
* @param openDb - Function to open the database (normally openSqliteDatabase)
|
||||
* @param closeDb - Function to safely close the probe adapter
|
||||
* @returns true if the retry succeeded (transient condition resolved)
|
||||
* false if all retries were exhausted or error is non-transient
|
||||
*/
|
||||
export function retryProbeIfTransient(
|
||||
sqliteFile: string,
|
||||
probeError: unknown,
|
||||
openDb: OpenDbFn,
|
||||
closeDb: (adapter: { driver: string; open: boolean; close(): void } | null | undefined) => void
|
||||
): boolean {
|
||||
if (!isTransientProbeError(probeError)) return false;
|
||||
|
||||
const retryDelays = [500, 1000, 2000];
|
||||
for (let i = 0; i < retryDelays.length; i++) {
|
||||
syncSleep(retryDelays[i]);
|
||||
try {
|
||||
const retryAdapter = openDb(sqliteFile, { readonly: true });
|
||||
closeDb(retryAdapter);
|
||||
return true;
|
||||
} catch {
|
||||
// Retry failed, try next delay
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[DB] All ${retryDelays.length} transient probe retries exhausted — declaring corruption`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
32
src/lib/db/repositories/routingConfigRepositories.ts
Normal file
32
src/lib/db/repositories/routingConfigRepositories.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
348
src/lib/db/repositories/sqliteComboRepository.ts
Normal file
348
src/lib/db/repositories/sqliteComboRepository.ts
Normal 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,
|
||||
};
|
||||
244
src/lib/db/repositories/sqliteModelComboMappingRepository.ts
Normal file
244
src/lib/db/repositories/sqliteModelComboMappingRepository.ts
Normal 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,
|
||||
};
|
||||
@@ -234,8 +234,12 @@ const urlPath =
|
||||
? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1))
|
||||
: decodeURIComponent(MITM_SERVER_URL.pathname);
|
||||
|
||||
const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs");
|
||||
const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath;
|
||||
// Lazy-resolve to avoid module-level fs.existsSync + process.cwd() at module scope,
|
||||
// which causes Turbopack's NFT tracer to follow the path into the entire src/ tree.
|
||||
function resolveMitmServerPath(): string {
|
||||
const cwdPath = path.join(/* turbopackIgnore: true */ process.cwd(), "src", "mitm", "server.cjs");
|
||||
return fs.existsSync(cwdPath) ? cwdPath : urlPath;
|
||||
}
|
||||
|
||||
// Check if a PID is alive
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
@@ -607,7 +611,7 @@ async function startMitmInternal(
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
|
||||
serverProcess = spawn(process.execPath, [resolveMitmServerPath()], {
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -528,9 +528,6 @@ const getExpectedParentPaths = (): string[] => {
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call)
|
||||
const EXPECTED_PARENT_PATHS = getExpectedParentPaths();
|
||||
|
||||
const getExtraPaths = () =>
|
||||
String(process.env.CLI_EXTRA_PATHS || "")
|
||||
.split(path.delimiter)
|
||||
@@ -820,7 +817,7 @@ export const checkKnownPath = async (commandPath: string) => {
|
||||
const isWithinExpected = await isLocationTrusted(
|
||||
commandPath,
|
||||
realPath,
|
||||
EXPECTED_PARENT_PATHS,
|
||||
getExpectedParentPaths(),
|
||||
isPathWithin,
|
||||
fs.realpath
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user