From 9757b6975cfa9a94f75b3f86e89b0cc366bc14e1 Mon Sep 17 00:00:00 2001 From: Huakun Shen Date: Wed, 26 Mar 2025 11:26:35 -0400 Subject: [PATCH] reimplemented most db command functions with ORM (migrate from tauri command invoke --- apps/desktop/package.json | 2 +- apps/desktop/src/lib/cmds/builtin.ts | 17 + apps/desktop/src/lib/orm/cmds.ts | 343 ++++++++++++++++++ apps/desktop/src/lib/orm/database.ts | 18 +- apps/desktop/src/routes/app/+page.svelte | 3 + .../app/troubleshooters/orm/+page.svelte | 123 +++++++ .../routes/app/troubleshooters/sidebar.svelte | 6 + packages/api/src/models/extension.ts | 12 +- packages/drizzle/README.md | 22 ++ packages/drizzle/drizzle/schema.ts | 9 +- packages/ui/package.json | 2 +- pnpm-lock.yaml | 14 +- 12 files changed, 548 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/lib/orm/cmds.ts create mode 100644 apps/desktop/src/routes/app/troubleshooters/orm/+page.svelte diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 61486a4..47b9cc4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,7 +39,7 @@ "lz-string": "^1.5.0", "pretty-bytes": "^6.1.1", "semver": "^7.7.1", - "svelte-inspect-value": "^0.3.0", + "svelte-inspect-value": "^0.5.0", "svelte-sonner": "^0.3.28", "sveltekit-superforms": "^2.23.1", "tauri-plugin-clipboard-api": "^2.1.11", diff --git a/apps/desktop/src/lib/cmds/builtin.ts b/apps/desktop/src/lib/cmds/builtin.ts index 0a9a992..cb48dfb 100644 --- a/apps/desktop/src/lib/cmds/builtin.ts +++ b/apps/desktop/src/lib/cmds/builtin.ts @@ -242,6 +242,23 @@ export const rawBuiltinCmds: BuiltinCmd[] = [ }, keywords: ["extension", "troubleshooter"] }, + { + name: "ORM Troubleshooter", + icon: { + type: IconEnum.Iconify, + value: "material-symbols:database" + }, + description: "", + flags: { + developer: true, + dev: true + }, + function: async () => { + appState.clearSearchTerm() + goto(i18n.resolveRoute("/app/troubleshooters/orm")) + }, + keywords: ["extension", "troubleshooter", "database", "orm"] + }, { name: "Create Quicklink", icon: { diff --git a/apps/desktop/src/lib/orm/cmds.ts b/apps/desktop/src/lib/orm/cmds.ts new file mode 100644 index 0000000..944ac18 --- /dev/null +++ b/apps/desktop/src/lib/orm/cmds.ts @@ -0,0 +1,343 @@ +import * as relations from "@kksh/drizzle/relations" +import * as schema from "@kksh/drizzle/schema" +import { CmdType, Ext, ExtCmd, ExtData, SearchMode, SearchModeEnum, SQLSortOrder, SQLSortOrderEnum } from "@kunkunapi/src/models" +import * as orm from "drizzle-orm" +import type { SelectedFields } from "drizzle-orm/sqlite-core" +import * as v from "valibot" +import { db } from "./database" + +/* -------------------------------------------------------------------------- */ +/* Built-in Extensions */ +/* -------------------------------------------------------------------------- */ + +/* -------------------------------------------------------------------------- */ +/* Extension CRUD */ +/* -------------------------------------------------------------------------- */ +export async function getUniqueExtensionByIdentifier(identifier: string): Promise { + const ext = await db + .select() + .from(schema.extensions) + .where(orm.eq(schema.extensions.identifier, identifier)) + .get() + return v.parse(v.optional(Ext), ext) +} + +/** + * Use this function when you expect the extension to exist. Such as builtin extensions. + * @param identifier + * @returns + */ +export function getExtensionByIdentifierExpectExists(identifier: string): Promise { + return getUniqueExtensionByIdentifier(identifier).then((ext) => { + if (!ext) { + throw new Error(`Unexpexted Error: Extension ${identifier} not found`) + } + return ext + }) +} + +export async function getAllExtensions(): Promise { + const exts = await db.select().from(schema.extensions).all() + return v.parse(v.array(Ext), exts) +} + +/** + * There can be duplicate extensions with the same identifier. Store and Dev extensions can have the same identifier. + * But install path must be unique. + * @param path + */ +export async function getUniqueExtensionByPath(path: string) { + const ext = await db + .select() + .from(schema.extensions) + .where(orm.eq(schema.extensions.path, path)) + .get() + return v.parse(Ext, ext) +} + +export function getAllExtensionsByIdentifier(identifier: string): Promise { + return db + .select() + .from(schema.extensions) + .where(orm.eq(schema.extensions.identifier, identifier)) + .all() + .then((exts) => v.parse(v.array(Ext), exts)) +} + +export function deleteExtensionByPath(path: string): Promise { + return db + .delete(schema.extensions) + .where(orm.eq(schema.extensions.path, path)) + .run() + .then(() => undefined) +} + +export function deleteExtensionByExtId(extId: number): Promise { + return db + .delete(schema.extensions) + .where(orm.eq(schema.extensions.extId, extId)) + .run() + .then(() => undefined) +} + +/* -------------------------------------------------------------------------- */ +/* Extension Command CRUD */ +/* -------------------------------------------------------------------------- */ + +// export async function getExtensionWithCmdsByIdentifier(identifier: string): Promise { +// const ext = await db +// .select({ +// ...schema.extensions, +// commands: relations.commandsRelations +// }) +// .from(schema.extensions) +// .leftJoin(schema.commands, orm.eq(schema.extensions.extId, schema.commands.extId)) +// .where(orm.eq(schema.extensions.identifier, identifier)) +// .get() + +// // return v.parse(v.nullable(ExtWithCmds), ext); +// } + +export async function getCmdById(cmdId: number): Promise { + const cmd = await db + .select() + .from(schema.commands) + .where(orm.eq(schema.commands.cmdId, cmdId)) + .get() + return v.parse(ExtCmd, cmd) +} + +export async function getAllCmds(): Promise { + const cmds = await db.select().from(schema.commands).all() + return v.parse(v.array(ExtCmd), cmds) +} + +export function getCommandsByExtId(extId: number) { + return db + .select() + .from(schema.commands) + .where(orm.eq(schema.commands.extId, extId)) + .all() + .then((cmds) => v.parse(v.array(ExtCmd), cmds)) +} + +export function deleteCmdById(cmdId: number) { + return db + .delete(schema.commands) + .where(orm.eq(schema.commands.cmdId, cmdId)) + .run() + .then(() => undefined) +} + +export function updateCmdByID(data: { + cmdId: number + name: string + cmdType: CmdType + data: string + alias?: string + hotkey?: string + enabled: boolean +}) { + return db + .update(schema.commands) + .set({ + name: data.name, + type: data.cmdType, + data: data.data, + alias: data.alias, // optional + hotkey: data.hotkey, // optional + enabled: data.enabled + // in drizzle schema, use integer({ mode: 'boolean' }) for boolean sqlite + // enabled: data.enabled ? String(data.enabled) : undefined + }) + .where(orm.eq(schema.commands.cmdId, data.cmdId)) + .run() + .then(() => undefined) +} + +/* -------------------------------------------------------------------------- */ +/* Extension Data CRUD */ +/* -------------------------------------------------------------------------- */ +export const ExtDataField = v.union([v.literal("data"), v.literal("search_text")]) +export type ExtDataField = v.InferOutput + +function convertRawExtDataToExtData(rawData?: { + createdAt: string + updatedAt: string + data: null | string + searchText?: null | string + dataId: number + extId: number + dataType: string +}): ExtData | undefined { + if (!rawData) { + return rawData + } + const parsedRes = v.safeParse(ExtData, { + ...rawData, + createdAt: new Date(rawData.createdAt), + updatedAt: new Date(rawData.updatedAt), + data: rawData.data ?? undefined, + searchText: rawData.searchText ?? undefined + }) + if (parsedRes.success) { + return parsedRes.output + } else { + console.error("Extension Data Parse Failure", parsedRes.issues) + throw new Error("Fail to parse extension data") + } +} + +export function createExtensionData(data: { + extId: number + dataType: string + data: string + searchText?: string +}) { + return db.insert(schema.extensionData).values(data).run() +} + +export function getExtensionDataById(dataId: number, fields?: ExtDataField[]) { + const _fields = fields ?? [] + const selectQuery: SelectedFields = { + dataId: schema.extensionData.dataId, + extId: schema.extensionData.extId, + dataType: schema.extensionData.dataType, + metadata: schema.extensionData.metadata, + createdAt: schema.extensionData.createdAt, + updatedAt: schema.extensionData.updatedAt + // data: schema.extensionData.data, + // searchText: schema.extensionData.searchText + } + if (_fields.includes("data")) { + selectQuery["data"] = schema.extensionData.data + } + if (_fields.includes("search_text")) { + selectQuery["searchText"] = schema.extensionData.searchText + } + return db + .select(selectQuery) + .from(schema.extensionData) + .where(orm.eq(schema.extensionData.dataId, dataId)) + .get() + .then((rawData) => { + console.log("Raw Data", rawData) + // @ts-expect-error - rawData is unknown, but will be safe parsed with valibot + return convertRawExtDataToExtData(rawData) + }) +} + +export async function searchExtensionData(searchParams: { + extId: number + searchMode: SearchMode + dataId?: number + dataType?: string + searchText?: string + afterCreatedAt?: string + beforeCreatedAt?: string + limit?: number + offset?: number + orderByCreatedAt?: SQLSortOrder + orderByUpdatedAt?: SQLSortOrder + fields?: ExtDataField[] +}): Promise { + const fields = v.parse(v.optional(v.array(ExtDataField), []), searchParams.fields) + const _fields = fields ?? [] + + // Build the select query based on fields + const selectQuery: SelectedFields = { + dataId: schema.extensionData.dataId, + extId: schema.extensionData.extId, + dataType: schema.extensionData.dataType, + createdAt: schema.extensionData.createdAt, + updatedAt: schema.extensionData.updatedAt + } + + if (_fields.includes("data")) { + selectQuery["data"] = schema.extensionData.data + } + if (_fields.includes("search_text")) { + selectQuery["searchText"] = schema.extensionData.searchText + } + + // Build the query + const query = db.select(selectQuery).from(schema.extensionData) + + // Add conditions + const conditions = [orm.eq(schema.extensionData.extId, searchParams.extId)] + + if (searchParams.dataId) { + conditions.push(orm.eq(schema.extensionData.dataId, searchParams.dataId)) + } + + if (searchParams.dataType) { + conditions.push(orm.eq(schema.extensionData.dataType, searchParams.dataType)) + } + + if (searchParams.searchText) { + switch (searchParams.searchMode) { + case SearchModeEnum.ExactMatch: + conditions.push(orm.eq(schema.extensionData.searchText, searchParams.searchText)) + break + case SearchModeEnum.Like: + conditions.push(orm.like(schema.extensionData.searchText, `%${searchParams.searchText}%`)) + break + case SearchModeEnum.FTS: + // For FTS, we need to use a raw SQL query since Drizzle doesn't support MATCH directly + conditions.push(orm.sql`${schema.extensionDataFts.searchText} MATCH ${searchParams.searchText}`) + break + } + } + + if (searchParams.afterCreatedAt) { + conditions.push(orm.gt(schema.extensionData.createdAt, searchParams.afterCreatedAt)) + } + + if (searchParams.beforeCreatedAt) { + conditions.push(orm.lt(schema.extensionData.createdAt, searchParams.beforeCreatedAt)) + } + + // Add ordering + if (searchParams.orderByCreatedAt) { + query.orderBy( + searchParams.orderByCreatedAt === SQLSortOrderEnum.Asc + ? orm.asc(schema.extensionData.createdAt) + : orm.desc(schema.extensionData.createdAt) + ) + } + + if (searchParams.orderByUpdatedAt) { + query.orderBy( + searchParams.orderByUpdatedAt === SQLSortOrderEnum.Asc + ? orm.asc(schema.extensionData.updatedAt) + : orm.desc(schema.extensionData.updatedAt) + ) + } + + // Add limit and offset + if (searchParams.limit) { + query.limit(searchParams.limit) + } + + if (searchParams.offset) { + query.offset(searchParams.offset) + } + + // Execute query and convert results + const results = await query.where(orm.and(...conditions)).all() + return results.map((rawData) => { + // @ts-expect-error - rawData is unknown, but will be safe parsed with valibot + return convertRawExtDataToExtData(rawData) + }).filter((item): item is ExtData => item !== undefined) +} + +// export async function getNCommands(n: number): +// export function createExtension(ext: { +// identifier: string +// version: string +// enabled?: boolean +// path?: string +// data?: any +// }) { +// return invoke(generateJarvisPluginCommand("create_extension"), ext) +// } diff --git a/apps/desktop/src/lib/orm/database.ts b/apps/desktop/src/lib/orm/database.ts index 2a08cd6..e405b7c 100644 --- a/apps/desktop/src/lib/orm/database.ts +++ b/apps/desktop/src/lib/orm/database.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { db as dbCmd } from "@kksh/api/commands" import * as schema from "@kksh/drizzle/schema" -import * as dbCmd from "@kunkunapi/src/commands/db" +import { error } from "@tauri-apps/plugin-log" import { drizzle } from "drizzle-orm/sqlite-proxy" /** @@ -15,21 +16,22 @@ export const db = drizzle( async (sql, params, method) => { let rows: any = [] let results = [] - // console.log({ - // sql, - // params, - // method - // }) + console.log({ + sql, + params, + method + }) + console.log(sql) // If the query is a SELECT, use the select method if (isSelectQuery(sql)) { rows = await dbCmd.select(sql, params).catch((e) => { - console.error("SQL Error:", e) + error("SQL Error:", e) return [] }) } else { // Otherwise, use the execute method rows = await dbCmd.execute(sql, params).catch((e) => { - console.error("SQL Error:", e) + error("SQL Error:", e) return [] }) return { rows: [] } diff --git a/apps/desktop/src/routes/app/+page.svelte b/apps/desktop/src/routes/app/+page.svelte index 0bc9b6e..77551ab 100644 --- a/apps/desktop/src/routes/app/+page.svelte +++ b/apps/desktop/src/routes/app/+page.svelte @@ -5,6 +5,7 @@ import { systemCommands, systemCommandsFiltered } from "@/cmds/system" import AppsCmds from "@/components/main/AppsCmds.svelte" import { i18n } from "@/i18n" + import { getUniqueExtensionByIdentifier } from "@/orm/cmds" import { db } from "@/orm/database" import * as m from "@/paraglide/messages" import { @@ -32,6 +33,7 @@ SystemCmds } from "@kksh/ui/main" import { cn } from "@kksh/ui/utils" + import { Ext } from "@kunkunapi/src/models/extension" import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" import { getCurrentWindow, Window } from "@tauri-apps/api/window" import { platform } from "@tauri-apps/plugin-os" @@ -46,6 +48,7 @@ } from "lucide-svelte" import { onMount } from "svelte" import { Inspect } from "svelte-inspect-value" + import * as v from "valibot" const win = getCurrentWindow() let inputEle: HTMLInputElement | null = $state(null) diff --git a/apps/desktop/src/routes/app/troubleshooters/orm/+page.svelte b/apps/desktop/src/routes/app/troubleshooters/orm/+page.svelte new file mode 100644 index 0000000..36e78c4 --- /dev/null +++ b/apps/desktop/src/routes/app/troubleshooters/orm/+page.svelte @@ -0,0 +1,123 @@ + + +
+ + + + + + +
+ + +
+ +
diff --git a/apps/desktop/src/routes/app/troubleshooters/sidebar.svelte b/apps/desktop/src/routes/app/troubleshooters/sidebar.svelte index ce429e0..ab276f8 100644 --- a/apps/desktop/src/routes/app/troubleshooters/sidebar.svelte +++ b/apps/desktop/src/routes/app/troubleshooters/sidebar.svelte @@ -6,6 +6,7 @@ import { Constants } from "@kksh/ui" import { ArrowLeftIcon } from "lucide-svelte" import AppWindow from "lucide-svelte/icons/app-window" + import DB from "lucide-svelte/icons/database" import Loader from "lucide-svelte/icons/loader" import Network from "lucide-svelte/icons/network" @@ -25,6 +26,11 @@ title: m.troubleshooters_sidebar_mdns_debugger_title(), url: i18n.resolveRoute("/app/troubleshooters/mdns-debugger"), icon: Network + }, + { + title: "ORM", + url: i18n.resolveRoute("/app/troubleshooters/orm"), + icon: DB } ] let currentItem = $state(items.find((item) => window.location.pathname === item.url)) diff --git a/packages/api/src/models/extension.ts b/packages/api/src/models/extension.ts index 0b8a7d9..e2f1baa 100644 --- a/packages/api/src/models/extension.ts +++ b/packages/api/src/models/extension.ts @@ -18,8 +18,11 @@ export const Ext = v.object({ extId: v.number(), identifier: v.string(), version: v.string(), - enabled: v.boolean(), - installed_at: v.string(), + enabled: v.pipe( + v.number(), + v.transform((input) => Boolean(input)) + ), + installedAt: v.string(), path: v.optional(v.nullable(v.string())), data: v.optional(v.any()) }) @@ -46,7 +49,10 @@ export const ExtCmd = v.object({ data: v.string(), alias: v.optional(v.nullable(v.string())), hotkey: v.optional(v.nullable(v.string())), - enabled: v.boolean() + enabled: v.pipe( + v.number(), + v.transform((input) => Boolean(input)) + ) }) export type ExtCmd = v.InferOutput diff --git a/packages/drizzle/README.md b/packages/drizzle/README.md index c6e495e..9ce0c45 100644 --- a/packages/drizzle/README.md +++ b/packages/drizzle/README.md @@ -11,3 +11,25 @@ bunx drizzle-kit pull We are using sqlite with fts5, which drizzle doesn't support yet, so pushing the schema will destroy the existing schema. We only use pulled schema to generate sql queries. + +## Update Schema + +After `drizzle-kit pull` the schema may have problem with JSON type and boolean type. + +Will need to manually update the following + +### JSON + +```diff lang="ts" ++ data: text({ mode: "json" }).notNull(), ++ metadata: text({ mode: "json" }), +- data: numeric().notNull(), +- metadata: numeric(), +``` + +### Boolean + +```diff lang="ts" ++ enabled: integer({ mode: "boolean" }), +- enabled: numeric().default(sql`(TRUE)`), +``` diff --git a/packages/drizzle/drizzle/schema.ts b/packages/drizzle/drizzle/schema.ts index 73fbf85..c671e24 100644 --- a/packages/drizzle/drizzle/schema.ts +++ b/packages/drizzle/drizzle/schema.ts @@ -29,7 +29,8 @@ export const commands = sqliteTable("commands", { .notNull() .references(() => extensions.extId, { onDelete: "cascade" }), name: text().notNull(), - enabled: numeric().default(sql`(TRUE)`), + enabled: integer({ mode: "boolean" }), + // enabled: numeric().default(sql`(TRUE)`), alias: text(), hotkey: text(), type: text().notNull(), @@ -42,8 +43,10 @@ export const extensionData = sqliteTable("extension_data", { .notNull() .references(() => extensions.extId, { onDelete: "cascade" }), dataType: text("data_type").notNull(), - data: numeric().notNull(), - metadata: numeric(), + // data: text({ mode: "json" }).notNull(), + // metadata: text({ mode: "json" }), + data: text("data").notNull(), + metadata: text("metadata"), searchText: text("search_text"), createdAt: numeric("created_at").default(sql`(CURRENT_TIMESTAMP)`), updatedAt: numeric("updated_at").default(sql`(CURRENT_TIMESTAMP)`) diff --git a/packages/ui/package.json b/packages/ui/package.json index 6219ec3..483da1d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -96,7 +96,7 @@ "remark-math": "^6.0.0", "shiki-magic-move": "^0.5.2", "svelte-exmarkdown": "^4.0.3", - "svelte-inspect-value": "^0.3.0", + "svelte-inspect-value": "^0.5.0", "svelte-motion": "^0.12.2", "valibot": "^1.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b585de..d1cea59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,8 +279,8 @@ importers: specifier: ^7.7.1 version: 7.7.1 svelte-inspect-value: - specifier: ^0.3.0 - version: 0.3.0(svelte@5.20.5) + specifier: ^0.5.0 + version: 0.5.0(svelte@5.20.5) svelte-sonner: specifier: ^0.3.28 version: 0.3.28(svelte@5.20.5) @@ -1290,8 +1290,8 @@ importers: specifier: ^4.0.3 version: 4.0.3(svelte@5.20.5) svelte-inspect-value: - specifier: ^0.3.0 - version: 0.3.0(svelte@5.20.5) + specifier: ^0.5.0 + version: 0.5.0(svelte@5.20.5) svelte-motion: specifier: ^0.12.2 version: 0.12.2(svelte@5.20.5) @@ -11715,8 +11715,8 @@ packages: peerDependencies: svelte: ^5.1.3 - svelte-inspect-value@0.3.0: - resolution: {integrity: sha512-nHv+7+FRePs86sgL2I8jlbSrs8/uJmHJ2uxnMk9tVipWdZYYcmGhsmU+7U8lm/1RAZFS63/xSKdceMDyE09y0A==} + svelte-inspect-value@0.5.0: + resolution: {integrity: sha512-ZWbu/TZl/gGAPe8Xjmg0YvERSpEC+q07HV8m0xhp51auTNh8mjaf07bcmcl0coBb0wnJqcAB4uWJ1GDdtGQrQw==} peerDependencies: svelte: ^5.19.0 @@ -25530,7 +25530,7 @@ snapshots: transitivePeerDependencies: - supports-color - svelte-inspect-value@0.3.0(svelte@5.20.5): + svelte-inspect-value@0.5.0(svelte@5.20.5): dependencies: esm-env: 1.2.2 fast-deep-equal: 3.1.3