diff --git a/Cargo.lock b/Cargo.lock index 50a70c5414..f67ec91c8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4736,7 +4736,6 @@ dependencies = [ "tauri-plugin-bedrock", "tauri-plugin-calendar", "tauri-plugin-clipboard-manager", - "tauri-plugin-db2", "tauri-plugin-deep-link", "tauri-plugin-deeplink2", "tauri-plugin-detect", @@ -4769,6 +4768,7 @@ dependencies = [ "tauri-plugin-permissions", "tauri-plugin-prevent-default", "tauri-plugin-process", + "tauri-plugin-reactive-db", "tauri-plugin-relay", "tauri-plugin-screen", "tauri-plugin-sentry", @@ -11985,15 +11985,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -13313,37 +13304,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgres-protocol" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac", - "md-5", - "memchr", - "rand 0.9.2", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "postgres-protocol", - "serde_core", - "serde_json", -] - [[package]] name = "posthog-rs" version = "0.4.3" @@ -16631,7 +16591,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -16668,7 +16628,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -17952,28 +17912,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "tauri-plugin-db2" -version = "0.1.0" -dependencies = [ - "db-core", - "dirs 6.0.0", - "futures-util", - "serde", - "serde_json", - "specta", - "specta-typescript", - "strum 0.27.2", - "tauri", - "tauri-plugin", - "tauri-plugin-settings", - "tauri-specta", - "thiserror 2.0.18", - "tokio", - "tokio-postgres", - "tracing", -] - [[package]] name = "tauri-plugin-deep-link" version = "2.4.7" @@ -18483,7 +18421,6 @@ dependencies = [ "apalis", "apalis-cron", "chrono", - "db-user", "detect", "host", "intercept", @@ -18661,6 +18598,25 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-reactive-db" +version = "0.1.0" +dependencies = [ + "libsqlite3-sys", + "serde", + "serde_json", + "specta", + "specta-typescript", + "sqlx", + "tauri", + "tauri-plugin", + "tauri-specta", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tauri-plugin-relay" version = "0.1.0" @@ -19674,32 +19630,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.9.2", - "socket2 0.6.3", - "tokio", - "tokio-util", - "whoami 2.1.1", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -21697,15 +21627,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -21730,15 +21651,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -22295,20 +22207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite 0.1.0", -] - -[[package]] -name = "whoami" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite 1.0.2", - "web-sys", + "wasite", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4175751147..8c5f8e6acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -195,7 +195,6 @@ tauri-plugin-audio-priority = { path = "plugins/audio-priority" } tauri-plugin-auth = { path = "plugins/auth" } tauri-plugin-bedrock = { path = "plugins/bedrock" } tauri-plugin-calendar = { path = "plugins/calendar" } -tauri-plugin-db2 = { path = "plugins/db2" } tauri-plugin-deeplink2 = { path = "plugins/deeplink2" } tauri-plugin-detect = { path = "plugins/detect" } tauri-plugin-dock = { path = "plugins/dock" } @@ -234,6 +233,7 @@ tauri-plugin-tracing = { path = "plugins/tracing" } tauri-plugin-tray = { path = "plugins/tray" } tauri-plugin-updater2 = { path = "plugins/updater2" } tauri-plugin-webhook = { path = "plugins/webhook" } +tauri-plugin-reactive-db = { path = "plugins/reactive-db" } tauri-plugin-windows = { path = "plugins/windows" } async-stream = "0.3.6" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 68bed1c666..57a96616d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,7 +41,6 @@ "@hypr/plugin-auth": "workspace:*", "@hypr/plugin-bedrock": "workspace:*", "@hypr/plugin-calendar": "workspace:*", - "@hypr/plugin-db2": "workspace:*", "@hypr/plugin-deeplink2": "workspace:*", "@hypr/plugin-detect": "workspace:*", "@hypr/plugin-export": "workspace:*", @@ -64,6 +63,7 @@ "@hypr/plugin-notify": "workspace:*", "@hypr/plugin-opener2": "workspace:*", "@hypr/plugin-overlay": "workspace:*", + "@hypr/plugin-reactive-db": "workspace:*", "@hypr/plugin-path2": "workspace:*", "@hypr/plugin-permissions": "workspace:*", "@hypr/plugin-relay": "workspace:*", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6a57e02837..d8fa09c61e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,7 +29,6 @@ tauri-plugin-autostart = { workspace = true } tauri-plugin-bedrock = { workspace = true } tauri-plugin-calendar = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } -tauri-plugin-db2 = { workspace = true } tauri-plugin-deep-link = { workspace = true } tauri-plugin-deeplink2 = { workspace = true } tauri-plugin-detect = { workspace = true } @@ -58,6 +57,7 @@ tauri-plugin-opener = { workspace = true } tauri-plugin-opener2 = { workspace = true } tauri-plugin-os = { workspace = true } tauri-plugin-overlay = { workspace = true } +tauri-plugin-reactive-db = { workspace = true } tauri-plugin-path2 = { workspace = true } tauri-plugin-permissions = { workspace = true } tauri-plugin-prevent-default = { workspace = true } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 566c4ed595..cad0e59f71 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -23,7 +23,6 @@ "calendar:default", "audio-priority:default", "auth:default", - "db2:default", "windows:default", "tracing:default", "tray:default", @@ -84,6 +83,7 @@ "autostart:default", "js:default", "flag:default", + "reactive-db:default", "relay:default", { "identifier": "http:default", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ea795a0d13..472e23cce2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -101,7 +101,6 @@ pub async fn main() { .plugin(tauri_plugin_importer::init()) .plugin(tauri_plugin_calendar::init()) .plugin(tauri_plugin_auth::init()) - .plugin(tauri_plugin_db2::init()) .plugin(tauri_plugin_tracing::init()) .plugin(tauri_plugin_hooks::init()) .plugin(tauri_plugin_icon::init()) @@ -158,7 +157,8 @@ pub async fn main() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--background"]), )) - .plugin(tauri_plugin_updater2::init()); + .plugin(tauri_plugin_updater2::init()) + .plugin(tauri_plugin_reactive_db::init()); if let Some(client) = sentry_client.as_ref() { builder = builder.plugin(tauri_plugin_sentry::init_with_no_injection(client)); @@ -184,7 +184,6 @@ pub async fn main() { .on_window_event(tauri_plugin_windows::on_window_event) .setup(move |app| { let app_handle = app.handle().clone(); - let app_clone = app_handle.clone(); specta_builder.mount_events(&app_handle); @@ -219,20 +218,20 @@ pub async fn main() { } } - tokio::spawn(async move { - use tauri_plugin_db2::Database2PluginExt; - - if let Err(e) = app_clone.db2().init_local().await { - tracing::error!("failed_to_init_local: {}", e); - } - }); - if let (Some(ctx), Some(handle)) = (&root_supervisor_ctx, root_supervisor_handle) { supervisor::monitor_supervisor(handle, ctx.is_exiting.clone(), app_handle.clone()); } // control::setup(&app_handle); + { + use tauri_plugin_reactive_db::ReactiveDbExt; + let handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + handle.reactive_db().init_memory().await.unwrap(); + }); + } + Ok(()) }) .build(tauri::generate_context!()) diff --git a/apps/desktop/src/sample/index.tsx b/apps/desktop/src/sample/index.tsx new file mode 100644 index 0000000000..8fd2276528 --- /dev/null +++ b/apps/desktop/src/sample/index.tsx @@ -0,0 +1,144 @@ +import { DatabaseIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { execute } from "@hypr/plugin-reactive-db"; +import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/utils"; + +import { useLiveQuery } from "./use-live-query"; + +import { StandardTabWrapper } from "~/shared/main"; +import { TabItemBase, type TabItem } from "~/shared/tabs"; +import type { Tab } from "~/store/zustand/tabs"; + +type SampleTab = Extract; + +export const TabItemSample: TabItem = ({ + tab, + tabIndex, + handleCloseThis, + handleSelectThis, + handleCloseOthers, + handleCloseAll, + handlePinThis, + handleUnpinThis, +}) => ( + } + title="Sample" + selected={tab.active} + pinned={tab.pinned} + tabIndex={tabIndex} + handleCloseThis={() => handleCloseThis(tab)} + handleSelectThis={() => handleSelectThis(tab)} + handleCloseOthers={handleCloseOthers} + handleCloseAll={handleCloseAll} + handlePinThis={() => handlePinThis(tab)} + handleUnpinThis={() => handleUnpinThis(tab)} + /> +); + +export function TabContentSample({ tab }: { tab: SampleTab }) { + return ( + + + + ); +} + +interface Session { + id: string; + title: string; + created_at: string; +} + +function SampleView() { + const [title, setTitle] = useState(""); + const [ready, setReady] = useState(false); + + useEffect(() => { + execute( + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + ).then(() => setReady(true)); + }, []); + + const { data: sessions, isLoading } = useLiveQuery( + "SELECT id, title, created_at FROM sessions ORDER BY created_at DESC", + [], + { enabled: ready }, + ); + + const { data: countResult } = useLiveQuery<{ count: number }>( + "SELECT COUNT(*) as count FROM sessions", + [], + { enabled: ready }, + ); + + const count = countResult?.[0]?.count ?? 0; + + const handleCreate = useCallback(async () => { + const trimmed = title.trim(); + if (!trimmed) return; + + await execute( + "INSERT INTO sessions (id, title, created_at) VALUES (?, ?, datetime('now'))", + [crypto.randomUUID(), trimmed], + ); + setTitle(""); + }, [title]); + + return ( +
+
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + placeholder="New session title..." + className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm outline-none focus:border-blue-400" + /> + + + {count} session{count !== 1 ? "s" : ""} + +
+ +
+ {isLoading ? ( +
Loading...
+ ) : sessions?.length === 0 ? ( +
+ No sessions yet. Create one above. +
+ ) : ( +
    + {sessions?.map((session) => ( +
  • + {session.title} + + {session.created_at} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/sample/use-live-query.ts b/apps/desktop/src/sample/use-live-query.ts new file mode 100644 index 0000000000..910857fa35 --- /dev/null +++ b/apps/desktop/src/sample/use-live-query.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; + +import { subscribe } from "@hypr/plugin-reactive-db"; + +export interface LiveQueryResult { + data: T[] | undefined; + error: string | undefined; + isLoading: boolean; +} + +export function useLiveQuery>( + sql: string, + params: unknown[] = [], + options?: { enabled?: boolean }, +): LiveQueryResult { + const [data, setData] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + const paramsKey = JSON.stringify(params); + + useEffect(() => { + if (options?.enabled === false) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(undefined); + + let cancelled = false; + let cleanup: (() => void) | undefined; + + subscribe(sql, params, { + onData: (rows) => { + if (cancelled) return; + setData(rows); + setError(undefined); + setIsLoading(false); + }, + onError: (msg) => { + if (cancelled) return; + setError(msg); + setIsLoading(false); + }, + }).then((unsub) => { + if (cancelled) { + unsub(); + } else { + cleanup = unsub; + } + }); + + return () => { + cancelled = true; + cleanup?.(); + }; + }, [sql, paramsKey, options?.enabled]); + + return { data, error, isLoading }; +} diff --git a/apps/desktop/src/shared/main/empty/index.tsx b/apps/desktop/src/shared/main/empty/index.tsx index d6e39ff5f0..a62a0dd698 100644 --- a/apps/desktop/src/shared/main/empty/index.tsx +++ b/apps/desktop/src/shared/main/empty/index.tsx @@ -77,6 +77,10 @@ function EmptyView() { () => openCurrent({ type: "search" }), [openCurrent], ); + const openSample = useCallback( + () => openCurrent({ type: "sample" }), + [openCurrent], + ); useHotkeys( "mod+o", @@ -110,6 +114,7 @@ function EmptyView() { shortcut={["⌘", "⇧", "F"]} onClick={openAdvancedSearch} /> +
); } + if (tab.type === "sample") { + return ( + + ); + } return null; } @@ -643,6 +658,9 @@ function ContentWrapper({ tab }: { tab: Tab }) { if (tab.type === "edit") { return ; } + if (tab.type === "sample") { + return ; + } return null; } diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index 2e72169153..3a8e5f72eb 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -119,7 +119,8 @@ export type Tab = }) | (BaseTab & { type: "onboarding" }) | (BaseTab & { type: "daily" }) - | (BaseTab & { type: "edit"; requestId: string }); + | (BaseTab & { type: "edit"; requestId: string }) + | (BaseTab & { type: "sample" }); export const getDefaultState = (tab: TabInput): Tab => { const base = { active: false, slotId: "", pinned: false }; @@ -227,6 +228,8 @@ export const getDefaultState = (tab: TabInput): Tab => { return { ...base, type: "daily" }; case "edit": return { ...base, type: "edit", requestId: tab.requestId }; + case "sample": + return { ...base, type: "sample" }; default: const _exhaustive: never = tab; return _exhaustive; @@ -275,6 +278,8 @@ export const uniqueIdfromTab = (tab: Tab): string => { return `daily`; case "edit": return `edit-${tab.requestId}`; + case "sample": + return "sample"; } }; diff --git a/plugins/db2/.gitignore b/plugins/db2/.gitignore deleted file mode 100644 index 50d8e32e89..0000000000 --- a/plugins/db2/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -/.vs -.DS_Store -.Thumbs.db -*.sublime* -.idea/ -debug.log -package-lock.json -.vscode/settings.json -yarn.lock - -/.tauri -/target -Cargo.lock -node_modules/ - -dist-js -dist diff --git a/plugins/db2/Cargo.toml b/plugins/db2/Cargo.toml deleted file mode 100644 index a6c6aa6abb..0000000000 --- a/plugins/db2/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "tauri-plugin-db2" -version = "0.1.0" -authors = ["You"] -edition = "2024" -exclude = ["/js", "/node_modules"] -links = "tauri-plugin-db2" -description = "" - -[build-dependencies] -tauri-plugin = { workspace = true, features = ["build"] } - -[dev-dependencies] -specta-typescript = { workspace = true } - -[dependencies] -tauri = { workspace = true, features = ["test"] } -tauri-specta = { workspace = true, features = ["derive", "typescript"] } - -tauri-plugin-settings = { workspace = true } - -hypr-db-core = { workspace = true, features = ["encryption"] } -tokio-postgres = { version = "0.7.14", features = ["with-serde_json-1"] } - -futures-util = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tracing = { workspace = true } - -dirs = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -specta = { workspace = true, features = ["serde_json"] } -strum = { workspace = true, features = ["derive"] } -thiserror = { workspace = true } diff --git a/plugins/db2/build.rs b/plugins/db2/build.rs deleted file mode 100644 index a5213108e4..0000000000 --- a/plugins/db2/build.rs +++ /dev/null @@ -1,5 +0,0 @@ -const COMMANDS: &[&str] = &["execute_local", "execute_cloud"]; - -fn main() { - tauri_plugin::Builder::new(COMMANDS).build(); -} diff --git a/plugins/db2/js/bindings.gen.ts b/plugins/db2/js/bindings.gen.ts deleted file mode 100644 index 12aa486ea6..0000000000 --- a/plugins/db2/js/bindings.gen.ts +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-nocheck - -// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. - -/** user-defined commands **/ - - -export const commands = { -async executeLocal(sql: string, args: string[]) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("plugin:db2|execute_local", { sql, args }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async executeCloud(sql: string, args: string[]) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("plugin:db2|execute_cloud", { sql, args }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -} -} - -/** user-defined events **/ - - - -/** user-defined constants **/ - - - -/** user-defined types **/ - -export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> - -/** tauri-specta globals **/ - -import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, -} from "@tauri-apps/api/core"; -import * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; - -type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; -}; - -export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; - -function __makeEvents__>( - mappings: Record, -) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; - - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); -} diff --git a/plugins/db2/js/index.ts b/plugins/db2/js/index.ts deleted file mode 100644 index a96e122f03..0000000000 --- a/plugins/db2/js/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./bindings.gen"; diff --git a/plugins/db2/package.json b/plugins/db2/package.json deleted file mode 100644 index 1e24ced97c..0000000000 --- a/plugins/db2/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@hypr/plugin-db2", - "private": true, - "main": "./js/index.ts", - "scripts": { - "codegen": "cargo test -p tauri-plugin-db2" - }, - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } -} diff --git a/plugins/db2/permissions/autogenerated/commands/execute_cloud.toml b/plugins/db2/permissions/autogenerated/commands/execute_cloud.toml deleted file mode 100644 index c67a93fad5..0000000000 --- a/plugins/db2/permissions/autogenerated/commands/execute_cloud.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-execute-cloud" -description = "Enables the execute_cloud command without any pre-configured scope." -commands.allow = ["execute_cloud"] - -[[permission]] -identifier = "deny-execute-cloud" -description = "Denies the execute_cloud command without any pre-configured scope." -commands.deny = ["execute_cloud"] diff --git a/plugins/db2/permissions/autogenerated/commands/execute_local.toml b/plugins/db2/permissions/autogenerated/commands/execute_local.toml deleted file mode 100644 index 8ecb1e73fc..0000000000 --- a/plugins/db2/permissions/autogenerated/commands/execute_local.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-execute-local" -description = "Enables the execute_local command without any pre-configured scope." -commands.allow = ["execute_local"] - -[[permission]] -identifier = "deny-execute-local" -description = "Denies the execute_local command without any pre-configured scope." -commands.deny = ["execute_local"] diff --git a/plugins/db2/permissions/autogenerated/reference.md b/plugins/db2/permissions/autogenerated/reference.md deleted file mode 100644 index 2fc16b21a5..0000000000 --- a/plugins/db2/permissions/autogenerated/reference.md +++ /dev/null @@ -1,70 +0,0 @@ -## Default Permission - -Default permissions for the plugin - -#### This default permission set includes the following: - -- `allow-execute-local` -- `allow-execute-cloud` - -## Permission Table - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IdentifierDescription
- -`db2:allow-execute-cloud` - - - -Enables the execute_cloud command without any pre-configured scope. - -
- -`db2:deny-execute-cloud` - - - -Denies the execute_cloud command without any pre-configured scope. - -
- -`db2:allow-execute-local` - - - -Enables the execute_local command without any pre-configured scope. - -
- -`db2:deny-execute-local` - - - -Denies the execute_local command without any pre-configured scope. - -
diff --git a/plugins/db2/permissions/default.toml b/plugins/db2/permissions/default.toml deleted file mode 100644 index 68fa9da254..0000000000 --- a/plugins/db2/permissions/default.toml +++ /dev/null @@ -1,3 +0,0 @@ -[default] -description = "Default permissions for the plugin" -permissions = ["allow-execute-local", "allow-execute-cloud"] diff --git a/plugins/db2/src/commands.rs b/plugins/db2/src/commands.rs deleted file mode 100644 index 39bf1fee78..0000000000 --- a/plugins/db2/src/commands.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::Database2PluginExt; - -#[tauri::command] -#[specta::specta] -pub(crate) async fn execute_local( - app: tauri::AppHandle, - sql: String, - args: Vec, -) -> Result, String> { - app.db2() - .execute_local(sql, args) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub(crate) async fn execute_cloud( - app: tauri::AppHandle, - sql: String, - args: Vec, -) -> Result, String> { - app.db2() - .execute_cloud(sql, args) - .await - .map_err(|e| e.to_string()) -} diff --git a/plugins/db2/src/error.rs b/plugins/db2/src/error.rs deleted file mode 100644 index c8018a9c17..0000000000 --- a/plugins/db2/src/error.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::{Serialize, ser::Serializer}; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - PostgresError(#[from] tokio_postgres::Error), - #[error(transparent)] - HyprDbError(#[from] hypr_db_core::Error), - #[error(transparent)] - TauriError(#[from] tauri::Error), - #[error(transparent)] - IoError(#[from] std::io::Error), - #[error(transparent)] - SettingsError(#[from] tauri_plugin_settings::Error), -} - -impl Serialize for Error { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_str(self.to_string().as_ref()) - } -} diff --git a/plugins/db2/src/ext.rs b/plugins/db2/src/ext.rs deleted file mode 100644 index 12b1bbf562..0000000000 --- a/plugins/db2/src/ext.rs +++ /dev/null @@ -1,191 +0,0 @@ -pub struct Database2<'a, R: tauri::Runtime, M: tauri::Manager> { - manager: &'a M, - _runtime: std::marker::PhantomData R>, -} - -impl<'a, R: tauri::Runtime, M: tauri::Manager> Database2<'a, R, M> { - pub async fn init_local(&self) -> Result<(), crate::Error> { - let db = { - if cfg!(debug_assertions) { - hypr_db_core::DatabaseBuilder::default() - .memory() - .build() - .await - .unwrap() - } else { - use tauri_plugin_settings::SettingsPluginExt; - let dir_path = self.manager.settings().global_base()?; - let file_path = dir_path.join("db.sqlite").into_std_path_buf(); - - hypr_db_core::DatabaseBuilder::default() - .local(file_path) - .build() - .await - .unwrap() - } - }; - { - let state = self.manager.state::(); - let mut guard = state.lock().await; - guard.local_db = Some(db); - } - Ok(()) - } - - pub async fn init_cloud(&self, connection_str: &str) -> Result<(), crate::Error> { - let (client, connection) = - tokio_postgres::connect(connection_str, tokio_postgres::NoTls).await?; - - tokio::spawn(async move { - if let Err(e) = connection.await { - eprintln!("connection error: {}", e); - } - }); - - { - let state = self.manager.state::(); - let mut guard = state.lock().await; - guard.cloud_db = Some(client); - } - Ok(()) - } - - pub async fn execute_local( - &self, - sql: String, - args: Vec, - ) -> Result, crate::Error> { - let state = self.manager.state::(); - let guard = state.lock().await; - - let mut items = Vec::new(); - - if let Some(db) = &guard.local_db { - let conn = db.conn()?; - - match conn.query(&sql, args).await { - Ok(mut rows) => loop { - match rows.next().await { - Ok(Some(row)) => { - let mut map = serde_json::Map::new(); - - for idx in 0..row.column_count() { - if let Some(column_name) = row.column_name(idx) { - let value = match row.get_value(idx) { - Ok(hypr_db_core::libsql::Value::Null) => { - serde_json::Value::Null - } - Ok(hypr_db_core::libsql::Value::Integer(i)) => { - serde_json::json!(i) - } - Ok(hypr_db_core::libsql::Value::Real(f)) => { - serde_json::json!(f) - } - Ok(hypr_db_core::libsql::Value::Text(s)) => { - serde_json::json!(s) - } - Ok(hypr_db_core::libsql::Value::Blob(b)) => { - serde_json::json!(b) - } - Err(_) => serde_json::Value::Null, - }; - map.insert(column_name.to_string(), value); - } - } - - items.push(serde_json::Value::Object(map)); - } - Ok(None) => break, - Err(e) => { - tracing::error!("{:?}", e); - break; - } - } - }, - Err(e) => { - tracing::error!("{:?}", e); - } - } - } - - Ok(items) - } - - pub async fn execute_cloud( - &self, - sql: String, - args: Vec, - ) -> Result, crate::Error> { - let state = self.manager.state::(); - let guard = state.lock().await; - - let mut items = Vec::new(); - - if let Some(db) = &guard.cloud_db { - use futures_util::TryStreamExt; - let mut stream = std::pin::pin!(db.query_raw(&sql, args).await?); - - while let Some(row) = stream.try_next().await? { - let mut map = serde_json::Map::new(); - - for (idx, column) in row.columns().iter().enumerate() { - let value = match *column.type_() { - tokio_postgres::types::Type::BOOL => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - tokio_postgres::types::Type::INT2 | tokio_postgres::types::Type::INT4 => { - row.try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)) - } - tokio_postgres::types::Type::INT8 => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - tokio_postgres::types::Type::FLOAT4 => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - tokio_postgres::types::Type::FLOAT8 => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - tokio_postgres::types::Type::TEXT - | tokio_postgres::types::Type::VARCHAR => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - tokio_postgres::types::Type::JSON | tokio_postgres::types::Type::JSONB => { - row.try_get::<_, Option>(idx)? - } - _ => row - .try_get::<_, Option>(idx)? - .map(|v| serde_json::json!(v)), - }; - - map.insert( - column.name().to_string(), - value.unwrap_or(serde_json::Value::Null), - ); - } - - items.push(serde_json::Value::Object(map)); - } - } - - Ok(items) - } -} - -pub trait Database2PluginExt { - fn db2(&self) -> Database2<'_, R, Self> - where - Self: tauri::Manager + Sized; -} - -impl> Database2PluginExt for T { - fn db2(&self) -> Database2<'_, R, Self> - where - Self: Sized, - { - Database2 { - manager: self, - _runtime: std::marker::PhantomData, - } - } -} diff --git a/plugins/db2/src/lib.rs b/plugins/db2/src/lib.rs deleted file mode 100644 index 0751fbc73d..0000000000 --- a/plugins/db2/src/lib.rs +++ /dev/null @@ -1,64 +0,0 @@ -use tokio::sync::Mutex; - -mod commands; -mod error; -mod ext; - -pub use error::*; -pub use ext::*; -use tauri::Manager; - -const PLUGIN_NAME: &str = "db2"; - -pub type ManagedState = Mutex; - -#[derive(Default)] -pub struct State { - pub local_db: Option, - pub cloud_db: Option, -} - -fn make_specta_builder() -> tauri_specta::Builder { - tauri_specta::Builder::::new() - .plugin_name(PLUGIN_NAME) - .commands(tauri_specta::collect_commands![ - commands::execute_local::, - commands::execute_cloud::, - ]) - .error_handling(tauri_specta::ErrorHandlingMode::Result) -} - -pub fn init() -> tauri::plugin::TauriPlugin { - let specta_builder = make_specta_builder(); - - tauri::plugin::Builder::new(PLUGIN_NAME) - .invoke_handler(specta_builder.invoke_handler()) - .setup(|app, _api| { - let state = ManagedState::default(); - app.manage(state); - Ok(()) - }) - .build() -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn export_types() { - const OUTPUT_FILE: &str = "./js/bindings.gen.ts"; - - make_specta_builder::() - .export( - specta_typescript::Typescript::default() - .formatter(specta_typescript::formatter::prettier) - .bigint(specta_typescript::BigIntExportBehavior::Number), - OUTPUT_FILE, - ) - .unwrap(); - - let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); - std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); - } -} diff --git a/plugins/db2/tsconfig.json b/plugins/db2/tsconfig.json deleted file mode 100644 index 13b985325d..0000000000 --- a/plugins/db2/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "include": ["./js/*.ts"], - "exclude": ["node_modules"] -} diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index c48ccad13d..022f1f9aa1 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -14,7 +14,6 @@ tauri-plugin = { workspace = true, features = ["build"] } specta-typescript = { workspace = true } [dependencies] -hypr-db-user = { workspace = true } hypr-detect = { workspace = true } hypr-host = { workspace = true } hypr-intercept = { workspace = true } diff --git a/plugins/reactive-db/Cargo.toml b/plugins/reactive-db/Cargo.toml new file mode 100644 index 0000000000..179e8cda54 --- /dev/null +++ b/plugins/reactive-db/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tauri-plugin-reactive-db" +version = "0.1.0" +edition = "2024" +links = "tauri-plugin-reactive-db" + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +specta-typescript = { workspace = true } + +[dependencies] +tauri = { workspace = true } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } + +sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "sqlite-unbundled"] } +libsqlite3-sys = "0.30" + +tokio = { workspace = true, features = ["sync"] } + +serde = { workspace = true } +serde_json = { workspace = true } +specta = { workspace = true, features = ["serde_json"] } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true, features = ["v4"] } diff --git a/plugins/reactive-db/build.rs b/plugins/reactive-db/build.rs new file mode 100644 index 0000000000..7691f5c061 --- /dev/null +++ b/plugins/reactive-db/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["subscribe", "unsubscribe", "execute"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/plugins/reactive-db/js/index.ts b/plugins/reactive-db/js/index.ts new file mode 100644 index 0000000000..8a67e5bbde --- /dev/null +++ b/plugins/reactive-db/js/index.ts @@ -0,0 +1,49 @@ +import { Channel, invoke } from "@tauri-apps/api/core"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type QueryEvent = + | { event: "result"; data: Record[] } + | { event: "error"; data: string }; + +export interface SubscribeOptions> { + onData: (rows: T[]) => void; + onError?: (error: string) => void; +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +export async function subscribe>( + sql: string, + params: unknown[], + options: SubscribeOptions, +): Promise<() => void> { + const channel = new Channel(); + + channel.onmessage = (event: QueryEvent) => { + if (event.event === "result") { + options.onData(event.data as T[]); + } else if (event.event === "error") { + options.onError?.(event.data); + } + }; + + const subscriptionId: string = await invoke("plugin:reactive-db|subscribe", { + sql, + params, + onEvent: channel, + }); + + return () => { + invoke("plugin:reactive-db|unsubscribe", { subscriptionId }).catch( + () => {}, + ); + }; +} + +export async function execute( + sql: string, + params: unknown[] = [], +): Promise[]> { + return invoke("plugin:reactive-db|execute", { sql, params }); +} diff --git a/plugins/reactive-db/package.json b/plugins/reactive-db/package.json new file mode 100644 index 0000000000..c429e700ef --- /dev/null +++ b/plugins/reactive-db/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hypr/plugin-reactive-db", + "version": "0.0.0", + "private": true, + "exports": { + ".": "./js/index.ts" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } +} diff --git a/plugins/reactive-db/permissions/autogenerated/commands/execute.toml b/plugins/reactive-db/permissions/autogenerated/commands/execute.toml new file mode 100644 index 0000000000..d98be89931 --- /dev/null +++ b/plugins/reactive-db/permissions/autogenerated/commands/execute.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-execute" +description = "Enables the execute command without any pre-configured scope." +commands.allow = ["execute"] + +[[permission]] +identifier = "deny-execute" +description = "Denies the execute command without any pre-configured scope." +commands.deny = ["execute"] diff --git a/plugins/reactive-db/permissions/autogenerated/commands/subscribe.toml b/plugins/reactive-db/permissions/autogenerated/commands/subscribe.toml new file mode 100644 index 0000000000..0277f2aec4 --- /dev/null +++ b/plugins/reactive-db/permissions/autogenerated/commands/subscribe.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-subscribe" +description = "Enables the subscribe command without any pre-configured scope." +commands.allow = ["subscribe"] + +[[permission]] +identifier = "deny-subscribe" +description = "Denies the subscribe command without any pre-configured scope." +commands.deny = ["subscribe"] diff --git a/plugins/reactive-db/permissions/autogenerated/commands/unsubscribe.toml b/plugins/reactive-db/permissions/autogenerated/commands/unsubscribe.toml new file mode 100644 index 0000000000..7d23bab710 --- /dev/null +++ b/plugins/reactive-db/permissions/autogenerated/commands/unsubscribe.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unsubscribe" +description = "Enables the unsubscribe command without any pre-configured scope." +commands.allow = ["unsubscribe"] + +[[permission]] +identifier = "deny-unsubscribe" +description = "Denies the unsubscribe command without any pre-configured scope." +commands.deny = ["unsubscribe"] diff --git a/plugins/reactive-db/permissions/autogenerated/reference.md b/plugins/reactive-db/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..45fd7cbacc --- /dev/null +++ b/plugins/reactive-db/permissions/autogenerated/reference.md @@ -0,0 +1,97 @@ +## Default Permission + +Default permissions for the reactive-db plugin + +#### This default permission set includes the following: + +- `allow-subscribe` +- `allow-unsubscribe` +- `allow-execute` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`reactive-db:allow-execute` + + + +Enables the execute command without any pre-configured scope. + +
+ +`reactive-db:deny-execute` + + + +Denies the execute command without any pre-configured scope. + +
+ +`reactive-db:allow-subscribe` + + + +Enables the subscribe command without any pre-configured scope. + +
+ +`reactive-db:deny-subscribe` + + + +Denies the subscribe command without any pre-configured scope. + +
+ +`reactive-db:allow-unsubscribe` + + + +Enables the unsubscribe command without any pre-configured scope. + +
+ +`reactive-db:deny-unsubscribe` + + + +Denies the unsubscribe command without any pre-configured scope. + +
diff --git a/plugins/reactive-db/permissions/default.toml b/plugins/reactive-db/permissions/default.toml new file mode 100644 index 0000000000..3a3bcb09fa --- /dev/null +++ b/plugins/reactive-db/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the reactive-db plugin" +permissions = ["allow-subscribe", "allow-unsubscribe", "allow-execute"] diff --git a/plugins/db2/permissions/schemas/schema.json b/plugins/reactive-db/permissions/schemas/schema.json similarity index 82% rename from plugins/db2/permissions/schemas/schema.json rename to plugins/reactive-db/permissions/schemas/schema.json index 6a13381392..dc3736b0c4 100644 --- a/plugins/db2/permissions/schemas/schema.json +++ b/plugins/reactive-db/permissions/schemas/schema.json @@ -295,34 +295,46 @@ "type": "string", "oneOf": [ { - "description": "Enables the execute_cloud command without any pre-configured scope.", + "description": "Enables the execute command without any pre-configured scope.", "type": "string", - "const": "allow-execute-cloud", - "markdownDescription": "Enables the execute_cloud command without any pre-configured scope." + "const": "allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." }, { - "description": "Denies the execute_cloud command without any pre-configured scope.", + "description": "Denies the execute command without any pre-configured scope.", "type": "string", - "const": "deny-execute-cloud", - "markdownDescription": "Denies the execute_cloud command without any pre-configured scope." + "const": "deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." }, { - "description": "Enables the execute_local command without any pre-configured scope.", + "description": "Enables the subscribe command without any pre-configured scope.", "type": "string", - "const": "allow-execute-local", - "markdownDescription": "Enables the execute_local command without any pre-configured scope." + "const": "allow-subscribe", + "markdownDescription": "Enables the subscribe command without any pre-configured scope." }, { - "description": "Denies the execute_local command without any pre-configured scope.", + "description": "Denies the subscribe command without any pre-configured scope.", "type": "string", - "const": "deny-execute-local", - "markdownDescription": "Denies the execute_local command without any pre-configured scope." + "const": "deny-subscribe", + "markdownDescription": "Denies the subscribe command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-execute-local`\n- `allow-execute-cloud`", + "description": "Enables the unsubscribe command without any pre-configured scope.", + "type": "string", + "const": "allow-unsubscribe", + "markdownDescription": "Enables the unsubscribe command without any pre-configured scope." + }, + { + "description": "Denies the unsubscribe command without any pre-configured scope.", + "type": "string", + "const": "deny-unsubscribe", + "markdownDescription": "Denies the unsubscribe command without any pre-configured scope." + }, + { + "description": "Default permissions for the reactive-db plugin\n#### This default permission set includes:\n\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-execute`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-execute-local`\n- `allow-execute-cloud`" + "markdownDescription": "Default permissions for the reactive-db plugin\n#### This default permission set includes:\n\n- `allow-subscribe`\n- `allow-unsubscribe`\n- `allow-execute`" } ] } diff --git a/plugins/reactive-db/src/commands.rs b/plugins/reactive-db/src/commands.rs new file mode 100644 index 0000000000..f18f464325 --- /dev/null +++ b/plugins/reactive-db/src/commands.rs @@ -0,0 +1,114 @@ +use crate::{ManagedState, QueryEvent, execute_query}; + +#[tauri::command] +#[specta::specta] +pub async fn subscribe( + _app: tauri::AppHandle, + state: tauri::State<'_, ManagedState>, + sql: String, + params: Vec, + on_event: tauri::ipc::Channel, +) -> Result { + let mut guard = state.lock().await; + + let pool = guard.pool.as_ref().ok_or("not initialized")?; + let updates_tx = guard.updates_tx.as_ref().ok_or("not initialized")?; + + // Extract which tables this query depends on. + let tables = crate::explain::extract_tables(pool, &sql) + .await + .map_err(|e| e.to_string())?; + + // Execute the query once and send the initial result set. + let rows = execute_query(pool, &sql, ¶ms) + .await + .map_err(|e| e.to_string())?; + on_event + .send(QueryEvent::Result(rows)) + .map_err(|e| e.to_string())?; + + // Clone everything the spawned task needs. + let rx = updates_tx.subscribe(); + let task_pool = pool.clone(); + let task_sql = sql.clone(); + let task_params = params.clone(); + let task_tables = tables.clone(); + let task_channel = on_event.clone(); + + tokio::spawn(async move { + let mut rx = rx; + loop { + match rx.recv().await { + Ok(update) => { + if !task_tables.contains(&update.table) { + continue; + } + + // Drain any additional pending updates before re-querying + // so we don't re-run the same query multiple times for a + // batch of writes. + while rx.try_recv().is_ok() {} + + match execute_query(&task_pool, &task_sql, &task_params).await { + Ok(rows) => { + if task_channel.send(QueryEvent::Result(rows)).is_err() { + break; + } + } + Err(e) => { + if task_channel.send(QueryEvent::Error(e.to_string())).is_err() { + break; + } + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + let id = uuid::Uuid::new_v4().to_string(); + + guard.subscriptions.insert( + id.clone(), + crate::Subscription { + sql, + params, + tables, + channel: on_event, + }, + ); + + Ok(id) +} + +#[tauri::command] +#[specta::specta] +pub async fn unsubscribe( + _app: tauri::AppHandle, + state: tauri::State<'_, ManagedState>, + subscription_id: String, +) -> Result<(), String> { + let mut guard = state.lock().await; + guard + .subscriptions + .remove(&subscription_id) + .ok_or_else(|| format!("subscription not found: {subscription_id}"))?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn execute( + _app: tauri::AppHandle, + state: tauri::State<'_, ManagedState>, + sql: String, + params: Vec, +) -> Result, String> { + let guard = state.lock().await; + let pool = guard.pool.as_ref().ok_or("not initialized")?; + execute_query(pool, &sql, ¶ms) + .await + .map_err(|e| e.to_string()) +} diff --git a/plugins/reactive-db/src/error.rs b/plugins/reactive-db/src/error.rs new file mode 100644 index 0000000000..c7a6f84b38 --- /dev/null +++ b/plugins/reactive-db/src/error.rs @@ -0,0 +1,20 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not initialized — call init() first")] + NotInitialized, + + #[error("subscription not found: {0}")] + SubscriptionNotFound(String), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Tauri(#[from] tauri::Error), +} + +impl serde::Serialize for Error { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} diff --git a/plugins/reactive-db/src/explain.rs b/plugins/reactive-db/src/explain.rs new file mode 100644 index 0000000000..1f962feadf --- /dev/null +++ b/plugins/reactive-db/src/explain.rs @@ -0,0 +1,122 @@ +use std::collections::{HashMap, HashSet}; + +use sqlx::{Row, SqlitePool}; + +/// Extract the set of table names that `sql` reads from, using `EXPLAIN QUERY PLAN`. +/// +/// # Safety +/// +/// `sql` is interpolated into `format!("EXPLAIN QUERY PLAN {sql}")` and executed directly. +/// Only pass SQL from trusted code, never user input. +pub async fn extract_tables(pool: &SqlitePool, sql: &str) -> Result, sqlx::Error> { + let master_rows = sqlx::query( + "SELECT tbl_name FROM sqlite_master WHERE type = 'table' AND tbl_name NOT LIKE 'sqlite_%'", + ) + .fetch_all(pool) + .await?; + + let known_tables: HashSet = master_rows + .iter() + .map(|r| r.get::("tbl_name")) + .collect(); + + let alias_map = build_alias_map(sql, &known_tables); + + let eqp_rows = sqlx::query(&format!("EXPLAIN QUERY PLAN {sql}")) + .fetch_all(pool) + .await?; + + let mut tables = HashSet::new(); + for row in &eqp_rows { + let detail: &str = row.get("detail"); + if let Some(name) = parse_table_from_detail(detail) { + if known_tables.contains(name) { + tables.insert(name.to_string()); + } else if let Some(real) = alias_map.get(name) { + tables.insert(real.clone()); + } + } + } + + Ok(tables) +} + +fn parse_table_from_detail(detail: &str) -> Option<&str> { + let trimmed = detail.trim(); + let rest = trimmed + .strip_prefix("SCAN ") + .or_else(|| trimmed.strip_prefix("SEARCH "))?; + rest.split_whitespace().next() +} + +fn build_alias_map(sql: &str, known_tables: &HashSet) -> HashMap { + let mut map = HashMap::new(); + let upper = sql.to_uppercase(); + let tokens: Vec<&str> = sql.split_whitespace().collect(); + let upper_tokens: Vec<&str> = upper.split_whitespace().collect(); + + for i in 0..tokens.len() { + let is_from_or_join = matches!( + upper_tokens[i], + "FROM" | "JOIN" | "INNER" | "LEFT" | "RIGHT" | "CROSS" + ); + if !is_from_or_join { + continue; + } + + let table_idx = if matches!(upper_tokens[i], "INNER" | "LEFT" | "RIGHT" | "CROSS") { + if i + 1 < tokens.len() && upper_tokens[i + 1] == "JOIN" { + i + 2 + } else { + continue; + } + } else { + i + 1 + }; + + if table_idx >= tokens.len() { + continue; + } + + let raw_table = tokens[table_idx].trim_end_matches(|c| c == ',' || c == ')'); + if !known_tables.contains(raw_table) { + continue; + } + + let alias_idx = + if table_idx + 1 < upper_tokens.len() && upper_tokens[table_idx + 1] == "AS" { + table_idx + 2 + } else { + table_idx + 1 + }; + + if alias_idx < tokens.len() { + let alias = + tokens[alias_idx].trim_end_matches(|c: char| c == ',' || c == ')' || c == ';'); + let alias_upper = alias.to_uppercase(); + if !alias.is_empty() + && !matches!( + alias_upper.as_str(), + "ON" | "WHERE" + | "SET" + | "JOIN" + | "INNER" + | "LEFT" + | "RIGHT" + | "CROSS" + | "ORDER" + | "GROUP" + | "HAVING" + | "LIMIT" + | "UNION" + | "EXCEPT" + | "INTERSECT" + ) + && !known_tables.contains(alias) + { + map.insert(alias.to_string(), raw_table.to_string()); + } + } + } + map +} diff --git a/plugins/reactive-db/src/ext.rs b/plugins/reactive-db/src/ext.rs new file mode 100644 index 0000000000..500a467df7 --- /dev/null +++ b/plugins/reactive-db/src/ext.rs @@ -0,0 +1,104 @@ +use std::ffi::c_void; +use std::sync::Arc; + +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use tokio::sync::broadcast; + +use crate::{ManagedState, TableUpdate, update_hook_callback}; + +pub struct ReactiveDb<'a, R: tauri::Runtime, M: tauri::Manager> { + manager: &'a M, + _runtime: std::marker::PhantomData R>, +} + +impl<'a, R: tauri::Runtime, M: tauri::Manager> ReactiveDb<'a, R, M> { + /// Initialize with custom connection options. + /// Returns a clone of the pool so the caller can run migrations, etc. + pub async fn init(&self, options: SqliteConnectOptions) -> Result { + let (tx, _) = broadcast::channel::(256); + let tx_for_hook = tx.clone(); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(move |conn, _| { + let tx = tx_for_hook.clone(); + Box::pin(async move { + let mut handle = conn.lock_handle().await?; + let raw = handle.as_raw_handle().as_ptr(); + + // Leak an Arc so the broadcast sender outlives the C callback. + let tx_ptr = Arc::into_raw(Arc::new(tx)) as *mut c_void; + unsafe { + libsqlite3_sys::sqlite3_update_hook( + raw, + Some(update_hook_callback), + tx_ptr, + ); + } + + Ok(()) + }) + }) + .connect_with(options) + .await?; + + { + let state = self.manager.state::(); + let mut guard = state.lock().await; + guard.pool = Some(pool.clone()); + guard.updates_tx = Some(tx); + } + + Ok(pool) + } + + /// Convenience: open a local file-backed database. + pub async fn init_local( + &self, + path: impl AsRef, + ) -> Result { + if let Some(parent) = path.as_ref().parent() { + std::fs::create_dir_all(parent).ok(); + } + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .pragma("journal_mode", "WAL") + .pragma("foreign_keys", "ON"); + self.init(options).await + } + + /// Convenience: open an in-memory database (useful for tests). + pub async fn init_memory(&self) -> Result { + let options = SqliteConnectOptions::new() + .filename(":memory:") + .pragma("foreign_keys", "ON"); + self.init(options).await + } + + /// Access the underlying pool (e.g. for migrations). + pub async fn pool(&self) -> Option { + let state = self.manager.state::(); + let guard = state.lock().await; + guard.pool.clone() + } +} + +pub trait ReactiveDbExt { + fn reactive_db(&self) -> ReactiveDb<'_, R, Self> + where + Self: tauri::Manager + Sized; +} + +impl> ReactiveDbExt for T { + fn reactive_db(&self) -> ReactiveDb<'_, R, Self> + where + Self: Sized, + { + ReactiveDb { + manager: self, + _runtime: std::marker::PhantomData, + } + } +} diff --git a/plugins/reactive-db/src/lib.rs b/plugins/reactive-db/src/lib.rs new file mode 100644 index 0000000000..6bab14da89 --- /dev/null +++ b/plugins/reactive-db/src/lib.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; +use std::ffi::{c_void, CStr}; + +use sqlx::SqlitePool; +use tokio::sync::broadcast; + +mod commands; +mod error; +mod explain; +mod ext; + +pub use error::*; +pub use ext::*; + +const PLUGIN_NAME: &str = "reactive-db"; + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(tag = "event", content = "data")] +pub enum QueryEvent { + #[serde(rename = "result")] + Result(Vec), + #[serde(rename = "error")] + Error(String), +} + +#[derive(Debug, Clone)] +pub(crate) struct TableUpdate { + pub table: String, +} + +#[allow(dead_code)] +pub(crate) struct Subscription { + pub sql: String, + pub params: Vec, + pub tables: std::collections::HashSet, + pub channel: tauri::ipc::Channel, +} + +pub struct State { + pub(crate) pool: Option, + pub(crate) updates_tx: Option>, + pub(crate) subscriptions: HashMap, +} + +impl Default for State { + fn default() -> Self { + Self { + pool: None, + updates_tx: None, + subscriptions: HashMap::new(), + } + } +} + +pub type ManagedState = tokio::sync::Mutex; + +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .plugin_name(PLUGIN_NAME) + .commands(tauri_specta::collect_commands![ + commands::subscribe::, + commands::unsubscribe::, + commands::execute::, + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Result) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + let specta_builder = make_specta_builder(); + + tauri::plugin::Builder::new(PLUGIN_NAME) + .invoke_handler(specta_builder.invoke_handler()) + .setup(|app, _api| { + use tauri::Manager; + app.manage(ManagedState::default()); + Ok(()) + }) + .build() +} + +// ── Query helpers ──────────────────────────────────────────────────────────── + +pub(crate) async fn execute_query( + pool: &SqlitePool, + sql: &str, + params: &[serde_json::Value], +) -> Result, sqlx::Error> { + let mut query = sqlx::query(sql); + for param in params { + query = match param { + serde_json::Value::Null => query.bind(None::), + serde_json::Value::Bool(b) => query.bind(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + query.bind(i) + } else { + query.bind(n.as_f64().unwrap_or_default()) + } + } + serde_json::Value::String(s) => query.bind(s.clone()), + other => query.bind(other.to_string()), + }; + } + let rows = query.fetch_all(pool).await?; + Ok(rows.iter().map(row_to_json).collect()) +} + +fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> serde_json::Value { + use sqlx::{Column, Row, TypeInfo, ValueRef}; + + let mut map = serde_json::Map::new(); + for (idx, col) in row.columns().iter().enumerate() { + let value = match row.try_get_raw(idx) { + Ok(raw) if !raw.is_null() => match raw.type_info().name() { + "TEXT" => row + .get::, _>(idx) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + "INTEGER" | "INT" | "BOOLEAN" => row + .get::, _>(idx) + .map(|v| serde_json::json!(v)) + .unwrap_or(serde_json::Value::Null), + "REAL" => row + .get::, _>(idx) + .map(|v| serde_json::json!(v)) + .unwrap_or(serde_json::Value::Null), + "BLOB" => row + .get::>, _>(idx) + .map(|v| serde_json::json!(v)) + .unwrap_or(serde_json::Value::Null), + _ => row + .get::, _>(idx) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + }, + _ => serde_json::Value::Null, + }; + map.insert(col.name().to_string(), value); + } + serde_json::Value::Object(map) +} + +// ── C callback for sqlite3_update_hook ────────────────────────────────────── + +unsafe extern "C" fn update_hook_callback( + user_data: *mut c_void, + op: std::os::raw::c_int, + _db_name: *const std::os::raw::c_char, + table_name: *const std::os::raw::c_char, + _rowid: libsqlite3_sys::sqlite3_int64, +) { + unsafe { + let is_mutation = op == libsqlite3_sys::SQLITE_INSERT + || op == libsqlite3_sys::SQLITE_UPDATE + || op == libsqlite3_sys::SQLITE_DELETE; + if !is_mutation { + return; + } + + let tx = &*(user_data as *const broadcast::Sender); + let table = CStr::from_ptr(table_name).to_string_lossy().into_owned(); + let _ = tx.send(TableUpdate { table }); + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn export_types() { + const OUTPUT_FILE: &str = "./js/bindings.gen.ts"; + + make_specta_builder::() + .export( + specta_typescript::Typescript::default() + .formatter(specta_typescript::formatter::prettier) + .bigint(specta_typescript::BigIntExportBehavior::Number), + OUTPUT_FILE, + ) + .unwrap(); + + let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); + std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); + } +} diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index b9a2282f17..7fbaa9975c 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -85,7 +85,7 @@ export type OpenTab = { tab: TabInput } export type PromptsState = { selectedTask: string | null } export type SearchState = { selectedTypes: string[] | null; initialQuery: string | null } export type SessionsState = { view: EditorView | null; autoStart: boolean | null } -export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat_support"; state?: ChatState | null } | { type: "onboarding" } | { type: "daily" } | { type: "edit"; requestId: string } +export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat_support"; state?: ChatState | null } | { type: "onboarding" } | { type: "daily" } | { type: "edit"; requestId: string } | { type: "sample" } export type TemplatesState = { showHomepage: boolean | null; isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type VisibilityEvent = { window: AppWindow; visible: boolean } export type WindowDestroyed = { window: AppWindow } diff --git a/plugins/windows/src/tab/mod.rs b/plugins/windows/src/tab/mod.rs index 45b3006a07..0102f8f1f1 100644 --- a/plugins/windows/src/tab/mod.rs +++ b/plugins/windows/src/tab/mod.rs @@ -89,5 +89,7 @@ common_derives! { #[serde(rename = "requestId")] request_id: String, }, + #[serde(rename = "sample")] + Sample, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd599d4991..4634863d3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,9 +223,6 @@ importers: '@hypr/plugin-calendar': specifier: workspace:* version: link:../../plugins/calendar - '@hypr/plugin-db2': - specifier: workspace:* - version: link:../../plugins/db2 '@hypr/plugin-deeplink2': specifier: workspace:* version: link:../../plugins/deeplink2 @@ -298,6 +295,9 @@ importers: '@hypr/plugin-permissions': specifier: workspace:* version: link:../../plugins/permissions + '@hypr/plugin-reactive-db': + specifier: workspace:* + version: link:../../plugins/reactive-db '@hypr/plugin-relay': specifier: workspace:* version: link:../../plugins/relay @@ -1786,12 +1786,6 @@ importers: specifier: ^2.10.1 version: 2.10.1 - plugins/db2: - dependencies: - '@tauri-apps/api': - specifier: ^2.10.1 - version: 2.10.1 - plugins/deeplink2: dependencies: '@tauri-apps/api': @@ -1942,6 +1936,12 @@ importers: specifier: ^2.10.1 version: 2.10.1 + plugins/reactive-db: + dependencies: + '@tauri-apps/api': + specifier: ^2.0.0 + version: 2.10.1 + plugins/relay: {} plugins/screen: