Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
38 changes: 36 additions & 2 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@

| field | value |
|-------|-------|
| version | 1.0.0 |
| type | binary |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu |
| author | iii |

## Install

```sh
iii worker add database@1.0.0
iii worker add database
```

## Skills
Expand Down Expand Up @@ -193,6 +192,41 @@ const { rows } = await iii.trigger({
| `database::rollbackTransaction` | Rollback and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. |
| `database::listDatabases` | List configured databases. Returns `{ databases, count }`; each entry has `name`, `driver`, credential-redacted `url`, `pool` settings, and `tls` (`mode`, `ca_cert_present`, `trust_native`). Config only — no health checks or live pool stats. |

### Reading the schema

| Function | Description |
|---|---|
| `database::listTables` | Every table and view, with its kind and (postgres) its schema. |
| `database::describeTable` | One table: columns with type, nullability, default, primary-key membership and foreign-key target; plus indexes and a planner row estimate. Foreign keys are structured `{ schema, table, column }`, not a joined string. |
| `database::describeSchema` | The same shape for every table at once. One catalog query per aspect across the whole database rather than one call per table, so a 200-table schema costs a handful of queries. `include_indexes` is off by default. |
| `database::schemaDiagram` | Positioned table nodes and routed foreign-key edges, plus each table's hub `degree`, the `isolated` tables, and remaining edge `crossings`. Layout runs server-side, so a renderer only draws. |

### Reading data

| Function | Description |
|---|---|
| `database::browseTable` | Paged, sorted, filtered table read — no SQL from the caller. Filters are structured (`{ column, op, value }`) and compile to a parameterised `WHERE` for the driver in hand; `total` honours the same filters. Sorts accept a `mode` (`natural`, `length`, `absolute_value`, `random`) applied across the whole table, not just the page. To follow a foreign key, filter on equality with `page_size: 1`. |
| `database::explain` | The query plan as a tree with per-node cost, row estimates and warnings, instead of the driver's raw text. `analyze` collects real timings by **running** the statement, so it defaults to `false` and is refused for anything that is not a single read. |
| `database::columnStats` | Profile a table's columns. Reads the planner's own statistics by default — free and approximate, labelled `source: planner`. `exact: true` runs real aggregates and scans the table; it is refused above a row-count ceiling. To profile rows you already hold, pipe a `browseTable` result through the `fp` worker instead. |

### Operations

| Function | Description |
|---|---|
| `database::health` | Live pool occupancy plus active queries, table sizes, blocking locks and cache hit ratio. Each section reports separately as `available`, `unsupported` or `denied`, so a driver gap or a restricted role is never mistaken for an empty result. |
| `database::terminateQuery` | Terminate a backend session, or cancel just its statement with `cancel_only`. Takes an id from `health`. Separate from `health` because it is a write. |

### Saved queries and history

Stored in the [`state`](https://github.com/iii-hq/workers/tree/main/state) worker, scoped per database, so they survive restarts and any agent can read them.

| Function | Description |
|---|---|
| `database::saveQuery` | Save a named query. Saving under an existing name replaces it. |
| `database::listSavedQueries` | Saved queries for a database, sorted by name. |
| `database::deleteSavedQuery` | Delete by id or by name. |
| `database::history` | Recent queries, newest first. Best effort — recording never blocks or fails a query, so this is a convenience rather than an audit log. For an audit trail bind `database::row-changed`. |

## Triggers

### `database::row-changed`
Expand Down
46 changes: 43 additions & 3 deletions database/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,54 @@ point. Placeholder syntax: `?` for SQLite and MySQL, `$1`/`$2`/… for Postgres.
transaction.
- `database::rollbackTransaction` — roll back and finalize an interactive
transaction.
- `database::listDatabases` — list every configured database with its
driver, credential-redacted connection URL, pool settings, and TLS mode.
Config details only; no health checks or live pool statistics.
- `database::listDatabases` — every configured database with its driver,
credential-redacted URL, pool settings and TLS mode. Config only; use
`database::health` for live state.

Interactive transactions auto-roll back when `timeout_ms` elapses (default
30 s, max 5 min). Prepared handles default to a 1 h TTL (max 24 h) with no
explicit release call — let them expire or stop using them when done.

### Reading the schema

One shape across all three drivers — prefer these over hand-writing
`sqlite_master` / `information_schema` / `PRAGMA`.

- `database::listTables` — tables and views, with kind and (postgres) schema.
- `database::describeTable` — columns with type, nullability, default, primary
key and a structured `foreign_key` of `{ schema, table, column }`; plus
indexes and a planner row estimate.
- `database::describeSchema` — the same for every table in one pass. Use this
rather than looping `describeTable`.
- `database::schemaDiagram` — positioned nodes, routed foreign-key edges, hub
`degree` and `isolated` tables. For reasoning about a schema's shape, not
only for drawing it.

### Reading data

- `database::browseTable` — paged, sorted, filtered reads with no SQL. Filters
are `{ column, op, value }` and `total` honours them. Follow a foreign key
with an equality filter at `page_size: 1`.
- `database::explain` — the plan as a tree with costs and warnings. `analyze`
**runs** the statement, so it defaults to false and is refused for anything
that is not a single read.
- `database::columnStats` — planner statistics by default (approximate,
labelled `source: planner`); `exact: true` scans. To profile rows you already
hold, use the `fp` worker on a `browseTable` result instead.

### Operations and reuse

- `database::health` — pool occupancy, active queries, table sizes, locks,
cache ratio. Each section is `available`, `unsupported` or `denied`, so a
driver gap is never mistaken for an empty result.
- `database::terminateQuery` — end a session, or cancel its statement with
`cancel_only`. Takes an id from `database::health`.
- `database::saveQuery`, `database::listSavedQueries`,
`database::deleteSavedQuery` — named queries per database, kept in the
`state` worker.
- `database::history` — recent queries, newest first. Best effort, not an audit
log; bind `database::row-changed` for that.

## Reacting to writes

Register a `database::row-changed` trigger to be told when this worker commits
Expand Down
165 changes: 165 additions & 0 deletions database/src/handlers/browse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//! `database::browseTable` — paged, sorted, filtered reads without the caller
//! writing SQL.
//!
//! Subsumes three things the console used to do by hand: building
//! `SELECT * … ORDER BY … LIMIT/OFFSET`, running a matching `COUNT(*)`, and
//! compiling filter chips into a `WHERE`. It also covers foreign-key lookup —
//! that is an equality filter at `page_size: 1` — so there is no separate
//! read-a-row function.

use super::filter::{self, FilterSpec, SortSpec};
use super::query::{self, err_to_str, QueryReq};
use super::AppState;
use crate::config::DriverKind;
use crate::driver::ColumnMeta;
use crate::error::DbError;
use crate::pool::Pool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;

fn default_page_size() -> u32 {
50
}

fn default_timeout() -> u64 {
30_000
}

fn default_include_total() -> bool {
true
}

/// Ceiling on a single page. A caller wanting everything should page.
const MAX_PAGE_SIZE: u32 = 1_000;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct BrowseTableReq {
#[serde(default)]
pub db: Option<String>,
pub table: String,
#[serde(default)]
pub schema: Option<String>,
/// Zero-based.
#[serde(default)]
pub page: u32,
#[serde(default = "default_page_size")]
pub page_size: u32,
/// Applied in order; sort priority is position in the list.
#[serde(default)]
pub sort: Vec<SortSpec>,
/// Combined with AND.
#[serde(default)]
pub filters: Vec<FilterSpec>,
/// A filtered `COUNT(*)` is a second query and can be expensive on a
/// large table. Turn it off while the caller is still typing.
#[serde(default = "default_include_total")]
pub include_total: bool,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct BrowseTableResp {
pub rows: Vec<serde_json::Map<String, Value>>,
pub columns: Vec<ColumnMeta>,
pub page: u32,
pub page_size: u32,
/// Derived from a sentinel row, so it is correct without a count.
pub has_more: bool,
/// Total matching the same filters. Absent when not requested.
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<i64>,
}

async fn driver_of(state: &AppState, db: &str) -> Result<DriverKind, String> {
Ok(match state.pool(db).await.map_err(err_to_str)? {
Pool::Sqlite(_) => DriverKind::Sqlite,
Pool::Postgres(_) => DriverKind::Postgres,
Pool::Mysql(_) => DriverKind::Mysql,
})
}

pub async fn handle(state: &AppState, req: BrowseTableReq) -> Result<BrowseTableResp, String> {
if req.table.trim().is_empty() {
return Err(err_to_str(DbError::InvalidParam {
index: 0,
reason: "table is required".into(),
}));
}
let db = state.resolve_db(req.db.clone()).await.map_err(err_to_str)?;
let driver = driver_of(state, &db).await?;

let page_size = req.page_size.clamp(1, MAX_PAGE_SIZE);
let offset = (req.page as u64) * (page_size as u64);

let target = filter::quote_table(driver, req.schema.as_deref(), &req.table);
let where_clause = filter::compile_where(driver, &req.filters, 1).map_err(err_to_str)?;
let predicate = if where_clause.sql.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clause.sql)
};

let order = filter::compile_order_by(driver, &req.sort).map_err(err_to_str)?;
let order_by = if order.is_empty() {
String::new()
} else {
format!(" ORDER BY {order}")
};

// Fetch one extra row to learn whether another page exists, so `has_more`
// is right even when the caller skipped the count.
let limit = page_size as u64 + 1;
let sql = format!("SELECT * FROM {target}{predicate}{order_by} LIMIT {limit} OFFSET {offset}");

let mut resp = query::handle(
state,
QueryReq {
db: Some(db.clone()),
sql,
params: where_clause.params.clone(),
timeout_ms: req.timeout_ms,
record_history: false,
},
)
.await?;

let has_more = resp.rows.len() as u64 > page_size as u64;
resp.rows.truncate(page_size as usize);

let total = if req.include_total {
let count_sql = format!("SELECT COUNT(*) AS total FROM {target}{predicate}");
let counted = query::handle(
state,
QueryReq {
db: Some(db),
sql: count_sql,
params: where_clause.params,
timeout_ms: req.timeout_ms,
record_history: false,
},
)
.await?;
counted
.rows
.first()
.and_then(|r| r.get("total"))
.and_then(|v| match v {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.parse().ok(),
_ => None,
})
} else {
None
};

Ok(BrowseTableResp {
rows: resp.rows,
columns: resp.columns,
page: req.page,
page_size,
has_more,
total,
})
}
Loading
Loading