Got models and event system working

This commit is contained in:
Gregory Schier
2026-03-08 15:18:31 -07:00
parent 7382287bef
commit 0a616eb5e2
13 changed files with 181 additions and 52 deletions

1
Cargo.lock generated
View File

@@ -10400,6 +10400,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
"ts-rs",
] ]
[[package]] [[package]]

View File

@@ -1,5 +1,9 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { RpcSchema } from "../../crates-proxy/yaak-proxy-lib/bindings/gen_rpc"; import { listen as tauriListen } from "@tauri-apps/api/event";
import type {
RpcEventSchema,
RpcSchema,
} from "../../crates-proxy/yaak-proxy-lib/bindings/gen_rpc";
type Req<K extends keyof RpcSchema> = RpcSchema[K][0]; type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
type Res<K extends keyof RpcSchema> = RpcSchema[K][1]; type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
@@ -10,3 +14,15 @@ export async function rpc<K extends keyof RpcSchema>(
): Promise<Res<K>> { ): Promise<Res<K>> {
return invoke("rpc", { cmd, payload }) as Promise<Res<K>>; return invoke("rpc", { cmd, payload }) as Promise<Res<K>>;
} }
/** Subscribe to a backend event. Returns an unsubscribe function. */
export function listen<K extends keyof RpcEventSchema>(
event: K & string,
callback: (payload: RpcEventSchema[K]) => void,
): () => void {
let unsub: (() => void) | null = null;
tauriListen<RpcEventSchema[K]>(event, (e) => callback(e.payload))
.then((fn) => (unsub = fn))
.catch(console.error);
return () => unsub?.();
}

View File

@@ -1,5 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ModelChangeEvent = { "type": "upsert" } | { "type": "delete" };
export type ModelPayload = { model: ProxyEntry, change: ModelChangeEvent, };
export type ProxyEntry = { id: string, createdAt: string, updatedAt: string, url: string, method: string, reqHeaders: Array<ProxyHeader>, reqBody: Array<number> | null, resStatus: number | null, resHeaders: Array<ProxyHeader>, resBody: Array<number> | null, error: string | null, }; export type ProxyEntry = { id: string, createdAt: string, updatedAt: string, url: string, method: string, reqHeaders: Array<ProxyHeader>, reqBody: Array<number> | null, resStatus: number | null, resHeaders: Array<ProxyHeader>, resBody: Array<number> | null, error: string | null, };
export type ProxyHeader = { name: string, value: string, }; export type ProxyHeader = { name: string, value: string, };

View File

@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModelPayload } from "./gen_models";
export type ProxyStartRequest = { port: number | null, }; export type ProxyStartRequest = { port: number | null, };
@@ -6,4 +7,6 @@ export type ProxyStartResponse = { port: number, alreadyRunning: boolean, };
export type ProxyStopRequest = Record<string, never>; export type ProxyStopRequest = Record<string, never>;
export type RpcEventSchema = { model_write: ModelPayload, };
export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], }; export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], };

View File

@@ -1,4 +1,4 @@
CREATE TABLE proxy_entries CREATE TABLE http_exchanges
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,

View File

@@ -7,24 +7,26 @@ use std::sync::Mutex;
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use yaak_database::UpdateSource; use yaak_database::{ModelChangeEvent, UpdateSource};
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState}; use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
use yaak_rpc::{RpcError, define_rpc}; use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
use crate::db::ProxyQueryManager; use crate::db::ProxyQueryManager;
use crate::models::{ProxyEntry, ProxyHeader}; use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
// -- Context -- // -- Context --
pub struct ProxyCtx { pub struct ProxyCtx {
handle: Mutex<Option<ProxyHandle>>, handle: Mutex<Option<ProxyHandle>>,
pub db: ProxyQueryManager, pub db: ProxyQueryManager,
pub events: RpcEventEmitter,
} }
impl ProxyCtx { impl ProxyCtx {
pub fn new(db_path: &Path) -> Self { pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
Self { Self {
handle: Mutex::new(None), handle: Mutex::new(None),
db: ProxyQueryManager::new(db_path), db: ProxyQueryManager::new(db_path),
events,
} }
} }
} }
@@ -68,7 +70,8 @@ fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result<ProxyStartRespo
// Spawn event loop before storing the handle // Spawn event loop before storing the handle
if let Some(event_rx) = proxy_handle.take_event_rx() { if let Some(event_rx) = proxy_handle.take_event_rx() {
let db = ctx.db.clone(); let db = ctx.db.clone();
std::thread::spawn(move || run_event_loop(event_rx, db)); let events = ctx.events.clone();
std::thread::spawn(move || run_event_loop(event_rx, db, events));
} }
*handle = Some(proxy_handle); *handle = Some(proxy_handle);
@@ -85,7 +88,7 @@ fn proxy_stop(ctx: &ProxyCtx, _req: ProxyStopRequest) -> Result<bool, RpcError>
// -- Event loop -- // -- Event loop --
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager) { fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new(); let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
while let Ok(event) = rx.recv() { while let Ok(event) = rx.recv() {
@@ -140,22 +143,22 @@ fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManag
r.response_body_size = size; r.response_body_size = size;
r.elapsed_ms = r.elapsed_ms.or(Some(elapsed_ms)); r.elapsed_ms = r.elapsed_ms.or(Some(elapsed_ms));
r.state = RequestState::Complete; r.state = RequestState::Complete;
write_entry(&db, &r); write_entry(&db, &events, &r);
} }
} }
ProxyEvent::Error { id, error } => { ProxyEvent::Error { id, error } => {
if let Some(mut r) = in_flight.remove(&id) { if let Some(mut r) = in_flight.remove(&id) {
r.error = Some(error); r.error = Some(error);
r.state = RequestState::Error; r.state = RequestState::Error;
write_entry(&db, &r); write_entry(&db, &events, &r);
} }
} }
} }
} }
} }
fn write_entry(db: &ProxyQueryManager, r: &CapturedRequest) { fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedRequest) {
let entry = ProxyEntry { let entry = HttpExchange {
url: r.url.clone(), url: r.url.clone(),
method: r.method.clone(), method: r.method.clone(),
req_headers: r.request_headers.iter() req_headers: r.request_headers.iter()
@@ -171,8 +174,14 @@ fn write_entry(db: &ProxyQueryManager, r: &CapturedRequest) {
..Default::default() ..Default::default()
}; };
db.with_conn(|ctx| { db.with_conn(|ctx| {
if let Err(e) = ctx.upsert(&entry, &UpdateSource::Background) { match ctx.upsert(&entry, &UpdateSource::Background) {
warn!("Failed to write proxy entry: {e}"); Ok((saved, created)) => {
events.emit("model_write", &ModelPayload {
model: saved,
change: ModelChangeEvent::Upsert { created },
});
}
Err(e) => warn!("Failed to write proxy entry: {e}"),
} }
}); });
} }
@@ -181,6 +190,11 @@ fn write_entry(db: &ProxyQueryManager, r: &CapturedRequest) {
define_rpc! { define_rpc! {
ProxyCtx; ProxyCtx;
"proxy_start" => proxy_start(ProxyStartRequest) -> ProxyStartResponse, commands {
"proxy_stop" => proxy_stop(ProxyStopRequest) -> bool, proxy_start(ProxyStartRequest) -> ProxyStartResponse,
proxy_stop(ProxyStopRequest) -> bool,
}
events {
model_write(ModelPayload),
}
} }

View File

@@ -3,7 +3,7 @@ use rusqlite::Row;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def}; use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date}; use yaak_database::{ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date};
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -16,8 +16,8 @@ pub struct ProxyHeader {
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "proxy_entries")] #[enum_def(table_name = "http_exchanges")]
pub struct ProxyEntry { pub struct HttpExchange {
pub id: String, pub id: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
@@ -31,21 +31,29 @@ pub struct ProxyEntry {
pub error: Option<String>, pub error: Option<String>,
} }
impl UpsertModelInfo for ProxyEntry { #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ModelPayload {
pub model: HttpExchange,
pub change: ModelChangeEvent,
}
impl UpsertModelInfo for HttpExchange {
fn table_name() -> impl IntoTableRef + IntoIden { fn table_name() -> impl IntoTableRef + IntoIden {
ProxyEntryIden::Table HttpExchangeIden::Table
} }
fn id_column() -> impl IntoIden + Eq + Clone { fn id_column() -> impl IntoIden + Eq + Clone {
ProxyEntryIden::Id HttpExchangeIden::Id
} }
fn generate_id() -> String { fn generate_id() -> String {
generate_prefixed_id("pe") generate_prefixed_id("he")
} }
fn order_by() -> (impl IntoColumnRef, Order) { fn order_by() -> (impl IntoColumnRef, Order) {
(ProxyEntryIden::CreatedAt, Order::Desc) (HttpExchangeIden::CreatedAt, Order::Desc)
} }
fn get_id(&self) -> String { fn get_id(&self) -> String {
@@ -56,7 +64,7 @@ impl UpsertModelInfo for ProxyEntry {
self, self,
source: &UpdateSource, source: &UpdateSource,
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> { ) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use ProxyEntryIden::*; use HttpExchangeIden::*;
Ok(vec![ Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)), (CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)), (UpdatedAt, upsert_date(source, self.updated_at)),
@@ -73,15 +81,15 @@ impl UpsertModelInfo for ProxyEntry {
fn update_columns() -> Vec<impl IntoIden> { fn update_columns() -> Vec<impl IntoIden> {
vec![ vec![
ProxyEntryIden::UpdatedAt, HttpExchangeIden::UpdatedAt,
ProxyEntryIden::Url, HttpExchangeIden::Url,
ProxyEntryIden::Method, HttpExchangeIden::Method,
ProxyEntryIden::ReqHeaders, HttpExchangeIden::ReqHeaders,
ProxyEntryIden::ReqBody, HttpExchangeIden::ReqBody,
ProxyEntryIden::ResStatus, HttpExchangeIden::ResStatus,
ProxyEntryIden::ResHeaders, HttpExchangeIden::ResHeaders,
ProxyEntryIden::ResBody, HttpExchangeIden::ResBody,
ProxyEntryIden::Error, HttpExchangeIden::Error,
] ]
} }

View File

@@ -1,7 +1,7 @@
use log::error; use log::error;
use tauri::{Manager, RunEvent, State}; use tauri::{Emitter, Manager, RunEvent, State};
use yaak_proxy_lib::ProxyCtx; use yaak_proxy_lib::ProxyCtx;
use yaak_rpc::RpcRouter; use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig; use yaak_window::window::CreateWindowConfig;
#[tauri::command] #[tauri::command]
@@ -20,8 +20,21 @@ pub fn run() {
.setup(|app| { .setup(|app| {
let data_dir = app.path().app_data_dir().expect("no app data dir"); let data_dir = app.path().app_data_dir().expect("no app data dir");
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir"); std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
app.manage(ProxyCtx::new(&data_dir.join("proxy.db")));
let (emitter, event_rx) = RpcEventEmitter::new();
app.manage(ProxyCtx::new(&data_dir.join("proxy.db"), emitter));
app.manage(yaak_proxy_lib::build_router()); app.manage(yaak_proxy_lib::build_router());
// Drain RPC events and forward as Tauri events
let app_handle = app.handle().clone();
std::thread::spawn(move || {
for event in event_rx {
if let Err(e) = app_handle.emit(event.event, event.payload) {
error!("Failed to emit RPC event: {e}");
}
}
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![rpc]) .invoke_handler(tauri::generate_handler![rpc])

View File

@@ -17,3 +17,4 @@ sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ts-rs = { workspace = true }

View File

@@ -12,7 +12,7 @@ pub use db_context::DbContext;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use migrate::run_migrations; pub use migrate::run_migrations;
pub use traits::{UpsertModelInfo, upsert_date}; pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::UpdateSource; pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id}; pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
// Re-export pool types that consumers will need // Re-export pool types that consumers will need

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")] #[serde(rename_all = "snake_case", tag = "type")]
@@ -15,3 +16,10 @@ impl UpdateSource {
Self::Window { label: label.into() } Self::Window { label: label.into() }
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ModelChangeEvent {
Upsert { created: bool },
Delete,
}

View File

@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::mpsc;
/// Type-erased handler function: takes context + JSON payload, returns JSON or error. /// Type-erased handler function: takes context + JSON payload, returns JSON or error.
type HandlerFn<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>; type HandlerFn<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
@@ -101,29 +102,97 @@ impl<Ctx> RpcRouter<Ctx> {
} }
} }
/// Define RPC commands with a single source of truth. /// A named event carrying a JSON payload, emitted from backend to frontend.
#[derive(Debug, Clone, Serialize)]
pub struct RpcEvent {
pub event: &'static str,
pub payload: serde_json::Value,
}
/// Channel-based event emitter. The backend calls `emit()`, the transport
/// adapter (Tauri, WebSocket, etc.) drains the receiver and delivers events.
#[derive(Clone)]
pub struct RpcEventEmitter {
tx: mpsc::Sender<RpcEvent>,
}
impl RpcEventEmitter {
pub fn new() -> (Self, mpsc::Receiver<RpcEvent>) {
let (tx, rx) = mpsc::channel();
(Self { tx }, rx)
}
/// Emit a typed event. Serializes the payload to JSON.
pub fn emit<T: Serialize>(&self, event: &'static str, payload: &T) {
if let Ok(value) = serde_json::to_value(payload) {
let _ = self.tx.send(RpcEvent { event, payload: value });
}
}
}
/// Define RPC commands and events with a single source of truth.
/// ///
/// Generates: /// Generates:
/// - `build_router()` — creates an `RpcRouter` with all handlers registered /// - `build_router()` — creates an `RpcRouter` with all handlers registered
/// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation /// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation
/// - `RpcEventSchema` — (if events declared) a struct mapping event names to payload types
///
/// The wire name for each command/event is derived from `stringify!($ident)`.
/// ///
/// # Example /// # Example
/// ```ignore /// ```ignore
/// define_rpc! { /// define_rpc! {
/// ProxyCtx; /// ProxyCtx;
/// "proxy_start" => proxy_start(ProxyStartRequest) -> ProxyStartResponse, /// commands {
/// "proxy_stop" => proxy_stop(ProxyStopRequest) -> bool, /// proxy_start(ProxyStartRequest) -> ProxyStartResponse,
/// proxy_stop(ProxyStopRequest) -> bool,
/// }
/// events {
/// model_write(ModelPayload),
/// }
/// } /// }
/// ``` /// ```
#[macro_export] #[macro_export]
macro_rules! define_rpc { macro_rules! define_rpc {
// With both commands and events
( (
$ctx:ty; $ctx:ty;
$( $name:literal => $handler:ident ( $req:ty ) -> $res:ty ),* $(,)? commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
events {
$( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)?
}
) => { ) => {
pub fn build_router() -> $crate::RpcRouter<$ctx> { pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new(); let mut router = $crate::RpcRouter::new();
$( router.register($name, $crate::rpc_handler!($handler)); )* $( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
router
}
#[derive(ts_rs::TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcSchema {
$( pub $handler: ($req, $res), )*
}
#[derive(ts_rs::TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcEventSchema {
$( pub $evt_ident: $evt_payload, )*
}
};
// Commands only (no events)
(
$ctx:ty;
commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
) => {
pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new();
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
router router
} }

View File

@@ -10,7 +10,7 @@ use std::collections::BTreeMap;
use ts_rs::TS; use ts_rs::TS;
use yaak_core::WorkspaceContext; use yaak_core::WorkspaceContext;
pub use yaak_database::{generate_id, generate_id_of_length, generate_prefixed_id}; pub use yaak_database::{ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id};
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -21,14 +21,6 @@ pub struct ModelPayload {
pub change: ModelChangeEvent, pub change: ModelChangeEvent,
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")]
pub enum ModelChangeEvent {
Upsert { created: bool },
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")] #[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]