Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
{}
{
}
202 changes: 147 additions & 55 deletions electron/workflow/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,97 @@
/**
* SQLite database schema definitions and migrations (sql.js version).
*
* Uses named migrations instead of sequential version numbers to avoid
* conflicts when multiple branches define migrations independently.
* Each migration has a unique string ID and an idempotent apply function.
*/

import type { Database as SqlJsDatabase } from "sql.js";

const DEFAULT_PER_EXECUTION_LIMIT = 10.0;
const DEFAULT_DAILY_LIMIT = 100.0;

/** Map old numeric versions to the named migration IDs they correspond to. */
const LEGACY_VERSION_MAP: Record<number, string[]> = {
1: ["001_initial_schema"],
2: ["001_initial_schema", "002_add_templates"],
3: ["001_initial_schema", "002_add_templates"],
};

interface NamedMigration {
id: string;
apply: (db: SqlJsDatabase) => void;
}

/**
* All migrations in order. Each `apply` MUST be idempotent so that
* re-running a migration on a database that already has the change
* is safe (e.g. use IF NOT EXISTS, check columns before ALTER).
*/
const migrations: NamedMigration[] = [
{
id: "001_initial_schema",
apply: (_db: SqlJsDatabase) => {
// Handled by initializeSchema — listed here for completeness.
},
},
{
id: "002_add_templates",
apply: (db: SqlJsDatabase) => {
console.log("[Schema] Applying migration: 002_add_templates");
db.run(`CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
tags TEXT,
type TEXT NOT NULL CHECK (type IN ('public', 'custom')),
template_type TEXT NOT NULL CHECK (template_type IN ('playground', 'workflow')),
is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
author TEXT,
use_count INTEGER NOT NULL DEFAULT 0,
thumbnail TEXT,
playground_data TEXT,
workflow_data TEXT
)`);

db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)",
);
},
},
{
id: "003_add_search_text",
apply: (db: SqlJsDatabase) => {
console.log("[Schema] Applying migration: 003_add_search_text");
const cols = db.exec("PRAGMA table_info(templates)");
const hasColumn = cols[0]?.values?.some(
(row) => row[1] === "search_text",
);
if (!hasColumn) {
db.run("ALTER TABLE templates ADD COLUMN search_text TEXT");
}
},
},
];

export function initializeSchema(db: SqlJsDatabase): void {
db.run(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
// Use the new named migrations table from the start
db.run(`CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);

Expand Down Expand Up @@ -91,7 +173,8 @@ export function initializeSchema(db: SqlJsDatabase): void {
use_count INTEGER NOT NULL DEFAULT 0,
thumbnail TEXT,
playground_data TEXT,
workflow_data TEXT
workflow_data TEXT,
search_text TEXT
)`);

// Indexes
Expand Down Expand Up @@ -144,64 +227,73 @@ export function initializeSchema(db: SqlJsDatabase): void {
db.run(
"INSERT OR IGNORE INTO api_keys (id, wavespeed_key, llm_key) VALUES (1, NULL, NULL)",
);
db.run("INSERT OR IGNORE INTO schema_version (version) VALUES (1)");

// Mark all migrations as applied for a fresh database
for (const m of migrations) {
db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [m.id]);
}
}

export function runMigrations(db: SqlJsDatabase): void {
const result = db.exec("SELECT MAX(version) as version FROM schema_version");
const currentVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0;

const migrations: Array<{
version: number;
apply: (db: SqlJsDatabase) => void;
}> = [
// Migration 2: Add templates table
{
version: 2,
apply: (db: SqlJsDatabase) => {
console.log("[Schema] Applying migration 2: Add templates table");

db.run(`CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
tags TEXT,
type TEXT NOT NULL CHECK (type IN ('public', 'custom')),
template_type TEXT NOT NULL CHECK (template_type IN ('playground', 'workflow')),
is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
author TEXT,
use_count INTEGER NOT NULL DEFAULT 0,
thumbnail TEXT,
playground_data TEXT,
workflow_data TEXT
)`);

db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)",
);
db.run(
"CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)",
);

db.run("INSERT INTO schema_version (version) VALUES (2)");
},
},
];
// --- Step 1: Detect and upgrade from legacy numeric schema_version ---
const tables = db.exec(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('schema_version', 'schema_migrations')",
);
const tableNames = tables[0]?.values?.map((r) => r[0] as string) ?? [];
const hasLegacyTable = tableNames.includes("schema_version");
const hasNewTable = tableNames.includes("schema_migrations");

if (hasLegacyTable && !hasNewTable) {
// Migrate from old numeric system to named migrations
console.log(
"[Schema] Upgrading from legacy schema_version to named migrations",
);

const result = db.exec(
"SELECT MAX(version) as version FROM schema_version",
);
const legacyVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0;

// Create the new table
db.run(`CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);

// Map old version number to known migration IDs
const knownApplied = LEGACY_VERSION_MAP[legacyVersion] ?? [
"001_initial_schema",
];
for (const id of knownApplied) {
db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [id]);
}

// Drop the old table
db.run("DROP TABLE IF EXISTS schema_version");
}

if (!hasLegacyTable && !hasNewTable) {
// No migration tracking at all — treat as version 1 (initial schema exists)
db.run(`CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [
"001_initial_schema",
]);
}

// --- Step 2: Run any missing migrations ---
const applied = db.exec("SELECT id FROM schema_migrations");
const appliedSet = new Set(
applied[0]?.values?.map((r) => r[0] as string) ?? [],
);

for (const m of migrations) {
if (m.version > currentVersion) {
if (!appliedSet.has(m.id)) {
console.log(`[Schema] Running migration: ${m.id}`);
m.apply(db);
db.run("INSERT INTO schema_migrations (id) VALUES (?)", [m.id]);
}
}
}
40 changes: 32 additions & 8 deletions electron/workflow/db/template.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export function createTemplate(input: CreateTemplateInput): Template {
`INSERT INTO templates (
id, name, description, tags, type, template_type, is_favorite,
created_at, updated_at, author, use_count, thumbnail,
playground_data, workflow_data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
playground_data, workflow_data, search_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
Expand All @@ -45,6 +45,7 @@ export function createTemplate(input: CreateTemplateInput): Template {
input.thumbnail || null,
playgroundData,
workflowData,
input._searchText || null,
],
);

Expand All @@ -57,7 +58,7 @@ export function getTemplateById(id: string): Template | null {
const result = db.exec(
`SELECT id, name, description, tags, type, template_type, is_favorite,
created_at, updated_at, author, use_count, thumbnail,
playground_data, workflow_data
playground_data, workflow_data, search_text
FROM templates WHERE id = ?`,
[id],
);
Expand Down Expand Up @@ -87,14 +88,23 @@ export function queryTemplates(filter?: TemplateFilter): Template[] {
}

if (filter?.category && filter.templateType === "workflow") {
conditions.push("json_extract(workflow_data, '$.category') = ?");
params.push(filter.category);
conditions.push("workflow_data LIKE ?");
params.push(`%"category":"${filter.category}"%`);
}

if (filter?.search) {
const searchPattern = `%${filter.search}%`;
conditions.push("(name LIKE ? OR description LIKE ? OR tags LIKE ?)");
params.push(searchPattern, searchPattern, searchPattern);
conditions.push(
"(name LIKE ? OR description LIKE ? OR tags LIKE ? OR playground_data LIKE ? OR workflow_data LIKE ? OR search_text LIKE ?)",
);
params.push(
searchPattern,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
);
}

const whereClause =
Expand All @@ -106,7 +116,7 @@ export function queryTemplates(filter?: TemplateFilter): Template[] {

const query = `SELECT id, name, description, tags, type, template_type, is_favorite,
created_at, updated_at, author, use_count, thumbnail,
playground_data, workflow_data
playground_data, workflow_data, search_text
FROM templates ${whereClause} ${orderBy}`;
const result = db.exec(query, params);

Expand Down Expand Up @@ -192,6 +202,19 @@ export function deleteTemplates(ids: string[]): void {
persistDatabase();
}

export function queryTemplateNames(templateType?: string): string[] {
const db = getDatabase();
let query = "SELECT name FROM templates";
const params: any[] = [];
if (templateType) {
query += " WHERE template_type = ?";
params.push(templateType);
}
const result = db.exec(query, params);
if (!result.length) return [];
return result[0].values.map((row: any[]) => row[0] as string);
}

function rowToTemplate(row: any[]): Template {
const tags = row[3] ? JSON.parse(row[3] as string) : [];
const playgroundData = row[12] ? JSON.parse(row[12] as string) : null;
Expand All @@ -212,5 +235,6 @@ function rowToTemplate(row: any[]): Template {
thumbnail: row[11] as string | null,
playgroundData,
workflowData,
_searchText: (row[14] as string) || undefined,
};
}
23 changes: 19 additions & 4 deletions electron/workflow/db/workflow.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,23 @@ export function updateWorkflow(
try {
db.run("DELETE FROM nodes WHERE workflow_id = ?", [id]);
db.run("DELETE FROM edges WHERE workflow_id = ?", [id]);
for (const node of graphDefinition.nodes) {
// Insert with NULL first (safe), then restore outputId

// Deduplicate by id — guards against React Flow state containing
// duplicate entries after undo/paste/rapid operations.
const seenNodeIds = new Set<string>();
const uniqueNodes = graphDefinition.nodes.filter((n) => {
if (seenNodeIds.has(n.id)) return false;
seenNodeIds.add(n.id);
return true;
});
const seenEdgeIds = new Set<string>();
const uniqueEdges = graphDefinition.edges.filter((e) => {
if (seenEdgeIds.has(e.id)) return false;
seenEdgeIds.add(e.id);
return true;
});

for (const node of uniqueNodes) {
db.run(
`INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id) VALUES (?, ?, ?, ?, ?, ?, NULL)`,
[
Expand All @@ -169,7 +184,7 @@ export function updateWorkflow(
],
);
}
for (const edge of graphDefinition.edges) {
for (const edge of uniqueEdges) {
db.run(
`INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key) VALUES (?, ?, ?, ?, ?, ?)`,
[
Expand All @@ -183,7 +198,7 @@ export function updateWorkflow(
);
}
// Restore currentOutputId where the execution record still exists
for (const node of graphDefinition.nodes) {
for (const node of uniqueNodes) {
const outputId =
node.currentOutputId ?? existingOutputIds.get(node.id) ?? null;
if (outputId) {
Expand Down
Loading
Loading