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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions database/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions database/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
172 changes: 172 additions & 0 deletions database/build.rs
Original file line number Diff line number Diff line change
@@ -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`"
);
}
1 change: 1 addition & 0 deletions database/src/driver/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }));
}
Expand Down
19 changes: 16 additions & 3 deletions database/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> },

#[serde(rename = "INVALID_PARAM")]
#[error("invalid parameter at index {index}: {reason}")]
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 11 additions & 6 deletions database/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,16 @@ pub struct AppState {

impl AppState {
pub async fn pool(&self, db: &str) -> Result<Pool, DbError> {
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<String> = pools.keys().cloned().collect();
available.sort();
DbError::UnknownDb {
db: db.to_string(),
available,
}
})
}
}
3 changes: 3 additions & 0 deletions database/src/handlers/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
5 changes: 4 additions & 1 deletion database/src/handlers/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions database/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions database/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
);
Expand Down
Loading
Loading