From 5eddb69112595c598fda87ef8ab158af0dfe514d Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 23 Jul 2026 16:52:59 -0300 Subject: [PATCH 1/4] fix(database): enumerate available handles in UNKNOWN_DB errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent that guesses a wrong db handle got only the bad name echoed back — in a live scan run, sub-agents burned 25 failed database::execute calls each brute-forcing 23 handle names before landing on "primary". UnknownDb now carries the registered handle names, filled from the pools map at the miss site, so one failure teaches the right name: {"code":"UNKNOWN_DB","db":"scans","available":["primary"]} The code field and existing fields are unchanged; `available` is additive. listDatabases already exposes the same names to any caller that can reach the worker, so no new information surface. --- database/src/driver/sqlite.rs | 1 + database/src/error.rs | 19 ++++++++++++++++--- database/src/handlers/mod.rs | 17 +++++++++++------ database/src/handlers/query.rs | 3 +++ database/src/handlers/transaction.rs | 5 ++++- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/database/src/driver/sqlite.rs b/database/src/driver/sqlite.rs index 17b2a63f20..f45f9085c7 100644 --- a/database/src/driver/sqlite.rs +++ b/database/src/driver/sqlite.rs @@ -890,6 +890,7 @@ mod tests { // different variant. let e = DbError::UnknownDb { db: "primary".into(), + available: vec![], }; assert!(matches!(with_failed_index(e, 3), DbError::UnknownDb { .. })); } diff --git a/database/src/error.rs b/database/src/error.rs index f6752393e8..dc25003354 100644 --- a/database/src/error.rs +++ b/database/src/error.rs @@ -26,8 +26,8 @@ pub enum DbError { TransactionNotFound { transaction_id: String }, #[serde(rename = "UNKNOWN_DB")] - #[error("unknown db {db}")] - UnknownDb { db: String }, + #[error("unknown db {db}; available: [{}]", available.join(", "))] + UnknownDb { db: String, available: Vec }, #[serde(rename = "INVALID_PARAM")] #[error("invalid parameter at index {index}: {reason}")] @@ -84,12 +84,25 @@ mod tests { } #[test] - fn unknown_db_serializes_with_stable_code() { + fn unknown_db_serializes_with_stable_code_and_available_handles() { let e = DbError::UnknownDb { db: "missing".into(), + available: vec!["analytics".into(), "primary".into()], }; let v: serde_json::Value = serde_json::to_value(&e).unwrap(); assert_eq!(v["code"], "UNKNOWN_DB"); + assert_eq!(v["db"], "missing"); + assert_eq!( + v["available"], + serde_json::json!(["analytics", "primary"]), + "available handles must be in the wire envelope so callers can self-correct" + ); + // The human-readable message names the handles too — that's what an + // LLM caller sees in a function_result. + assert_eq!( + e.to_string(), + "unknown db missing; available: [analytics, primary]" + ); } #[test] diff --git a/database/src/handlers/mod.rs b/database/src/handlers/mod.rs index 38595ee537..f8cc7154ce 100644 --- a/database/src/handlers/mod.rs +++ b/database/src/handlers/mod.rs @@ -59,11 +59,16 @@ pub struct AppState { impl AppState { pub async fn pool(&self, db: &str) -> Result { - self.pools - .read() - .await - .get(db) - .cloned() - .ok_or_else(|| DbError::UnknownDb { db: db.to_string() }) + let pools = self.pools.read().await; + pools.get(db).cloned().ok_or_else(|| { + // Enumerate the registered handles so a caller that guessed wrong + // learns the right name from one failure instead of trial-and-error. + let mut available: Vec = pools.keys().cloned().collect(); + available.sort(); + DbError::UnknownDb { + db: db.to_string(), + available, + } + }) } } diff --git a/database/src/handlers/query.rs b/database/src/handlers/query.rs index 22f6027f86..ff65866ba9 100644 --- a/database/src/handlers/query.rs +++ b/database/src/handlers/query.rs @@ -154,6 +154,9 @@ mod tests { .await .unwrap_err(); assert!(err.contains("UNKNOWN_DB"), "got: {err}"); + // The error must enumerate the registered handles so a caller that + // guessed a wrong name can self-correct from one failure. + assert!(err.contains("\"available\":[\"primary\"]"), "got: {err}"); } #[tokio::test(flavor = "multi_thread")] diff --git a/database/src/handlers/transaction.rs b/database/src/handlers/transaction.rs index 369588ae60..8c2f152dd8 100644 --- a/database/src/handlers/transaction.rs +++ b/database/src/handlers/transaction.rs @@ -206,7 +206,10 @@ mod tests { // Non-DriverError variants → None assert_eq!( - failed_index_of(&crate::error::DbError::UnknownDb { db: "x".into() }), + failed_index_of(&crate::error::DbError::UnknownDb { + db: "x".into(), + available: vec![] + }), None ); assert_eq!( From daa92606d3770484dca72b42369e8fa8e7c4d28d Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 23 Jul 2026 16:53:24 -0300 Subject: [PATCH 2/4] feat(database): injectable console UI with database function-call renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the SOP scaffold (docs/sops/injectable-console-ui.md, state worker template) and a function-trigger renderer covering all 13 database::* functions, so calls render as real cards in chat and traces instead of raw JSON: - query/transactionQuery/runStatement: highlighted SQL, params, result rows as a table (capped at 50 with a +N note) - execute/transactionExecute: SQL + affected rows + returned rows - executeBatch/transaction: numbered step list, failed step marked, committed/rolled-back chip - prepareStatement/beginTransaction/commit/rollback: handle + status - listDatabases: name/driver/url/pool table - FunctionIdLabel dims the database:: prefix on every card header Error outputs return null and fall through to the console's built-in error card. Assets are esbuild outputs embedded via include_str! and registered through the shared iii-console-ui crate (content function database::ui-content, III_DATABASE_UI_WATCH dev watcher). main.rs now Arc-wraps the client — ConsoleUi::register needs &Arc; deref coercion keeps every existing call site unchanged. --- database/Cargo.lock | 13 + database/Cargo.toml | 1 + database/build.rs | 172 ++++++++ database/src/lib.rs | 1 + database/src/main.rs | 10 +- database/src/ui.rs | 77 ++++ database/ui/build.mjs | 37 ++ database/ui/package.json | 18 + database/ui/page.tsx | 22 ++ .../ui/src/function-trigger-message/index.tsx | 367 ++++++++++++++++++ .../src/function-trigger-message/parsers.ts | 163 ++++++++ database/ui/styles.css | 135 +++++++ database/ui/tsconfig.json | 14 + pnpm-lock.yaml | 16 + pnpm-workspace.yaml | 1 + 15 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 database/build.rs create mode 100644 database/src/ui.rs create mode 100644 database/ui/build.mjs create mode 100644 database/ui/package.json create mode 100644 database/ui/page.tsx create mode 100644 database/ui/src/function-trigger-message/index.tsx create mode 100644 database/ui/src/function-trigger-message/parsers.ts create mode 100644 database/ui/styles.css create mode 100644 database/ui/tsconfig.json diff --git a/database/Cargo.lock b/database/Cargo.lock index d11636a465..de8b6a5301 100644 --- a/database/Cargo.lock +++ b/database/Cargo.lock @@ -594,6 +594,7 @@ dependencies = [ "clap", "deadpool-postgres", "futures-util", + "iii-console-ui", "iii-helpers", "iii-sdk", "mysql_async", @@ -1258,6 +1259,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" diff --git a/database/Cargo.toml b/database/Cargo.toml index a8a80c7b1b..c131cd2a4d 100644 --- a/database/Cargo.toml +++ b/database/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" iii-helpers = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/database/build.rs b/database/build.rs new file mode 100644 index 0000000000..f00c1fd043 --- /dev/null +++ b/database/build.rs @@ -0,0 +1,172 @@ +//! Build script for the `database` worker. +//! +//! Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the state worker's precedent). Set `SKIP_UI_BUILD=1` to use +//! the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); +} diff --git a/database/src/lib.rs b/database/src/lib.rs index 6ae751d2c8..e518c6b6d2 100644 --- a/database/src/lib.rs +++ b/database/src/lib.rs @@ -9,6 +9,7 @@ pub mod handlers; pub mod pool; pub mod transaction; pub mod triggers; +pub mod ui; pub mod value; pub fn worker_name() -> &'static str { diff --git a/database/src/main.rs b/database/src/main.rs index ad9ec933f0..6cdc476953 100644 --- a/database/src/main.rs +++ b/database/src/main.rs @@ -57,13 +57,15 @@ async fn main() -> Result<()> { "starting" ); - let iii = register_worker( + // Arc-wrapped for `ui::register` (the console-ui crate clones the client + // into its hot-reload watcher task); everything else auto-derefs. + let iii = Arc::new(register_worker( &cli.url, InitOptions { otel: Some(OtelConfig::default()), ..Default::default() }, - ); + )); let seed = match &cli.config { Some(path) => match WorkerConfig::from_file(path) { @@ -315,6 +317,10 @@ async fn main() -> Result<()> { configuration::register_config_trigger(&iii, state.clone()) .context("registering configuration change trigger")?; + // Injectable console UI (function-trigger renderer) — after the + // database::* functions so the console can attribute the assets. + database::ui::register(&iii); + tracing::info!( "database worker registered 13 functions and 1 trigger type, waiting for invocations" ); diff --git a/database/src/ui.rs b/database/src/ui.rs new file mode 100644 index 0000000000..325b8b853a --- /dev/null +++ b/database/src/ui.rs @@ -0,0 +1,77 @@ +//! Injectable console UI for the database worker +//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP: +//! workers/docs/sops/injectable-console-ui.md). +//! +//! Ships two assets into any running console: +//! +//! - `database/page.js` (`console:script`) — a function-trigger renderer for +//! every `database::*` call: SQL with highlighting, request chips (db, +//! transaction/handle ids), result-row tables, batch/transaction step +//! lists. Error outputs fall through to the console's built-in error card. +//! - `database/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="database"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `database::ui-content`, one +//! Message-path trigger per asset, `III_DATABASE_UI_WATCH` hot-reload +//! watcher) lives in the shared `iii-console-ui` crate (path-linked from +//! `workers/crates/console-ui`); this module only names the assets and +//! embeds their bytes. +//! +//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui +//! external — they resolve through the console's import map at runtime) and +//! embedded at compile time so the worker stays one self-contained binary. +//! For the dev loop, set `III_DATABASE_UI_WATCH` to the build output +//! directory (or `1` for `ui/dist`): the worker polls both files and +//! re-registers a changed asset's trigger — every open console tab +//! hot-swaps it. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "database/page.js"; +pub const STYLES_PATH: &str = "database/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("database") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the database worker's console UI. Call after all +/// `database::*` functions are registered. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=database]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="database"]"#) + || STYLES_CSS.contains("[data-iii-ui=database]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/database/ui/build.mjs b/database/ui/build.mjs new file mode 100644 index 0000000000..f039d23d10 --- /dev/null +++ b/database/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime + * through the console's import map (a bundled React copy would surface as + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_DATABASE_UI_WATCH + * poller for the hot-reload dev loop. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/database/ui/package.json b/database/ui/package.json new file mode 100644 index 0000000000..6c2a47a530 --- /dev/null +++ b/database/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@iii-workers/database-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/database/ui/page.tsx b/database/ui/page.tsx new file mode 100644 index 0000000000..82aee1ce81 --- /dev/null +++ b/database/ui/page.tsx @@ -0,0 +1,22 @@ +/** + * Entry for the database worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/page.js and served over + * the `console:script` trigger (see src/ui.rs). The stylesheet is its own + * asset: ../styles.css ships over `console:style` as database/styles.css — + * the console mounts and link-swaps it, styles-before-scripts on boot. + * + * `setup(host)` registers one contribution: the function-trigger renderer + * in src/function-trigger-message/ — how every database::* call renders in + * chat and traces (SQL, request chips, result tables). No page and no + * config form yet; those slots can be added here later. + * + * Registration goes through `host` so the loader disposes it on hot + * reload / worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { createDatabaseTriggerRenderer } from './src/function-trigger-message' + +export default function setup(host: Host) { + host.functionTriggers.register(createDatabaseTriggerRenderer(host)) +} diff --git a/database/ui/src/function-trigger-message/index.tsx b/database/ui/src/function-trigger-message/index.tsx new file mode 100644 index 0000000000..155bf950c3 --- /dev/null +++ b/database/ui/src/function-trigger-message/index.tsx @@ -0,0 +1,367 @@ +/** + * Injected function-trigger message renderer for every `database::*` call — + * registered through `host.functionTriggers`, so it dispatches BEFORE the + * console's built-in families and overrides how database calls render in + * chat and in the traces span tab. + * + * Coverage is deliberately total (all 13 functions), grouped by response + * shape: query-like (rows table), execute-like (affected rows), tx-like + * (step list with failed-step marking), handles (prepare/begin/commit/ + * rollback), and listDatabases (config table). Error outputs return null + * and fall through to the console's built-in error card — it already + * renders the worker's `{code: "UNKNOWN_DB", available: [...]}` bodies + * legibly. Unknown/odd success shapes degrade to a JSON dump inside the + * card, never a throw. The card carries a small "database ui" tag so an + * override is distinguishable from first-party rendering at a glance. + */ + +import { + CodeHighlight, + type FunctionTriggerMessage, + type FunctionTriggerRenderer, + type Host, + JsonHighlight, +} from '@iii-dev/console-ui' +import { + DB_PREFIX, + type DbRequest, + asRecord, + asString, + cellText, + isErrorOutput, + parseExecuteResp, + parseListDatabases, + parseQueryResp, + parseRequest, + parseTxResp, + shortId, + unwrapEnvelope, +} from './parsers' + +const MAX_ROWS = 50 + +function Chip({ k, v, tone }: { k?: string; v: string; tone?: 'ok' | 'alert' }) { + return ( + + {k ? {k} : null} + {v} + + ) +} + +function RequestChips({ req }: { req: DbRequest }) { + return ( + <> + {req.db ? : null} + {req.transactionId ? : null} + {req.handleId ? : null} + {req.isolation ? : null} + + ) +} + +function CardShell({ + op, + running, + head, + children, +}: { + op: string + running?: boolean + head?: React.ReactNode + children?: React.ReactNode +}) { + return ( +
+
+ {op} + {head} + database ui +
+ {children} +
+ ) +} + +function SqlBlock({ req }: { req: DbRequest }) { + if (!req.sql) return null + return ( + <> +
+ +
+ {req.params ? ( +
+ params + {JSON.stringify(req.params)} +
+ ) : null} + + ) +} + +function RowsTable({ columns, rows }: { columns: string[]; rows: Record[] }) { + if (rows.length === 0 || columns.length === 0) return null + const shown = rows.slice(0, MAX_ROWS) + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {shown.map((row, i) => ( + + {columns.map((c) => { + const { text, isNull } = cellText(row[c]) + return ( + + ) + })} + + ))} + +
{c}
+ {text} +
+ {rows.length > MAX_ROWS ? ( +
+{rows.length - MAX_ROWS} more rows
+ ) : null} +
+ ) +} + +/** Last-resort body for a success shape we don't recognize. */ +function RawDetails({ details }: { details: unknown }) { + return ( +
+ +
+ ) +} + +function QueryView({ req, details }: { req: DbRequest; details: unknown }) { + const resp = parseQueryResp(details) + return ( + <> + + {resp ? ( + resp.rows.length === 0 ? ( +
· 0 rows
+ ) : ( + + ) + ) : ( + + )} + + ) +} + +function ExecuteView({ req, details }: { req: DbRequest; details: unknown }) { + const resp = parseExecuteResp(details) + return ( + <> + +
+ {resp.affectedRows !== undefined + ? `${resp.affectedRows} row${resp.affectedRows === 1 ? '' : 's'} affected` + : 'done'} + {resp.lastInsertId ? ` · last insert id ${resp.lastInsertId}` : ''} +
+ {resp.returnedRows.length > 0 ? ( + + ) : null} + + ) +} + +function TxView({ req, details }: { req: DbRequest; details: unknown }) { + const resp = parseTxResp(details) + const statements = req.statements ?? [] + const count = Math.max(statements.length, resp.steps.length) + return ( + <> + {count > 0 ? ( +
+ {Array.from({ length: count }, (_, i) => { + const failed = resp.failedIndex === i + const sql = statements[i]?.sql + const affected = resp.steps[i]?.affectedRows + return ( +
+ {i + 1} + {sql ?? '—'} + + {failed ? 'failed' : affected !== undefined ? `${affected} affected` : ''} + +
+ ) + })} +
+ ) : ( + + )} + + ) +} + +function ListDatabasesView({ details }: { details: unknown }) { + const dbs = parseListDatabases(details) + if (dbs.length === 0) return
· no databases configured
+ const rows = dbs.map((d) => ({ + name: d.name, + driver: d.driver, + url: d.url, + 'pool max': d.poolMax, + })) + return +} + +function HandleView({ details, label }: { details: unknown; label: string }) { + // PrepareResp{handle:{id,expires_at}} / BeginTxResp{transaction:{...}}. + const obj = asRecord(details) + const handle = asRecord(obj.handle ?? obj.transaction) + const id = asString(handle.id) ?? asString(obj.transaction_id) + const expires = asString(handle.expires_at) + return ( +
+ {label} + {id ? ` · ${id}` : ''} + {expires ? ` · expires ${expires}` : ''} +
+ ) +} + +function SettledView({ message }: { message: FunctionTriggerMessage }) { + const op = message.functionId.slice(DB_PREFIX.length) + const req = parseRequest(message.input) + const details = unwrapEnvelope(message.output) + + const body = (() => { + switch (op) { + case 'query': + case 'transactionQuery': + case 'runStatement': + return + case 'execute': + case 'transactionExecute': + return + case 'executeBatch': + case 'transaction': + return + case 'listDatabases': + return + case 'prepareStatement': + return ( + <> + + + + ) + case 'beginTransaction': + return + case 'commitTransaction': + return
committed
+ case 'rollbackTransaction': + return
rolled back
+ default: + // Anything else under database:: (e.g. on-config-change). + return + } + })() + + const tx = op === 'executeBatch' || op === 'transaction' ? parseTxResp(details) : undefined + return ( + + + {tx?.committed === true ? : null} + {tx?.committed === false ? : null} + + } + > + {body} + + ) +} + +function RunningView({ message }: { message: FunctionTriggerMessage }) { + const op = message.functionId.slice(DB_PREFIX.length) + const req = parseRequest(message.input) + return ( + }> + +
· running…
+
+ ) +} + +/** Pending-approval preview: the SQL a call is about to run. */ +function Preview({ message }: { message: FunctionTriggerMessage }) { + const op = message.functionId.slice(DB_PREFIX.length) + const req = parseRequest(message.input) + return ( + }> + + {req.statements ? ( +
+ {req.statements.map((s, i) => ( +
+ {i + 1} + {s.sql ?? '—'} +
+ ))} +
+ ) : null} +
+ ) +} + +function FunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith(DB_PREFIX)) { + return {functionId} + } + return ( + <> + {DB_PREFIX} + + {functionId.slice(DB_PREFIX.length)} + + + ) +} + +export function createDatabaseTriggerRenderer(host: Host): FunctionTriggerRenderer { + const render = ( + message: FunctionTriggerMessage, + running: boolean, + ): React.ReactNode | null => { + if (!message.functionId.startsWith(DB_PREFIX)) return null + if (message.pendingApproval) return null // host renders preview + approval bar + if (running) return + // Errors fall through to the console's built-in error card (it renders + // the worker's {code, ...} bodies, e.g. UNKNOWN_DB + available list). + if (isErrorOutput(message.output)) return null + return + } + return { + id: 'database/page.js#function-calls', + isMatch: (functionId) => functionId.startsWith(DB_PREFIX), + tryRender: (message) => render(message, !!message.running), + tryRenderRunning: (message) => render(message, true), + tryRenderPreview: (message) => + message.pendingApproval && + message.functionId.startsWith(DB_PREFIX) && + (parseRequest(message.input).sql || parseRequest(message.input).statements) + ? + : null, + FunctionIdLabel, + } +} diff --git a/database/ui/src/function-trigger-message/parsers.ts b/database/ui/src/function-trigger-message/parsers.ts new file mode 100644 index 0000000000..a2dd56a5fa --- /dev/null +++ b/database/ui/src/function-trigger-message/parsers.ts @@ -0,0 +1,163 @@ +/** + * Defensive parsing for database::* trigger messages. Every accessor + * tolerates absent/odd shapes and returns undefined rather than throwing — + * the console fences injected renderers, but a throw degrades the card to + * an error chip, and a raw-JSON fallback is strictly better. + */ + +export const DB_PREFIX = 'database::' + +/** `{ content: [...], details }` harness result envelope → details. */ +export function unwrapEnvelope(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + const obj = value as Record + if (Array.isArray(obj.content) && 'details' in obj) return obj.details + return value +} + +export function isErrorOutput(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + 'error' in (value as Record) + ) +} + +export function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +export function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +export interface DbRequest { + db?: string + sql?: string + params?: unknown[] + transactionId?: string + handleId?: string + isolation?: string + /** executeBatch: `string | {sql, params}` items; transaction: `{sql, params}`. */ + statements?: { sql?: string; params?: unknown[] }[] +} + +export function parseRequest(input: unknown): DbRequest { + const obj = asRecord(input) + const statements = asArray(obj.statements).map((s) => { + if (typeof s === 'string') return { sql: s } + const rec = asRecord(s) + return { sql: asString(rec.sql), params: Array.isArray(rec.params) ? rec.params : undefined } + }) + return { + db: asString(obj.db), + // `query` is an accepted alias for `sql` on query/execute. + sql: asString(obj.sql) ?? asString(obj.query), + params: Array.isArray(obj.params) && obj.params.length > 0 ? obj.params : undefined, + transactionId: asString(obj.transaction_id), + handleId: asString(obj.handle_id), + isolation: asString(obj.isolation), + statements: statements.length > 0 ? statements : undefined, + } +} + +/** QueryResp — rows are row-of-objects keyed by column name. */ +export interface QueryLike { + rows: Record[] + rowCount?: number + columns: string[] +} + +export function parseQueryResp(details: unknown): QueryLike | undefined { + const obj = asRecord(details) + if (!Array.isArray(obj.rows)) return undefined + const rows = obj.rows.map(asRecord) + const columns = asArray(obj.columns) + .map((c) => asString(asRecord(c).name)) + .filter((n): n is string => !!n) + // Fall back to the first row's keys when columns metadata is absent. + const cols = columns.length > 0 ? columns : Object.keys(rows[0] ?? {}) + return { rows, rowCount: asNumber(obj.row_count), columns: cols } +} + +/** ExecuteResp / TxExecuteResp. */ +export interface ExecuteLike { + affectedRows?: number + lastInsertId?: string + returnedRows: Record[] +} + +export function parseExecuteResp(details: unknown): ExecuteLike { + const obj = asRecord(details) + return { + affectedRows: asNumber(obj.affected_rows), + lastInsertId: asString(obj.last_insert_id), + returnedRows: asArray(obj.returned_rows).map(asRecord), + } +} + +/** TxResp — executeBatch and transaction share it. */ +export interface TxLike { + committed?: boolean + failedIndex?: number + steps: { affectedRows?: number }[] +} + +export function parseTxResp(details: unknown): TxLike { + const obj = asRecord(details) + return { + committed: typeof obj.committed === 'boolean' ? obj.committed : undefined, + failedIndex: asNumber(obj.failed_index), + steps: asArray(obj.results).map((r) => ({ + affectedRows: asNumber(asRecord(r).affected_rows), + })), + } +} + +export interface DatabaseInfoLike { + name?: string + driver?: string + url?: string + poolMax?: number +} + +export function parseListDatabases(details: unknown): DatabaseInfoLike[] { + const obj = asRecord(details) + return asArray(obj.databases).map((d) => { + const rec = asRecord(d) + return { + name: asString(rec.name), + driver: asString(rec.driver), + url: asString(rec.url), + poolMax: asNumber(asRecord(rec.pool).max), + } + }) +} + +/** Shorten opaque ids (uuids, tx ids) for chip display. */ +export function shortId(id: string): string { + return id.length > 13 ? `${id.slice(0, 13)}…` : id +} + +/** Compact single-line rendering of a table cell value. */ +export function cellText(v: unknown): { text: string; isNull: boolean } { + if (v === null || v === undefined) return { text: 'null', isNull: true } + if (typeof v === 'string') { + return { text: v.length > 200 ? `${v.slice(0, 200)}…` : v, isNull: false } + } + if (typeof v === 'object') { + const json = JSON.stringify(v) + return { text: json.length > 200 ? `${json.slice(0, 200)}…` : json, isNull: false } + } + return { text: String(v), isNull: false } +} diff --git a/database/ui/styles.css b/database/ui/styles.css new file mode 100644 index 0000000000..9774607d65 --- /dev/null +++ b/database/ui/styles.css @@ -0,0 +1,135 @@ +/* + * Styles for the database worker's injected console UI. Shipped as its own + * `console:style` asset (database/styles.css) — the console mounts it as a + * and link-swaps it on change. + * + * EVERY rule is scoped under [data-iii-ui="database"] — the wrapper the + * console mounts around every injected render. Unscoped rules would beat + * the console's layered CSS document-wide (injected sheets are unlayered). + * Keyframe names are global: keep the db-ui- prefix. Colors come from the + * console's design tokens so light/dark theming is free. + */ + +/* --- function-trigger message (chat / traces card) ------------------- */ +[data-iii-ui="database"] .db-ui-msg { + border-top: 1px solid var(--color-rule-2); + background: var(--color-bg); + font-family: var(--font-mono, ui-monospace, monospace); +} +[data-iii-ui="database"] .db-ui-msg-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + flex-wrap: wrap; +} +[data-iii-ui="database"] .db-ui-pill { + border: 1px solid var(--color-accent); + color: var(--color-accent); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 8px; +} +[data-iii-ui="database"] .db-ui-pill.quiet { + border-color: var(--color-rule); + color: var(--color-ink-faint); +} +[data-iii-ui="database"] .db-ui-chip { + border: 1px solid var(--color-rule); + color: var(--color-ink); + font-size: 11.5px; + padding: 1px 8px; +} +[data-iii-ui="database"] .db-ui-chip .k { color: var(--color-ink-ghost); } +[data-iii-ui="database"] .db-ui-chip.ok { border-color: var(--color-ok); color: var(--color-ok); } +[data-iii-ui="database"] .db-ui-chip.alert { border-color: var(--color-alert); color: var(--color-alert); } +[data-iii-ui="database"] .db-ui-msg-tag { + margin-left: auto; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="database"] .db-ui-msg-note { + padding: 0 12px 10px; + font-size: 12px; + color: var(--color-ink-faint); +} +[data-iii-ui="database"] .db-ui-msg-note.pulse { animation: db-ui-pulse 1.6s ease-in-out infinite; } +@keyframes db-ui-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* --- SQL block -------------------------------------------------------- */ +[data-iii-ui="database"] .db-ui-sql { + padding: 0 12px 8px; + font-size: 12px; + overflow-x: auto; +} +[data-iii-ui="database"] .db-ui-params { + padding: 0 12px 8px; + font-size: 11.5px; + color: var(--color-ink-faint); + overflow-x: auto; +} +[data-iii-ui="database"] .db-ui-params .k { color: var(--color-ink-ghost); } + +/* --- result rows table ------------------------------------------------ */ +[data-iii-ui="database"] .db-ui-table-wrap { + padding: 0 12px 10px; + overflow-x: auto; +} +[data-iii-ui="database"] .db-ui-table { + border-collapse: collapse; + font-size: 12px; + width: 100%; +} +[data-iii-ui="database"] .db-ui-table th { + text-align: left; + font-weight: 500; + color: var(--color-ink-faint); + border-bottom: 1px solid var(--color-rule); + padding: 3px 10px 3px 0; + white-space: nowrap; +} +[data-iii-ui="database"] .db-ui-table td { + color: var(--color-ink); + border-bottom: 1px solid var(--color-rule-2); + padding: 3px 10px 3px 0; + vertical-align: top; + max-width: 380px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="database"] .db-ui-table td.null { color: var(--color-ink-ghost); font-style: italic; } + +/* --- batch / transaction step list ------------------------------------ */ +[data-iii-ui="database"] .db-ui-steps { + padding: 0 12px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="database"] .db-ui-step { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 12px; +} +[data-iii-ui="database"] .db-ui-step .idx { + color: var(--color-ink-ghost); + min-width: 1.5em; + text-align: right; +} +[data-iii-ui="database"] .db-ui-step .sql { + color: var(--color-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="database"] .db-ui-step .meta { color: var(--color-ink-faint); white-space: nowrap; } +[data-iii-ui="database"] .db-ui-step.failed .idx, +[data-iii-ui="database"] .db-ui-step.failed .sql { color: var(--color-alert); } diff --git a/database/ui/tsconfig.json b/database/ui/tsconfig.json new file mode 100644 index 0000000000..e5ac605406 --- /dev/null +++ b/database/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed1305fe26..9a958c754e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,22 @@ importers: specifier: ^4.1.6 version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) + database/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages/console-ui: devDependencies: '@types/react': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6744f3a439..95030cb072 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - packages/* - console/web - console/ui + - database/ui - state/ui # pnpm ≥11 blocks dependency postinstall scripts by default; esbuild's From 47913a81b5c49d6ddcd7fb1dadd2a810332411a2 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 23 Jul 2026 17:01:15 -0300 Subject: [PATCH 3/4] fix(ci): install pnpm in the database e2e job for the build.rs UI build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit database/build.rs now shells out to pnpm to build ui/dist before embedding it; the e2e job installed Node but not pnpm, so the cargo build died in locate_pnpm() (build.rs:168). ci.yml's rust job already sets up pnpm via the hashFiles(ui/package.json) condition, and the release path auto-detects ui/ (web_bundle) — this was the one job missing the toolchain. --- .github/workflows/database-e2e.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/database-e2e.yml b/.github/workflows/database-e2e.yml index 223dc471d6..7f46adce0b 100644 --- a/.github/workflows/database-e2e.yml +++ b/.github/workflows/database-e2e.yml @@ -33,6 +33,13 @@ jobs: with: workspaces: database + # database/build.rs shells out to pnpm to build the injected console + # UI (ui/dist) before embedding it via include_str! — mirrors the + # ci.yml rust job. Version comes from the repo-root package.json + # `packageManager` field (the UI pnpm workspace root). + - name: Setup pnpm (for build.rs UI build) + uses: pnpm/action-setup@v4 + - name: Install Node.js uses: actions/setup-node@v4 with: From 0f497c6b2fa3d47c4d342f8baed360cd203c3b3a Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 23 Jul 2026 17:05:32 -0300 Subject: [PATCH 4/4] fix(ci): bump database e2e job to Node 22 for pnpm's node:sqlite dependency pnpm@11 (repo packageManager) needs the node:sqlite builtin (Node >=22.5) for its store; the job pinned Node 20 so pnpm install died with ERR_UNKNOWN_BUILTIN_MODULE inside build.rs. ci.yml's pnpm jobs already standardize on 22; the e2e harness has no engines pin (only a @types/node ^20 dev dependency, which is types-only). --- .github/workflows/database-e2e.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/database-e2e.yml b/.github/workflows/database-e2e.yml index 7f46adce0b..4052410bad 100644 --- a/.github/workflows/database-e2e.yml +++ b/.github/workflows/database-e2e.yml @@ -43,7 +43,9 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: '20' + # 22, not 20: pnpm@11 requires the node:sqlite builtin (Node ≥22.5) + # for its store, and ci.yml's pnpm jobs already standardize on 22. + node-version: '22' cache: 'npm' cache-dependency-path: database/tests/e2e/workers/harness/package-lock.json