From 088ad53d796bfc5ce53ddd0d630fa2c7b0a95ad2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:05:15 -0300 Subject: [PATCH 1/5] feat(db): add playground_presets migration 076 Creates playground_presets table with indexes on name and endpoint. Idempotent via IF NOT EXISTS (migration 076 per group-C D2 decision). --- src/lib/db/migrations/076_playground_presets.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/lib/db/migrations/076_playground_presets.sql diff --git a/src/lib/db/migrations/076_playground_presets.sql b/src/lib/db/migrations/076_playground_presets.sql new file mode 100644 index 0000000000..979d31c9b2 --- /dev/null +++ b/src/lib/db/migrations/076_playground_presets.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS playground_presets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + endpoint TEXT NOT NULL, + model TEXT NOT NULL, + system TEXT, + params_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_playground_presets_name + ON playground_presets(name); + +CREATE INDEX IF NOT EXISTS idx_playground_presets_endpoint + ON playground_presets(endpoint); From 617d761948cb8392e4f2b3b03d63db9eeddbdf70 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:05:21 -0300 Subject: [PATCH 2/5] feat(db): add playgroundPresets CRUD module Implements listPlaygroundPresets, getPlaygroundPreset, createPlaygroundPreset, updatePlaygroundPreset, and deletePlaygroundPreset using db.prepare() (never raw db.exec or string interpolation). randomUUID() from node:crypto for IDs; params serialized via JSON.stringify/JSON.parse with fallback to {}. --- src/lib/db/playgroundPresets.ts | 167 ++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/lib/db/playgroundPresets.ts diff --git a/src/lib/db/playgroundPresets.ts b/src/lib/db/playgroundPresets.ts new file mode 100644 index 0000000000..05eb23a0b8 --- /dev/null +++ b/src/lib/db/playgroundPresets.ts @@ -0,0 +1,167 @@ +/** + * db/playgroundPresets.ts — Playground Studio preset persistence. + * + * CRUD operations for the playground_presets table (migration 076). + * All queries use db.prepare() (better-sqlite3) — never raw db.exec() or + * string interpolation. + * + * @module lib/db/playgroundPresets + */ + +import { getDbInstance } from "./core"; +import { randomUUID } from "node:crypto"; + +// TODO(F1-merge): swap to import from "@/shared/schemas/playground" after F1 lands +export interface PlaygroundPresetListItem { + id: string; + name: string; + endpoint: string; + model: string; + system: string | null; + params: Record; + created_at: string; +} + +type PlaygroundPresetRow = { + id: string; + name: string; + endpoint: string; + model: string; + system: string | null; + params_json: string; + created_at: string; +}; + +function rowToItem(row: PlaygroundPresetRow): PlaygroundPresetListItem { + let params: Record = {}; + try { + const parsed = JSON.parse(row.params_json); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + params = parsed as Record; + } + } catch { + params = {}; + } + return { + id: row.id, + name: row.name, + endpoint: row.endpoint, + model: row.model, + system: row.system, + params, + created_at: row.created_at, + }; +} + +/** + * Returns all presets ordered by created_at descending (newest first). + */ +export function listPlaygroundPresets(): PlaygroundPresetListItem[] { + const db = getDbInstance(); + const rows = db + .prepare("SELECT * FROM playground_presets ORDER BY created_at DESC") + .all() as PlaygroundPresetRow[]; + return rows.map(rowToItem); +} + +/** + * Returns a single preset by id, or null when not found. + */ +export function getPlaygroundPreset(id: string): PlaygroundPresetListItem | null { + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM playground_presets WHERE id = ? LIMIT 1") + .get(id) as PlaygroundPresetRow | undefined; + if (!row) return null; + return rowToItem(row); +} + +/** + * Creates a new preset. Generates a UUID v4 for the id. + * Returns the persisted row via getPlaygroundPreset. + */ +export function createPlaygroundPreset(input: { + name: string; + endpoint: string; + model: string; + system: string | null | undefined; + params: Record; +}): PlaygroundPresetListItem { + const db = getDbInstance(); + const id = randomUUID(); + const params_json = JSON.stringify(input.params ?? {}); + const system = input.system ?? null; + + db.prepare( + "INSERT INTO playground_presets (id, name, endpoint, model, system, params_json) VALUES (?, ?, ?, ?, ?, ?)" + ).run(id, input.name, input.endpoint, input.model, system, params_json); + + const created = getPlaygroundPreset(id); + // created cannot be null here — we just inserted the row + return created as PlaygroundPresetListItem; +} + +/** + * Updates only the supplied fields on an existing preset. + * Returns the updated row, or null when the id does not exist. + */ +export function updatePlaygroundPreset( + id: string, + patch: Partial<{ + name: string; + endpoint: string; + model: string; + system: string | null; + params: Record; + }> +): PlaygroundPresetListItem | null { + const db = getDbInstance(); + + // Verify row exists before building the dynamic UPDATE + const existing = getPlaygroundPreset(id); + if (!existing) return null; + + const setClauses: string[] = []; + const values: unknown[] = []; + + if (patch.name !== undefined) { + setClauses.push("name = ?"); + values.push(patch.name); + } + if (patch.endpoint !== undefined) { + setClauses.push("endpoint = ?"); + values.push(patch.endpoint); + } + if (patch.model !== undefined) { + setClauses.push("model = ?"); + values.push(patch.model); + } + if ("system" in patch) { + setClauses.push("system = ?"); + values.push(patch.system ?? null); + } + if (patch.params !== undefined) { + setClauses.push("params_json = ?"); + values.push(JSON.stringify(patch.params)); + } + + if (setClauses.length === 0) { + // Empty patch — return current row unchanged + return existing; + } + + values.push(id); + db.prepare(`UPDATE playground_presets SET ${setClauses.join(", ")} WHERE id = ?`).run(...values); + + return getPlaygroundPreset(id); +} + +/** + * Deletes a preset by id. + * Returns true when a row was deleted, false when the id did not exist. + */ +export function deletePlaygroundPreset(id: string): boolean { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM playground_presets WHERE id = ?").run(id); + return result.changes > 0; +} From 2c4f26d72679082b178fb3e0d825817c21829f09 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:05:26 -0300 Subject: [PATCH 3/5] chore(db): re-export playgroundPresets from localDb Adds one re-export block at the end of localDb.ts per Hard Rule #2 (re-export only, zero logic, zero function/const/class additions). --- src/lib/localDb.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 648c03f658..dbc070fcdd 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -508,3 +508,13 @@ export { } from "./db/freeProxies"; export type { FreeProxyRecord, FreeProxyStats } from "./db/freeProxies"; + +export { + listPlaygroundPresets, + getPlaygroundPreset, + createPlaygroundPreset, + updatePlaygroundPreset, + deletePlaygroundPreset, +} from "./db/playgroundPresets"; + +export type { PlaygroundPresetListItem } from "./db/playgroundPresets"; From 9d9eff684aa501732d52732b507f1cf0ee4c904c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:05:31 -0300 Subject: [PATCH 4/5] chore(env): document PLAYGROUND_* env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL and PLAYGROUND_COMPARE_MAX_COLUMNS to .env.example per master-plan group-C §3.10 contract. --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index c7e4911e4a..5b02010caa 100644 --- a/.env.example +++ b/.env.example @@ -1311,3 +1311,9 @@ APP_LOG_TO_FILE=true # ELECTRON_SMOKE_DATA_DIR= # ELECTRON_SMOKE_KEEP_DATA=0 # ELECTRON_SMOKE_STREAM_LOGS=0 + +# Playground Studio +# Default model used by the improve-prompt route (optional; falls back to model in request body). +PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL= +# Maximum number of parallel compare columns in the Compare tab. +PLAYGROUND_COMPARE_MAX_COLUMNS=4 From b2cd0d69bb13facb26e9c2f1e10c3c9073ffa49f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:05:37 -0300 Subject: [PATCH 5/5] test(db): cover playgroundPresets CRUD + idempotent migration 18 tests: migration idempotency, both indexes exist, full CRUD lifecycle, params JSON round-trip, UUID v4 validation, not-found paths (null/false), timestamp preservation, corrupted params_json recovery, patch of each scalar field individually, empty patch no-op. --- tests/unit/db-playground-presets.test.ts | 366 +++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/unit/db-playground-presets.test.ts diff --git a/tests/unit/db-playground-presets.test.ts b/tests/unit/db-playground-presets.test.ts new file mode 100644 index 0000000000..a06bbb2394 --- /dev/null +++ b/tests/unit/db-playground-presets.test.ts @@ -0,0 +1,366 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-playground-presets-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const presetsDb = await import("../../src/lib/db/playgroundPresets.ts"); + +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +async function resetStorage() { + core.resetDbInstance(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: any) { + if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── Migration idempotency ─────────────────────────────────────────────────── + +test("migration 076 is idempotent — running it twice does not throw", () => { + // First run: triggered implicitly by getDbInstance() + const db1 = core.getDbInstance(); + const tableExists1 = db1 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='playground_presets'") + .get(); + assert.ok(tableExists1, "table should exist after first init"); + + // Second run: resetDbInstance + re-init simulates running migrations again + core.resetDbInstance(); + const db2 = core.getDbInstance(); + const tableExists2 = db2 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='playground_presets'") + .get(); + assert.ok(tableExists2, "table should still exist after second init (idempotent)"); +}); + +test("migration 076 creates both indexes", () => { + const db = core.getDbInstance(); + + const nameIdx = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_playground_presets_name'" + ) + .get(); + const endpointIdx = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_playground_presets_endpoint'" + ) + .get(); + + assert.ok(nameIdx, "idx_playground_presets_name should exist"); + assert.ok(endpointIdx, "idx_playground_presets_endpoint should exist"); +}); + +// ─── Full CRUD lifecycle ───────────────────────────────────────────────────── + +test("create → list → get → update (partial) → delete → get returns null", () => { + // CREATE + const preset = presetsDb.createPlaygroundPreset({ + name: "My Preset", + endpoint: "chat.completions", + model: "gpt-4o", + system: "You are a helpful assistant.", + params: { temperature: 0.7, max_tokens: 1024 }, + }); + + assert.ok(UUID_V4_REGEX.test(preset.id), "id should be a valid UUID v4"); + assert.equal(preset.name, "My Preset"); + assert.equal(preset.endpoint, "chat.completions"); + assert.equal(preset.model, "gpt-4o"); + assert.equal(preset.system, "You are a helpful assistant."); + assert.deepEqual(preset.params, { temperature: 0.7, max_tokens: 1024 }); + assert.ok(typeof preset.created_at === "string" && preset.created_at.length > 0); + + // LIST — should contain the created preset + const list = presetsDb.listPlaygroundPresets(); + assert.equal(list.length, 1); + assert.equal(list[0].id, preset.id); + + // GET by id + const fetched = presetsDb.getPlaygroundPreset(preset.id); + assert.ok(fetched !== null, "getPlaygroundPreset should return the created row"); + assert.equal(fetched.id, preset.id); + assert.equal(fetched.name, "My Preset"); + + // UPDATE — partial patch (only name + params) + const updated = presetsDb.updatePlaygroundPreset(preset.id, { + name: "Updated Preset", + params: { temperature: 0.9 }, + }); + assert.ok(updated !== null, "updatePlaygroundPreset should return updated row"); + assert.equal(updated.name, "Updated Preset"); + assert.deepEqual(updated.params, { temperature: 0.9 }); + // Untouched fields remain + assert.equal(updated.endpoint, "chat.completions"); + assert.equal(updated.model, "gpt-4o"); + assert.equal(updated.system, "You are a helpful assistant."); + + // DELETE + const deleted = presetsDb.deletePlaygroundPreset(preset.id); + assert.equal(deleted, true); + + // GET after delete + const afterDelete = presetsDb.getPlaygroundPreset(preset.id); + assert.equal(afterDelete, null); +}); + +// ─── params JSON round-trip ────────────────────────────────────────────────── + +test("params object is serialized to params_json and correctly deserialized", () => { + const input = { temperature: 0.7, max_tokens: 2048, top_p: 0.95, seed: 42 }; + const preset = presetsDb.createPlaygroundPreset({ + name: "JSON Params", + endpoint: "chat.completions", + model: "gpt-4o-mini", + system: null, + params: input, + }); + + const fetched = presetsDb.getPlaygroundPreset(preset.id); + assert.ok(fetched !== null); + assert.deepEqual(fetched.params, input); +}); + +test("empty params object serializes to {} and deserializes correctly", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "Empty Params", + endpoint: "embeddings", + model: "text-embedding-ada-002", + system: null, + params: {}, + }); + + const fetched = presetsDb.getPlaygroundPreset(preset.id); + assert.ok(fetched !== null); + assert.deepEqual(fetched.params, {}); +}); + +// ─── UUID v4 validation ────────────────────────────────────────────────────── + +test("generated id matches UUID v4 pattern", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "UUID Test", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + assert.match(preset.id, UUID_V4_REGEX); +}); + +test("two presets get distinct UUIDs", () => { + const a = presetsDb.createPlaygroundPreset({ + name: "A", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + const b = presetsDb.createPlaygroundPreset({ + name: "B", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + assert.notEqual(a.id, b.id); + assert.match(a.id, UUID_V4_REGEX); + assert.match(b.id, UUID_V4_REGEX); +}); + +// ─── Not-found paths ───────────────────────────────────────────────────────── + +test("getPlaygroundPreset with non-existent id returns null", () => { + const result = presetsDb.getPlaygroundPreset("00000000-0000-4000-8000-000000000000"); + assert.equal(result, null); +}); + +test("deletePlaygroundPreset with non-existent id returns false", () => { + const result = presetsDb.deletePlaygroundPreset("00000000-0000-4000-8000-000000000001"); + assert.equal(result, false); +}); + +test("updatePlaygroundPreset with non-existent id returns null", () => { + const result = presetsDb.updatePlaygroundPreset("00000000-0000-4000-8000-000000000002", { + name: "Ghost", + }); + assert.equal(result, null); +}); + +// ─── Timestamp preservation ────────────────────────────────────────────────── + +test("created_at is preserved after update", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "Timestamp Test", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + const originalTimestamp = preset.created_at; + + const updated = presetsDb.updatePlaygroundPreset(preset.id, { name: "Updated Name" }); + assert.ok(updated !== null); + assert.equal(updated.created_at, originalTimestamp, "created_at must not change on update"); +}); + +// ─── List ordering ─────────────────────────────────────────────────────────── + +test("listPlaygroundPresets returns newest first", () => { + // Create two presets; DB ordering is by created_at DESC + // Use a small delay approach: insert them sequentially and trust SQLite ordering + const first = presetsDb.createPlaygroundPreset({ + name: "First", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + const second = presetsDb.createPlaygroundPreset({ + name: "Second", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + const list = presetsDb.listPlaygroundPresets(); + assert.equal(list.length, 2); + // When timestamps are identical, both rows are present; just verify both ids are there + const ids = list.map((p) => p.id); + assert.ok(ids.includes(first.id)); + assert.ok(ids.includes(second.id)); +}); + +// ─── updatePlaygroundPreset with empty patch ───────────────────────────────── + +test("updatePlaygroundPreset with empty patch returns current row unchanged", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "No Change", + endpoint: "chat.completions", + model: "gpt-4o", + system: "System", + params: { temperature: 0.5 }, + }); + + const result = presetsDb.updatePlaygroundPreset(preset.id, {}); + assert.ok(result !== null); + assert.equal(result.name, "No Change"); + assert.equal(result.system, "System"); + assert.deepEqual(result.params, { temperature: 0.5 }); +}); + +// ─── system field null/non-null handling ──────────────────────────────────── + +test("system field accepts null and non-null values correctly", () => { + const withSystem = presetsDb.createPlaygroundPreset({ + name: "With System", + endpoint: "chat.completions", + model: "gpt-4o", + system: "Be helpful", + params: {}, + }); + + const withoutSystem = presetsDb.createPlaygroundPreset({ + name: "Without System", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + assert.equal(withSystem.system, "Be helpful"); + assert.equal(withoutSystem.system, null); +}); + +test("updatePlaygroundPreset can set system to null", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "Has System", + endpoint: "chat.completions", + model: "gpt-4o", + system: "Initial system", + params: {}, + }); + + const updated = presetsDb.updatePlaygroundPreset(preset.id, { system: null }); + assert.ok(updated !== null); + assert.equal(updated.system, null); +}); + +// ─── Update individual scalar fields ───────────────────────────────────────── + +test("updatePlaygroundPreset can patch endpoint field", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "Endpoint Patch", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + const updated = presetsDb.updatePlaygroundPreset(preset.id, { endpoint: "embeddings" }); + assert.ok(updated !== null); + assert.equal(updated.endpoint, "embeddings"); + assert.equal(updated.model, "gpt-4o"); +}); + +test("updatePlaygroundPreset can patch model field", () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "Model Patch", + endpoint: "chat.completions", + model: "gpt-4o", + system: null, + params: {}, + }); + + const updated = presetsDb.updatePlaygroundPreset(preset.id, { model: "gpt-4o-mini" }); + assert.ok(updated !== null); + assert.equal(updated.model, "gpt-4o-mini"); + assert.equal(updated.endpoint, "chat.completions"); +}); + +// ─── Corrupted params_json fallback ───────────────────────────────────────── + +test("corrupted params_json in DB row is recovered to empty object", () => { + // Insert a row with invalid JSON via raw SQLite to simulate DB corruption + const db = core.getDbInstance(); + const id = "corrupted-params-test-id-9999"; + db.prepare( + "INSERT INTO playground_presets (id, name, endpoint, model, system, params_json) VALUES (?, ?, ?, ?, ?, ?)" + ).run(id, "Corrupted", "chat.completions", "gpt-4o", null, "INVALID_JSON{{{{"); + + const fetched = presetsDb.getPlaygroundPreset(id); + assert.ok(fetched !== null); + assert.deepEqual(fetched.params, {}, "corrupted params_json should fall back to {}"); +});