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
33 changes: 33 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Windows qualification
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
native:
runs-on: windows-2025
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.99.0
- uses: oven-sh/setup-bun@v2
- uses: ilammy/msvc-dev-cmd@v1
- name: Allow symlink security fixtures
shell: pwsh
run: |
New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' -Name AllowDevelopmentWithoutDevLicense -Value 1 -PropertyType DWord -Force
- name: Install build cache in the disposable runner
shell: pwsh
run: |
cargo install mbx --version 1.27.0 --locked --root "$env:RUNNER_TEMP/mbx"
if ($LASTEXITCODE) { exit $LASTEXITCODE }
"$env:RUNNER_TEMP/mbx/bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Rust contracts and Windows filesystem security
shell: pwsh
run: |
mbx test --manifest-path http/Cargo.toml --locked -- --test-threads=1
if ($LASTEXITCODE) { exit $LASTEXITCODE }
- name: Build native extension and exercise HTTP bridge
shell: pwsh
run: ./scripts/qualify-windows.ps1
13 changes: 9 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ set(SIDEQUERY_HTTP_LOCK ${CMAKE_CURRENT_SOURCE_DIR}/http/Cargo.lock)
file(GLOB_RECURSE SIDEQUERY_HTTP_SOURCES CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/http/src/*.rs)
set(SIDEQUERY_HTTP_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/http-cargo)
if(NOT UNIX)
message(FATAL_ERROR "SQLNB's local notebook server currently supports macOS and Linux")
if(MSVC)
set(SIDEQUERY_HTTP_LIBRARY ${SIDEQUERY_HTTP_TARGET_DIR}/release/sidequery_http.lib)
# DuckDB uses the static MSVC runtime; Rust and its C dependencies must agree.
set(SIDEQUERY_RUST_ENV "RUSTFLAGS=$ENV{RUSTFLAGS} -C target-feature=+crt-static")
else()
set(SIDEQUERY_HTTP_LIBRARY ${SIDEQUERY_HTTP_TARGET_DIR}/release/libsidequery_http.a)
endif()
set(SIDEQUERY_HTTP_LIBRARY ${SIDEQUERY_HTTP_TARGET_DIR}/release/libsidequery_http.a)

set(SIDEQUERY_REPO_MBX ${CMAKE_CURRENT_SOURCE_DIR}/scripts/mbx)
if(EXISTS ${SIDEQUERY_REPO_MBX})
Expand All @@ -46,7 +49,7 @@ endif()

add_custom_command(
OUTPUT ${SIDEQUERY_HTTP_LIBRARY}
COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${SIDEQUERY_HTTP_TARGET_DIR}
COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${SIDEQUERY_HTTP_TARGET_DIR} ${SIDEQUERY_RUST_ENV}
${SIDEQUERY_CARGO_BUILD} --manifest-path ${SIDEQUERY_HTTP_MANIFEST} --locked --release
DEPENDS ${SIDEQUERY_HTTP_MANIFEST} ${SIDEQUERY_HTTP_LOCK} ${SIDEQUERY_HTTP_SOURCES}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/http
Expand All @@ -67,6 +70,8 @@ foreach(target ${EXTENSION_NAME} ${LOADABLE_EXTENSION_NAME})
target_link_libraries(${target} iconv)
elseif(UNIX)
target_link_libraries(${target} rt util m)
elseif(WIN32)
target_link_libraries(${target} ws2_32 userenv bcrypt ntdll crypt32 secur32 shell32 advapi32 winhttp rpcrt4 ole32)
endif()
endforeach()

Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,50 @@ For local UI development, use `assets := '/absolute/path/to/assets'`; local asse

The authenticated `GET /api/v1/recent-notebook` endpoint returns `{ "path": "relative.sqlnb" }`, or `{ "path": null }` when no readable recent notebook is available. The UI records a successful open or save using `PUT` to the same endpoint with `{ "path": "relative.sqlnb" }`. The path must identify an existing notebook within the configured root. This preference survives server restarts and port changes and is isolated by canonical notebook root under the OS cache's `sidequery/sqlnb-extension/notebooks` directory. It contains only the relative path; unavailable or corrupt preferences do not affect normal notebook file operations.

## DuckDB UI notebooks

The local DuckDB UI launched by `duckdb -ui` stores its notebooks in a DuckDB
database at `~/.duckdb/extension_data/ui/ui.db`. SQLNB exposes existing notebooks
as a second live source alongside local SQLNB files. Opening a notebook does not
execute it; saving edits writes a new version to that same database.

The browser uses three native SQL functions, each returning a JSON string:

```sql
select sqlnb_duckdb_ui_notebooks();
select sqlnb_duckdb_ui_notebook('notebook-uuid');
select sqlnb_duckdb_ui_save('notebook-uuid', expected_entity_version, 'Title', 'edited notebook JSON');
```

Reads return the official ID, entity version, title and stored notebook JSON.
Listing omits the JSON bodies. Saves require serialization format 3 and both the
expected entity version and unchanged content version. An accepted save advances
both versions and expires the preceding current revision in seven days, matching
the official UI. Conflicts and malformed edits roll back. Notebook identity,
creation time, internal name, current-notebook preference, unknown JSON fields
and historical revisions remain intact. Browsing never creates or migrates a
database, and `enable_external_access` must be enabled.

An official UI catalog already attached to the host instance takes precedence;
otherwise each operation opens the existing default database for its duration.
Normal DuckDB locks apply. A separate process holding a write lock prevents
access; stop that process or run both UIs in the same DuckDB instance. No lock
bypass or database copy is used. The current adapter supports editing existing
notebooks, not creating or deleting notebook records.

The frontend owns conversion through the canonical Rust SQLNB parser and
serializer. Cell IDs, database choices, grouping, comments and SQLNB directives
survive saves. Features the official UI does not implement remain SQLNB source
comments, with a compatibility notice in SQLNB. The official UI must reopen or
refresh a notebook to see saved edits. SQLNB refreshes clean documents and retains
dirty drafts when another editor changes the source.

The native source smoke uses an isolated home directory and database:

```sh
bun scripts/duckdb-ui-smoke.mjs /absolute/path/to/duckdb /absolute/path/to/sqlnb.duckdb_extension
```

## Deployment contract

The stable base URL serves `manifest.json` with `version` (7–64 lowercase hexadecimal characters), `bridgeVersion: 1`, an optional relative `assetBase` directory, and a sorted `files` array of `{path, sha256, size}`. File paths are relative to the asset base and include `index.html`. Publish the manifest together with its complete immutable generation. Incompatible bridge versions are rejected; capabilities beyond the native bridge require an extension update.
Expand Down
1 change: 1 addition & 0 deletions http/Cargo.lock

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

3 changes: 3 additions & 0 deletions http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ uuid = { version = "1", features = ["v4"] }
rmcp = { version = "=3.4.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
tokio-util = "0.7"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }

[dev-dependencies]
tempfile = "3"
19 changes: 14 additions & 5 deletions http/src/cache_location.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#[cfg(any(target_os = "linux", test))]
#[cfg(any(target_os = "linux", all(test, unix)))]
use std::ffi::OsStr;
use std::path::PathBuf;
use url::Url;
Expand All @@ -10,10 +10,9 @@ pub(super) fn asset_cache_scope(remote: &Url) -> Result<PathBuf, String> {
}

pub(super) fn notebook_state_path(root: &std::path::Path) -> Result<PathBuf, String> {
use std::os::unix::ffi::OsStrExt;
Ok(cache_root()?
.join("sidequery/sqlnb-extension/notebooks")
.join(crate::revision(root.as_os_str().as_bytes()))
.join(crate::revision(root.as_os_str().as_encoded_bytes()))
.join("recent-notebook.json"))
}

Expand All @@ -27,10 +26,12 @@ fn cache_root() -> Result<PathBuf, String> {
let root = linux_cache_root(std::env::var_os("XDG_CACHE_HOME").as_deref(), || {
std::env::var_os("HOME")
})?;
#[cfg(windows)]
let root = windows_local_data()?.join("Cache");
Ok(root)
}

#[cfg(any(target_os = "linux", test))]
#[cfg(any(target_os = "linux", all(test, unix)))]
fn linux_cache_root(
xdg_cache_home: Option<&OsStr>,
home: impl FnOnce() -> Option<std::ffi::OsString>,
Expand All @@ -49,7 +50,7 @@ fn linux_cache_root(
Ok(PathBuf::from(home).join(".cache"))
}

#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;

Expand Down Expand Up @@ -86,3 +87,11 @@ mod tests {
}
}
}

#[cfg(windows)]
pub(super) fn windows_local_data() -> Result<PathBuf, String> {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or_else(|| "LOCALAPPDATA is unavailable or not absolute".into())
}
180 changes: 180 additions & 0 deletions http/src/duckdb_ui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! The local DuckDB UI's stored notebook format. Keep unknown metadata intact.
use serde_json::Value;
use std::collections::HashSet;

const MAX_BYTES: usize = 16 * 1024 * 1024;

fn notebook(source: &str) -> Result<Value, String> {
if source.len() > MAX_BYTES {
return Err("DuckDB UI notebook exceeds the 16 MiB limit".into());
}
let value: Value = serde_json::from_str(source)
.map_err(|error| format!("Invalid DuckDB UI notebook JSON: {error}"))?;
if value
.get("notebookSerializationFormat")
.and_then(Value::as_u64)
!= Some(3)
{
return Err("Unsupported DuckDB UI notebook format; expected format 3".into());
}
if !value
.get("version")
.and_then(Value::as_i64)
.is_some_and(|version| (1..=i32::MAX as i64).contains(&version))
{
return Err("Invalid DuckDB UI notebook content version".into());
}
if !matches!(
value.get("currentDatabase"),
Some(Value::Null | Value::String(_))
) {
return Err("Invalid DuckDB UI current database".into());
}
let view = value
.get("viewMode")
.and_then(Value::as_object)
.ok_or("Invalid DuckDB UI notebook view mode")?;
match view.get("mode").and_then(Value::as_str) {
Some("default") => {}
Some("fullScreen")
if view.get("cellId").is_some_and(Value::is_number)
&& matches!(
view.get("focus").and_then(Value::as_str),
Some("editor" | "results" | "split")
) => {}
_ => return Err("Invalid DuckDB UI notebook view mode".into()),
}
let cells = value
.get("cells")
.and_then(Value::as_array)
.ok_or("Invalid DuckDB UI notebook cells")?;
let mut ids = HashSet::new();
for cell in cells {
let id = cell
.get("cellId")
.filter(|id| id.is_number())
.ok_or("Invalid DuckDB UI cell ID")?;
if !ids.insert(id.to_string()) {
return Err("Duplicate DuckDB UI cell ID".into());
}
if !matches!(cell.get("query"), Some(Value::Null | Value::String(_)))
|| !matches!(
cell.get("useDatabase"),
None | Some(Value::Null | Value::String(_))
)
|| !matches!(cell.get("isActive"), None | Some(Value::Bool(_)))
|| !matches!(
cell.get("runMode").and_then(Value::as_str),
None | Some("default" | "instant")
)
|| cell.get("runMode").is_some_and(|mode| !mode.is_string())
{
return Err("Invalid DuckDB UI notebook cell".into());
}
}
Ok(value)
}

pub(crate) fn initial_version(source: &str) -> Result<String, String> {
let mut value = notebook(source)?;
value["version"] = Value::from(1);
serde_json::to_string(&value).map_err(|error| error.to_string())
}

pub(crate) fn next_version(previous: &str, edited: &str) -> Result<String, String> {
let previous = notebook(previous)?;
let mut edited = notebook(edited)?;
if edited["version"] != previous["version"] {
return Err("DuckDB UI notebook content version changed; reload before saving".into());
}
let version = previous["version"].as_i64().unwrap();
if version == i32::MAX as i64 {
return Err("DuckDB UI notebook content version is exhausted".into());
}
edited["version"] = Value::from(version + 1);
let result = serde_json::to_string(&edited).map_err(|error| error.to_string())?;
if result.len() > MAX_BYTES {
return Err("DuckDB UI notebook exceeds the 16 MiB limit".into());
}
Ok(result)
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn fixture() -> Value {
json!({"notebookSerializationFormat":3,"version":2,"currentDatabase":"analytics",
"viewMode":{"mode":"default"},"futureNotebookOption":{"keep":true},
"cells":[{"cellId":2,"query":"-- A database-aware cell\nselect answer, 'π' as label from sample;",
"useDatabase":"analytics","isActive":true,"runMode":"instant","futureCellOption":42},
{"cellId":3,"query":null,"useDatabase":null}]})
}

#[test]
fn advances_official_content_version_without_losing_unknown_metadata() {
let previous = fixture();
let mut edited = previous.clone();
edited["cells"][0]["query"] = json!("select 'edited π' as value;");
let result: Value = serde_json::from_str(
&next_version(&previous.to_string(), &edited.to_string()).unwrap(),
)
.unwrap();
edited["version"] = json!(3);
assert_eq!(result, edited);
}

#[test]
fn rejects_incompatible_or_malformed_edits_before_a_write() {
let previous = fixture().to_string();
for (field, invalid) in [
("notebookSerializationFormat", json!(4)),
("version", json!(3)),
("currentDatabase", json!(42)),
("viewMode", json!({"mode":"unknown"})),
("cells", json!({})),
] {
let mut edited = fixture();
edited[field] = invalid;
assert!(
next_version(&previous, &edited.to_string()).is_err(),
"{field}"
);
}
for (field, invalid) in [
("query", json!(7)),
("cellId", json!("2")),
("useDatabase", json!(true)),
("isActive", json!("yes")),
("runMode", json!("unknown")),
] {
let mut edited = fixture();
edited["cells"][0][field] = invalid;
assert!(
next_version(&previous, &edited.to_string()).is_err(),
"{field}"
);
}
let mut duplicate = fixture();
duplicate["cells"][1]["cellId"] = json!(2);
assert!(next_version(&previous, &duplicate.to_string()).is_err());
assert!(next_version(&previous, "{").is_err());
}
}

#[cfg(test)]
mod creation_tests {
use super::*;
#[test]
fn new_identity_resets_version_preserving_opaque_metadata() {
let source = r#"{"notebookSerializationFormat":3,"version":9,"currentDatabase":null,"viewMode":{"mode":"default","future":42},"cells":[{"cellId":7,"query":"select 1","opaque":[1,2]}],"future":{"keep":true}}"#;
let mut expected: Value = serde_json::from_str(source).unwrap();
expected["version"] = Value::from(1);
assert_eq!(
serde_json::from_str::<Value>(&initial_version(source).unwrap()).unwrap(),
expected
);
assert!(initial_version("{}").is_err());
}
}
Loading
Loading