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
5 changes: 3 additions & 2 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,9 @@ for await (const event of db.watch()) { ... }

`dirsql server --frontend` is a shared Rust CLI option available through the
Rust binary and the Python and TypeScript launchers. It serves the embedded
SQL explorer shell at `/` on the API's origin and exposes its load diagnostic
through `GET /frontend/state` (HTTP 503 when unavailable). The page needs no
SQL explorer shell at `/` on the API's origin and exposes its load diagnostic,
query root, and declared tables and columns through `GET /frontend/state`
(HTTP 503 when unavailable). The page needs no
runtime asset download; the option adds no SDK binding API. Without the flag,
the existing HTTP routes are unchanged. Query controls and distribution
validation are tracked in the subsequent frontend issues.
Expand Down
30 changes: 26 additions & 4 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,32 @@ dirsql server --frontend
dirsql server --frontend -c .dirsql.toml
```

The shell reads `GET /frontend/state`: `{"error":null}` when the index is
ready, or HTTP `503` with `{"error":"<diagnostic>"}` when config loading
fails. Fix the config and restart the server. Without `--frontend`, the `/`
and `/frontend/state` routes return `404`.
The shell reads `GET /frontend/state`. When the index is ready it returns
HTTP `200`:

```json
{
"error": null,
"root": "/abs/path/of/the/invocation/directory",
"tables": [
{"name": "posts", "columns": [{"name": "basename", "type": "TEXT"}]}
]
}
```

`root` is the query root, where [path-tables](./path-tables.md) resolve.
`tables` lists the declared tables, ordered by name, each with its columns in
declaration order and the declared SQLite type (`""` when untyped). Empty
tables are listed. A configless server, or a config with only functions or
extensions, returns `"tables": []`. dirsql's bookkeeping tables, SQLite's own
tables, virtual tables with their shadow tables, and path-tables are never
listed. The metadata is read from SQLite's catalog: no file is parsed, no
directory walked, no table counted.

When config loading fails the endpoint returns HTTP `503` with
`{"error":"<diagnostic>"}` and no `root` or `tables`. Fix the config and
restart the server. Without `--frontend`, the `/` and `/frontend/state`
routes return `404`. `POST /query` is unchanged.

### Flags

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/cli_version_test.py tests/e2e/query_contract_test.py tests/e2e/declared_table_name_test.py tests/e2e/on_file_query_test.py -x -q",
"ran_at": 1791764712,
"exit_code": 0,
"commit": "ae61a26f7b9018c3e12aebeea7c6a3cf32ef4d87",
"branch": "bot/1488-frontend-metadata-1011"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Added** `GET /frontend/state` (with `dirsql server --frontend`) reports the query root and the declared tables with their columns, plus `DirSQL::root()` and `DirSQL::tables()` in the Rust core.
21 changes: 21 additions & 0 deletions packages/rust/migrations.d/2026-10-11-frontend-state-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Frontend state reports the query root and declared tables

#### Summary

The ready response of `GET /frontend/state` (served only with `dirsql server --frontend`) gains `root` and `tables` next to `error`. The Rust core gains `DirSQL::root()` and `DirSQL::tables()`. Nothing breaks.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

A ready `GET /frontend/state` body is now `{"error": null, "root": "...", "tables": [...]}` rather than `{"error": null}`. `POST /query` is unchanged.

#### Verification

Run `dirsql server --frontend --port 0`, then `curl <printed URL>frontend/state`. The body lists `root` and `tables`.
37 changes: 36 additions & 1 deletion packages/rust/src/cli/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use tokio_stream::wrappers::BroadcastStream;

use super::AppState;
use super::execute::{QueryFailure, execute_query, require_ready};
use crate::TableInfo;

pub(super) struct AppContext {
pub state: AppState,
Expand Down Expand Up @@ -53,7 +54,15 @@ pub(super) fn router(ctx: SharedCtx, frontend: bool) -> Router {

async fn handle_frontend_state(State(ctx): State<SharedCtx>) -> Response {
match &ctx.state {
AppState::Ready(_) => Json(json!({ "error": null })).into_response(),
AppState::Ready(db) => match db.tables() {
Ok(tables) => Json(json!({
"error": null,
"root": db.root().to_string_lossy(),
"tables": tables.iter().map(table_json).collect::<Vec<_>>(),
}))
.into_response(),
Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
},
AppState::Unavailable(reason) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": reason })),
Expand All @@ -62,6 +71,17 @@ async fn handle_frontend_state(State(ctx): State<SharedCtx>) -> Response {
}
}

fn table_json(table: &TableInfo) -> serde_json::Value {
json!({
"name": table.name,
"columns": table
.columns
.iter()
.map(|c| json!({ "name": c.name, "type": c.sql_type }))
.collect::<Vec<_>>(),
})
}

async fn handle_query(State(ctx): State<SharedCtx>, body: String) -> Response {
match execute_query(&ctx.state, body, Some(ctx.query_timeout)).await {
Ok(value) => Json(value).into_response(),
Expand Down Expand Up @@ -215,6 +235,21 @@ mod tests {
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[test]
fn table_json_names_each_column_with_its_type() {
let table = TableInfo {
name: "t".into(),
columns: vec![crate::ColumnInfo {
name: "a".into(),
sql_type: "TEXT".into(),
}],
};
assert_eq!(
table_json(&table),
json!({"name": "t", "columns": [{"name": "a", "type": "TEXT"}]})
);
}

#[test]
fn error_response_sets_json_content_type() {
let resp = error_response(StatusCode::BAD_REQUEST, "boom");
Expand Down
112 changes: 112 additions & 0 deletions packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,27 @@ pub struct QueryResult {
pub rows: Vec<HashMap<String, Value>>,
}

/// A column of a user table as SQLite's catalog reports it.
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnInfo {
pub name: String,
pub sql_type: String,
}

/// A user table and its columns in declaration order.
#[derive(Debug, Clone, PartialEq)]
pub struct TableInfo {
pub name: String,
pub columns: Vec<ColumnInfo>,
}

const USER_TABLES: &str = "SELECT name FROM pragma_table_list WHERE schema = 'main' \
AND type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' \
AND name NOT LIKE '\\_dirsql\\_%' ESCAPE '\\' ORDER BY name";

const TABLE_COLUMNS: &str = "SELECT name, type FROM pragma_table_xinfo(?1, 'main') \
WHERE hidden <> 1 ORDER BY cid";

pub struct Db {
conn: Connection,
/// Where this connection's path tables hold their rows for the statement in
Expand Down Expand Up @@ -553,6 +574,34 @@ impl Db {
Ok(columns)
}

/// The plain tables in the persistent schema with their columns, read from
/// SQLite's catalog alone: no table is scanned and no virtual table is
/// opened. dirsql's bookkeeping tables, `sqlite_` tables, virtual tables
/// (and their shadow tables) and the per-connection path-tables are left
/// out.
pub fn user_tables(&self) -> Result<Vec<TableInfo>> {
let names = self
.conn
.prepare(USER_TABLES)?
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut columns = self.conn.prepare(TABLE_COLUMNS)?;
names
.into_iter()
.map(|name| {
let columns = columns
.query_map([&name], |row| {
Ok(ColumnInfo {
name: row.get(0)?,
sql_type: row.get(1)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(TableInfo { name, columns })
})
.collect()
}

/// Normalize a row to match the table schema.
///
/// In relaxed mode (strict=false): extra keys are dropped, missing keys become NULL.
Expand Down Expand Up @@ -1860,6 +1909,69 @@ mod tests {
assert_eq!(results[1]["name"], Value::Text("cherry".into()));
}

#[test]
fn user_tables_lists_plain_tables_with_columns_in_name_order() {
let db = Db::new().unwrap();
db.create_table("zeta", "CREATE TABLE zeta (a TEXT, \"two words\" INTEGER)")
.unwrap();
db.create_table("_alpha", "CREATE TABLE _alpha (x)")
.unwrap();
let col = |name: &str, sql_type: &str| ColumnInfo {
name: name.into(),
sql_type: sql_type.into(),
};
assert_eq!(
db.user_tables().unwrap(),
vec![
TableInfo {
name: "_alpha".into(),
columns: vec![col("x", "")],
},
TableInfo {
name: "zeta".into(),
columns: vec![col("a", "TEXT"), col("two words", "INTEGER")],
},
]
);
}

#[test]
fn user_tables_is_empty_for_a_fresh_db() {
assert_eq!(Db::new().unwrap().user_tables().unwrap(), vec![]);
}

#[test]
fn user_tables_skips_virtual_shadow_sqlite_and_temp_tables() {
let db = Db::new().unwrap();
db.create_table(
"notes",
"CREATE TABLE notes (body TEXT); \
CREATE VIRTUAL TABLE notes_fts USING fts5(body); \
CREATE TABLE seq (id INTEGER PRIMARY KEY AUTOINCREMENT); \
CREATE TEMP TABLE scratch (y);",
)
.unwrap();
let names: Vec<String> = db
.user_tables()
.unwrap()
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, vec!["notes", "seq"]);
}

#[test]
fn user_tables_keeps_generated_columns() {
let db = Db::new().unwrap();
db.create_table(
"g",
"CREATE TABLE g (a INTEGER, b INTEGER GENERATED ALWAYS AS (a + 1) VIRTUAL)",
)
.unwrap();
let columns = db.user_tables().unwrap().remove(0).columns;
assert_eq!(columns.len(), 2);
}

#[test]
fn get_table_columns_returns_user_columns_only() {
let db = Db::new().unwrap();
Expand Down
34 changes: 33 additions & 1 deletion packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ use std::time::Duration;
use thiserror::Error;

pub use crate::config::ExtensionSpec as Extension;
pub use crate::db::{DbError, QueryResult, Value};
pub use crate::db::{ColumnInfo, DbError, QueryResult, TableInfo, Value};
pub use crate::differ::RowEvent;
#[doc(hidden)]
pub use crate::row_event_flat::{FlatRowEvent, flatten_row_event};
Expand Down Expand Up @@ -558,6 +558,18 @@ impl DirSQL {
db.query_ordered(sql).map_err(map_db_error)
}

/// The directory the index is rooted at: where path-tables resolve.
pub fn root(&self) -> &Path {
&self.inner.root
}

/// The tables a query can read by name, with their columns, straight from
/// SQLite's catalog: empty tables included, no file read or table scanned.
pub fn tables(&self) -> Result<Vec<TableInfo>> {
let db = self.inner.db.lock().map_err(DirSqlError::lock)?;
db.user_tables().map_err(map_db_error)
}

/// [`query_ordered`](Self::query_ordered) folding each row's cells, in
/// projection order, into the state `start` makes from the projection,
/// as they are read rather than collected first.
Expand Down Expand Up @@ -4284,6 +4296,26 @@ mod internal_tests {
assert!(dbg.contains("boom"), "got: {dbg}");
}

#[test]
fn tables_lists_declared_tables_and_root_reports_the_index_root() {
let dir = TempDir::new().unwrap();
let db = DirSQL::builder()
.root(dir.path())
.table(Table::new(
"b",
"CREATE TABLE b (y TEXT)",
"*.b",
|_| vec![],
))
.build()
.unwrap();
let tables = db.tables().unwrap();
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].name, "b");
assert_eq!(tables[0].columns[0].name, "y");
assert_eq!(db.root(), dir.path());
}

#[test]
fn builder_setters_configure_and_build() {
let dir = TempDir::new().unwrap();
Expand Down
Loading
Loading