From 7a5fa8223cced9df834617e509de23dab477fc4f Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 00:51:38 +0800 Subject: [PATCH 01/19] feat(macos): package self-contained test client --- .github/workflows/macos-package.yml | 79 ++++ Makefile | 12 +- apps/chat2db-desktop/icons/icon.icns | Bin 0 -> 96933 bytes apps/chat2db-desktop/src/lib.rs | 343 +++++++++++++++++- apps/chat2db-desktop/tauri.conf.json | 2 +- apps/chat2db-desktop/tauri.package.conf.json | 24 ++ .../src/community-tauri-bridge.test.ts | 22 +- packaging/macos/THIRD_PARTY_NOTICES.md | 16 + packaging/macos/jlink-modules.txt | 22 ++ scripts/build-macos-package.sh | 184 ++++++++++ scripts/build-macos-runtime.sh | 127 +++++++ scripts/community-tauri-bridge.js | 4 + scripts/verify-macos-package.sh | 91 +++++ 13 files changed, 905 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/macos-package.yml create mode 100644 apps/chat2db-desktop/icons/icon.icns create mode 100644 apps/chat2db-desktop/tauri.package.conf.json create mode 100644 packaging/macos/THIRD_PARTY_NOTICES.md create mode 100644 packaging/macos/jlink-modules.txt create mode 100755 scripts/build-macos-package.sh create mode 100755 scripts/build-macos-runtime.sh create mode 100755 scripts/verify-macos-package.sh diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml new file mode 100644 index 0000000..96b4287 --- /dev/null +++ b/.github/workflows/macos-package.yml @@ -0,0 +1,79 @@ +name: macOS Package + +on: + workflow_dispatch: + inputs: + publish_authorized_artifact: + description: Upload the package only after Object-form authorization is recorded + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: macos-package-${{ github.ref }} + cancel-in-progress: false + +jobs: + package: + name: macOS ARM64 self-contained package + runs-on: macos-26 + timeout-minutes: 90 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + submodules: recursive + - name: Enforce Object-form artifact authorization + if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED != 'true' }} + run: | + echo "Artifact upload requires CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED=true" >&2 + exit 1 + - name: Verify ARM64 runner + run: test "$(uname -m)" = arm64 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9ed843567869ab1699d4 + with: + toolchain: 1.88.0 + components: clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.8.1 + with: + shared-key: macos-package-arm64 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.7.1 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: | + java/pom.xml + java/compat-runtime/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-bom/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-spi/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/pom.xml + third_party/chat2db-community/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/pom.xml + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.22.2" + cache: npm + cache-dependency-path: apps/frontend/package-lock.json + - name: Install pinned Tauri CLI + run: cargo install tauri-cli --version 2.8.4 --locked + - name: Build and verify self-contained package + run: make macos-package + - name: Add package manifest to summary + run: cat target/macos-package/BUILD-MANIFEST.txt >> "$GITHUB_STEP_SUMMARY" + - name: Upload authorized package + if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: Chat2DB-Rust-macOS-arm64-${{ github.sha }} + path: | + target/macos-package/*.zip + target/macos-package/*.dmg + target/macos-package/SHA256SUMS + target/macos-package/BUILD-MANIFEST.txt + if-no-files-found: error + compression-level: 0 + retention-days: 7 diff --git a/Makefile b/Makefile index b944ff1..b65c4d0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ community-h2-integration \ community-product-h2-integration product-h2-integration mysql-driver-pack \ community-product-mysql-integration \ - frontend-deps frontend-source frontend desktop generate-contracts check-contracts + frontend-deps frontend-source frontend desktop generate-contracts check-contracts \ + macos-runtime macos-package macos-package-verify JAVA_ENGINE_JAR := $(CURDIR)/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar H2_DRIVER_JAR := $(CURDIR)/java/compat-runtime/target/test-drivers/h2-2.3.232.jar @@ -92,3 +93,12 @@ frontend: frontend-source check-contracts desktop: frontend cargo test -p chat2db-desktop --locked cargo check -p chat2db-desktop --all-targets --features custom-protocol --locked + +macos-runtime: + ./scripts/build-macos-runtime.sh + +macos-package: java community-h2-classpath mysql-driver-pack frontend macos-runtime + ./scripts/build-macos-package.sh + +macos-package-verify: + ./scripts/verify-macos-package.sh diff --git a/apps/chat2db-desktop/icons/icon.icns b/apps/chat2db-desktop/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..a76f9e5f933c4f4698f6c09a34e11647ebd649c4 GIT binary patch literal 96933 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

5$CQ( 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 uRmvC+5C1aXzZ{}CZU57J|BtMikzro#{O5Gb, + java_engine_jar: Option, + community_classpath_dir: Option, + driver_pack_dir: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct RuntimeResourcePaths { + java_bin: OsString, + java_engine_jar: PathBuf, + community_classpath_dir: Option, + driver_pack_dir: Option, +} + +#[derive(Debug)] +struct BundledRuntimeResources { + java_bin: PathBuf, + java_engine_jar: PathBuf, + community_classpath_dir: PathBuf, + driver_pack_dir: PathBuf, +} + +impl BundledRuntimeResources { + fn from_executable(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + if macos_dir.file_name() != Some(OsStr::new("MacOS")) { + return None; + } + let contents_dir = macos_dir.parent()?; + if contents_dir.file_name() != Some(OsStr::new("Contents")) { + return None; + } + let app_dir = contents_dir.parent()?; + if app_dir.extension() != Some(OsStr::new("app")) { + return None; + } + + let resource_root = contents_dir.join("Resources").join("chat2db"); + Some(Self { + java_bin: resource_root.join("java").join("bin").join("java"), + java_engine_jar: resource_root + .join("engine") + .join("chat2db-compat-runtime.jar"), + community_classpath_dir: resource_root.join("community-classpath"), + driver_pack_dir: resource_root.join("driver-packs"), + }) + } +} + struct DesktopState { application: Application, local_server: Mutex>, @@ -178,6 +234,11 @@ pub enum DesktopError { path: PathBuf, source: std::io::Error, }, + InvalidBundledResource { + resource: &'static str, + expected: &'static str, + path: PathBuf, + }, InvalidVaultMasterKeyEncoding, CommunityClasspath(Box), Local(Box), @@ -219,6 +280,15 @@ impl std::fmt::Display for DesktopError { "unable to inspect {JAVA_ENGINE_JAR_ENV} at {}: {source}", path.display() ), + Self::InvalidBundledResource { + resource, + expected, + path, + } => write!( + formatter, + "bundled {resource} is missing or is not a {expected}: {}", + path.display() + ), Self::InvalidVaultMasterKeyEncoding => write!( formatter, "{VAULT_MASTER_KEY_ENV} must be UTF-8 standard base64 for exactly 32 bytes" @@ -247,6 +317,7 @@ impl std::error::Error for DesktopError { Self::MissingJavaEngineJar | Self::EmptyEnvironmentVariable(_) | Self::InvalidJavaEngineJar(_) + | Self::InvalidBundledResource { .. } | Self::InvalidVaultMasterKeyEncoding => None, } } @@ -339,11 +410,21 @@ pub fn run() -> Result { } fn runtime_config_from_environment() -> Result { - let engine_jar = required_java_engine_jar()?; - let java = optional_nonempty_os_env(JAVA_BIN_ENV)?.unwrap_or_else(|| OsString::from("java")); - let mut engine = EngineConfig::new(EngineCommand::java_jar(java, engine_jar)); - if let Some(community_classpath_dir) = optional_nonempty_os_env(COMMUNITY_CLASSPATH_DIR_ENV)? { - let classpath = load_fixed_community_classpath(PathBuf::from(community_classpath_dir)) + let resource_overrides = RuntimeResourceOverrides { + java_engine_jar: optional_nonempty_os_env(JAVA_ENGINE_JAR_ENV)?, + java_bin: optional_nonempty_os_env(JAVA_BIN_ENV)?, + community_classpath_dir: optional_nonempty_os_env(COMMUNITY_CLASSPATH_DIR_ENV)?, + driver_pack_dir: optional_nonempty_os_env(DRIVER_PACK_DIR_ENV)?, + }; + let current_executable = env::current_exe().ok(); + let resources = + resolve_runtime_resource_paths(current_executable.as_deref(), resource_overrides)?; + let mut engine = EngineConfig::new(EngineCommand::java_jar( + resources.java_bin, + resources.java_engine_jar, + )); + if let Some(community_classpath_dir) = resources.community_classpath_dir { + let classpath = load_fixed_community_classpath(community_classpath_dir) .map_err(|error| DesktopError::CommunityClasspath(Box::new(error)))?; engine = engine.with_community_classpath(classpath); } @@ -352,8 +433,8 @@ fn runtime_config_from_environment() -> Result { if let Some(data_dir) = optional_nonempty_os_env(DATA_DIR_ENV)? { config = config.with_data_dir(PathBuf::from(data_dir)); } - if let Some(driver_pack_dir) = optional_nonempty_os_env(DRIVER_PACK_DIR_ENV)? { - config = config.with_driver_pack_dir(PathBuf::from(driver_pack_dir)); + if let Some(driver_pack_dir) = resources.driver_pack_dir { + config = config.with_driver_pack_dir(driver_pack_dir); } match env::var(VAULT_MASTER_KEY_ENV) { Ok(master_key) => config = config.with_vault_master_key_base64(master_key), @@ -365,12 +446,66 @@ fn runtime_config_from_environment() -> Result { Ok(config) } -fn required_java_engine_jar() -> Result { - let path = optional_nonempty_os_env(JAVA_ENGINE_JAR_ENV)? - .map(PathBuf::from) - .ok_or(DesktopError::MissingJavaEngineJar)?; - validate_java_engine_jar(&path)?; - Ok(path) +fn resolve_runtime_resource_paths( + executable: Option<&Path>, + overrides: RuntimeResourceOverrides, +) -> Result { + let bundled = executable.and_then(BundledRuntimeResources::from_executable); + + let java_bin = match overrides.java_bin { + Some(java_bin) => java_bin, + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_file(BUNDLED_JAVA_BIN, &resources.java_bin)?; + resources.java_bin.clone().into_os_string() + } + None => OsString::from("java"), + }, + }; + let java_engine_jar = match overrides.java_engine_jar { + Some(java_engine_jar) => { + let path = PathBuf::from(java_engine_jar); + validate_java_engine_jar(&path)?; + path + } + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_file(BUNDLED_JAVA_ENGINE_JAR, &resources.java_engine_jar)?; + resources.java_engine_jar.clone() + } + None => return Err(DesktopError::MissingJavaEngineJar), + }, + }; + let community_classpath_dir = match overrides.community_classpath_dir { + Some(directory) => Some(PathBuf::from(directory)), + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_directory( + BUNDLED_COMMUNITY_CLASSPATH, + &resources.community_classpath_dir, + )?; + Some(resources.community_classpath_dir.clone()) + } + None => None, + }, + }; + let driver_pack_dir = match overrides.driver_pack_dir { + Some(directory) => Some(PathBuf::from(directory)), + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_directory(BUNDLED_DRIVER_PACKS, &resources.driver_pack_dir)?; + Some(resources.driver_pack_dir.clone()) + } + None => None, + }, + }; + + Ok(RuntimeResourcePaths { + java_bin, + java_engine_jar, + community_classpath_dir, + driver_pack_dir, + }) } fn optional_nonempty_os_env(name: &'static str) -> Result, DesktopError> { @@ -401,6 +536,28 @@ fn validate_java_engine_jar(path: &Path) -> Result<(), DesktopError> { } } +fn validate_bundled_file(resource: &'static str, path: &Path) -> Result<(), DesktopError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) | Err(_) => Err(DesktopError::InvalidBundledResource { + resource, + expected: "regular file", + path: path.to_path_buf(), + }), + } +} + +fn validate_bundled_directory(resource: &'static str, path: &Path) -> Result<(), DesktopError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) | Err(_) => Err(DesktopError::InvalidBundledResource { + resource, + expected: "directory", + path: path.to_path_buf(), + }), + } +} + fn api_error(error: &AppError) -> ApiError { error.api_error() } @@ -1318,7 +1475,12 @@ async fn result_page( #[cfg(test)] mod tests { - use std::{ffi::OsString, fs::File, sync::Arc}; + use std::{ + ffi::OsString, + fs::{self, File}, + path::PathBuf, + sync::Arc, + }; use chat2db_contract::{ AgentEvent, AgentEventEnvelope, AgentStreamMessage, BuildCommunityDmlRequest, @@ -1332,13 +1494,47 @@ mod tests { use tokio::sync::oneshot; use super::{ - DesktopError, SubscriptionRegistry, agent_stream_message, build_community_dml_for, + BUNDLED_COMMUNITY_CLASSPATH, BUNDLED_DRIVER_PACKS, BUNDLED_JAVA_BIN, + BUNDLED_JAVA_ENGINE_JAR, BundledRuntimeResources, DesktopError, RuntimeResourceOverrides, + SubscriptionRegistry, agent_stream_message, build_community_dml_for, build_community_namespace_sql_for, complete_community_sql_for, format_community_sql_for, legacy_request_for, operation_stream_message, parse_after_sequence, - start_community_table_preview_for, validate_community_sql_for, validate_java_engine_jar, - validate_optional_os_env, + resolve_runtime_resource_paths, start_community_table_preview_for, + validate_community_sql_for, validate_java_engine_jar, validate_optional_os_env, }; + fn complete_app_bundle() -> (tempfile::TempDir, PathBuf, BundledRuntimeResources) { + let directory = tempfile::tempdir().expect("temporary app bundle"); + let executable = directory + .path() + .join("Chat2DB.app") + .join("Contents") + .join("MacOS") + .join("chat2db-desktop"); + fs::create_dir_all(executable.parent().expect("bundle executable parent")) + .expect("bundle executable directory"); + File::create(&executable).expect("bundle executable"); + + let resources = BundledRuntimeResources::from_executable(&executable) + .expect("synthetic executable must be recognized as an app bundle"); + fs::create_dir_all(resources.java_bin.parent().expect("Java binary parent")) + .expect("bundled Java directory"); + File::create(&resources.java_bin).expect("bundled Java binary"); + fs::create_dir_all( + resources + .java_engine_jar + .parent() + .expect("engine JAR parent"), + ) + .expect("bundled engine directory"); + File::create(&resources.java_engine_jar).expect("bundled engine JAR"); + fs::create_dir_all(&resources.community_classpath_dir) + .expect("bundled Community classpath"); + fs::create_dir_all(&resources.driver_pack_dir).expect("bundled driver packs"); + + (directory, executable, resources) + } + #[tokio::test] async fn legacy_request_preserves_the_community_jcef_response_shape() { let response = legacy_request_for( @@ -1553,6 +1749,117 @@ mod tests { )); } + #[test] + fn macos_app_bundle_supplies_all_default_runtime_resources() { + let (_directory, executable, bundled) = complete_app_bundle(); + + let resolved = + resolve_runtime_resource_paths(Some(&executable), RuntimeResourceOverrides::default()) + .expect("complete app bundle must resolve"); + + assert_eq!(resolved.java_bin, bundled.java_bin.into_os_string()); + assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); + assert_eq!( + resolved.community_classpath_dir, + Some(bundled.community_classpath_dir) + ); + assert_eq!(resolved.driver_pack_dir, Some(bundled.driver_pack_dir)); + } + + #[test] + fn environment_paths_override_missing_app_bundle_resources() { + let directory = tempfile::tempdir().expect("temporary app bundle"); + let executable = directory + .path() + .join("Chat2DB.app") + .join("Contents") + .join("MacOS") + .join("chat2db-desktop"); + fs::create_dir_all(executable.parent().expect("bundle executable parent")) + .expect("bundle executable directory"); + File::create(&executable).expect("bundle executable"); + + let overrides_root = directory.path().join("overrides"); + let java_bin = overrides_root.join("java"); + let java_engine_jar = overrides_root.join("engine.jar"); + let community_classpath_dir = overrides_root.join("community-classpath"); + let driver_pack_dir = overrides_root.join("driver-packs"); + fs::create_dir_all(&overrides_root).expect("override root"); + File::create(&java_bin).expect("override Java binary"); + File::create(&java_engine_jar).expect("override engine JAR"); + fs::create_dir_all(&community_classpath_dir).expect("override Community classpath"); + fs::create_dir_all(&driver_pack_dir).expect("override driver packs"); + + let resolved = resolve_runtime_resource_paths( + Some(&executable), + RuntimeResourceOverrides { + java_bin: Some(java_bin.clone().into_os_string()), + java_engine_jar: Some(java_engine_jar.clone().into_os_string()), + community_classpath_dir: Some(community_classpath_dir.clone().into_os_string()), + driver_pack_dir: Some(driver_pack_dir.clone().into_os_string()), + }, + ) + .expect("environment overrides must not require bundled fallbacks"); + + assert_eq!(resolved.java_bin, java_bin.into_os_string()); + assert_eq!(resolved.java_engine_jar, java_engine_jar); + assert_eq!( + resolved.community_classpath_dir, + Some(community_classpath_dir) + ); + assert_eq!(resolved.driver_pack_dir, Some(driver_pack_dir)); + } + + #[test] + fn app_bundle_reports_each_missing_runtime_resource() { + for missing_resource in [ + BUNDLED_JAVA_BIN, + BUNDLED_JAVA_ENGINE_JAR, + BUNDLED_COMMUNITY_CLASSPATH, + BUNDLED_DRIVER_PACKS, + ] { + let (_directory, executable, bundled) = complete_app_bundle(); + let (missing_path, is_directory) = match missing_resource { + BUNDLED_JAVA_BIN => (bundled.java_bin, false), + BUNDLED_JAVA_ENGINE_JAR => (bundled.java_engine_jar, false), + BUNDLED_COMMUNITY_CLASSPATH => (bundled.community_classpath_dir, true), + BUNDLED_DRIVER_PACKS => (bundled.driver_pack_dir, true), + _ => unreachable!("all bundled resources are covered"), + }; + if is_directory { + fs::remove_dir_all(&missing_path).expect("remove bundled directory"); + } else { + fs::remove_file(&missing_path).expect("remove bundled file"); + } + + let error = resolve_runtime_resource_paths( + Some(&executable), + RuntimeResourceOverrides::default(), + ) + .expect_err("missing bundled resource must fail closed"); + assert!(matches!( + error, + DesktopError::InvalidBundledResource { resource, path, .. } + if resource == missing_resource && path == missing_path + )); + } + } + + #[test] + fn development_executable_still_requires_java_engine_environment() { + let directory = tempfile::tempdir().expect("temporary development layout"); + let executable = directory + .path() + .join("target") + .join("debug") + .join("chat2db-desktop"); + + assert!(matches!( + resolve_runtime_resource_paths(Some(&executable), RuntimeResourceOverrides::default()), + Err(DesktopError::MissingJavaEngineJar) + )); + } + #[test] fn optional_path_environment_rejects_explicit_empty_values() { assert!(matches!( diff --git a/apps/chat2db-desktop/tauri.conf.json b/apps/chat2db-desktop/tauri.conf.json index 6326a01..999aa05 100644 --- a/apps/chat2db-desktop/tauri.conf.json +++ b/apps/chat2db-desktop/tauri.conf.json @@ -29,6 +29,6 @@ }, "bundle": { "active": false, - "icon": ["icons/icon.png", "icons/icon.ico"] + "icon": ["icons/icon.png", "icons/icon.icns", "icons/icon.ico"] } } diff --git a/apps/chat2db-desktop/tauri.package.conf.json b/apps/chat2db-desktop/tauri.package.conf.json new file mode 100644 index 0000000..14b3f5e --- /dev/null +++ b/apps/chat2db-desktop/tauri.package.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBuildCommand": "npm --prefix frontend run build" + }, + "bundle": { + "active": true, + "targets": ["app"], + "category": "DeveloperTool", + "resources": { + "../../target/macos-runtime": "chat2db/java", + "../../java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar": "chat2db/engine/chat2db-compat-runtime.jar", + "../../target/community-h2-classpath": "chat2db/community-classpath", + "../../target/mysql-driver-packs": "chat2db/driver-packs", + "../../LICENSE": "chat2db/licenses/Chat2DB-Rust-LICENSE.txt", + "../../third_party/chat2db-community/LICENSE": "chat2db/licenses/Chat2DB-Community-LICENSE.txt", + "../../packaging/macos/THIRD_PARTY_NOTICES.md": "chat2db/licenses/THIRD_PARTY_NOTICES.md" + }, + "macOS": { + "minimumSystemVersion": "12.0", + "hardenedRuntime": false + } + } +} diff --git a/apps/frontend/src/community-tauri-bridge.test.ts b/apps/frontend/src/community-tauri-bridge.test.ts index 3b71550..ee55fb3 100644 --- a/apps/frontend/src/community-tauri-bridge.test.ts +++ b/apps/frontend/src/community-tauri-bridge.test.ts @@ -17,9 +17,12 @@ describe('Community Tauri bridge', () => { it('forwards the existing javaQuery contract through one Tauri command', async () => { const invoke = vi.fn().mockResolvedValue('{"ok":true}'); + const replace = vi.fn(); const onSuccess = vi.fn(); const onFailure = vi.fn(); - const window: Record = {}; + const window: Record = { + location: { hash: '', replace }, + }; const globalObject = { __TAURI__: { core: { invoke } }, window, @@ -33,7 +36,24 @@ describe('Community Tauri bridge', () => { expect(invoke).toHaveBeenCalledWith('legacy_request', { request: '{"uuid":"request-1"}', }); + expect(replace).toHaveBeenCalledWith('#/workspace'); expect(onSuccess).toHaveBeenCalledWith('{"ok":true}'); expect(onFailure).not.toHaveBeenCalled(); }); + + it('preserves an explicit desktop route', () => { + const replace = vi.fn(); + const window: Record = { + location: { hash: '#/stream', replace }, + }; + + runInNewContext(bridgeSource, { + globalThis: { + __TAURI__: { core: { invoke: vi.fn() } }, + window, + }, + }); + + expect(replace).not.toHaveBeenCalled(); + }); }); diff --git a/packaging/macos/THIRD_PARTY_NOTICES.md b/packaging/macos/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..7b3d835 --- /dev/null +++ b/packaging/macos/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# Third-Party Notices + +This local test package contains the exact original Chat2DB Community frontend +and fixed Java compatibility classpath pinned by this repository. Their source +revision, artifact names, byte lengths, and SHA-256 digests are recorded in: + +- `scripts/community-frontend.lock.json` +- `third_party/community-h2-classpath.lock` +- `target/mysql-driver-packs/01-mysql/driver-pack.json` + +The package includes copies of both the Chat2DB Rust license and the pinned +Chat2DB Community license under `Contents/Resources/chat2db/licenses`. + +This notice is an inventory aid for an internal test build. It is not the +complete NOTICE/SBOM, commercial authorization, Developer ID signature, or +notarization evidence required for an external Object-form release. diff --git a/packaging/macos/jlink-modules.txt b/packaging/macos/jlink-modules.txt new file mode 100644 index 0000000..2639830 --- /dev/null +++ b/packaging/macos/jlink-modules.txt @@ -0,0 +1,22 @@ +# Java SE covers the public APIs referenced by the fixed Community classpath. +java.se + +# JDK modules cover JDBC charset, crypto, networking, diagnostics, service +# loading, and archive providers used directly or reflectively by the retained +# compatibility engine and its fixed plugin classpath. +jdk.attach +jdk.charsets +jdk.crypto.cryptoki +jdk.crypto.ec +jdk.httpserver +jdk.jdi +jdk.jfr +jdk.localedata +jdk.management +jdk.management.agent +jdk.naming.dns +jdk.naming.rmi +jdk.net +jdk.security.auth +jdk.unsupported +jdk.zipfs diff --git a/scripts/build-macos-package.sh b/scripts/build-macos-package.sh new file mode 100755 index 0000000..66a43c7 --- /dev/null +++ b/scripts/build-macos-package.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +desktop_root="${repository_root}/apps/chat2db-desktop" +build_target="${CHAT2DB_MACOS_BUILD_TARGET:-${repository_root}/target/macos-package-build}" +app_path="${build_target}/release/bundle/macos/Chat2DB Rust.app" +package_directory="${repository_root}/target/macos-package" +staging_directory="" + +cleanup() { + if [[ -n "${staging_directory}" && -d "${staging_directory}" ]]; then + rm -rf -- "${staging_directory}" + fi +} +trap cleanup EXIT + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS package generation requires Darwin" >&2 + exit 1 +fi +target_root="${repository_root}/target" +if [[ -e "${target_root}" && ( ! -d "${target_root}" || -L "${target_root}" ) ]]; then + echo "refusing to use unsafe target directory: ${target_root}" >&2 + exit 1 +fi +mkdir -p "${target_root}" +if [[ "${build_target}" != /* || "$(dirname "${build_target}")" != "${target_root}" ]]; then + echo "CHAT2DB_MACOS_BUILD_TARGET must be an absolute direct child of ${target_root}" >&2 + exit 1 +fi +case "$(basename "${build_target}")" in + macos-package-build | macos-package-build-*) ;; + *) + echo "CHAT2DB_MACOS_BUILD_TARGET must use the macos-package-build name prefix" >&2 + exit 1 + ;; +esac +if [[ -L "${build_target}" || ( -e "${build_target}" && ! -d "${build_target}" ) ]]; then + echo "refusing to use unsafe macOS build target: ${build_target}" >&2 + exit 1 +fi +git_directory="$(git -C "${repository_root}" rev-parse --absolute-git-dir)" +lock_file="${git_directory}/chat2db-macos-package.lock" +if [[ -L "${lock_file}" || ( -e "${lock_file}" && ! -f "${lock_file}" ) ]]; then + echo "refusing to use unsafe macOS package lock: ${lock_file}" >&2 + exit 1 +fi +exec 9>"${lock_file}" +if ! lockf -s -t 0 9; then + echo "another macOS runtime or package build is already using this checkout" >&2 + exit 1 +fi +host_arch="$(uname -m)" +case "${host_arch}" in + arm64) artifact_arch="aarch64" ;; + x86_64) artifact_arch="x86_64" ;; + *) + echo "unsupported macOS architecture: ${host_arch}" >&2 + exit 1 + ;; +esac + +for path in \ + "${repository_root}/target/macos-runtime/bin/java" \ + "${repository_root}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" \ + "${repository_root}/target/community-h2-classpath" \ + "${repository_root}/target/mysql-driver-packs" \ + "${repository_root}/apps/frontend/dist"; do + if [[ ! -e "${path}" ]]; then + echo "package prerequisite is missing: ${path}" >&2 + exit 1 + fi +done +if ! cargo tauri --version | grep -Fxq "tauri-cli 2.8.4"; then + echo "tauri-cli 2.8.4 is required; install it with cargo install tauri-cli --version 2.8.4 --locked" >&2 + exit 1 +fi +rust_toolchain="${CHAT2DB_RUST_TOOLCHAIN:-1.88.0}" +rust_version="$(rustup run "${rust_toolchain}" rustc --version 2>/dev/null || true)" +if [[ "${rust_version}" != rustc\ 1.88.0\ * ]]; then + echo "Rust 1.88.0 is required for macOS packaging; found ${rust_version:-missing}" >&2 + exit 1 +fi + +staged_resource_root="${build_target}/release/chat2db" +if [[ -L "${staged_resource_root}" || ( -e "${staged_resource_root}" && ! -d "${staged_resource_root}" ) ]]; then + echo "refusing to refresh unsafe staged resource directory: ${staged_resource_root}" >&2 + exit 1 +fi +if [[ -d "${staged_resource_root}" ]]; then + chmod -R u+rwX "${staged_resource_root}" +fi + +( + cd "${desktop_root}" + CARGO_TARGET_DIR="${build_target}" \ + RUSTUP_TOOLCHAIN="${rust_toolchain}" \ + CI=true cargo tauri build \ + --config tauri.package.conf.json \ + --bundles app \ + --ci \ + --ignore-version-mismatches \ + -- \ + --locked +) + +if [[ ! -d "${app_path}" || -L "${app_path}" ]]; then + echo "Tauri did not create the expected app bundle: ${app_path}" >&2 + exit 1 +fi + +signing_identity="${APPLE_SIGNING_IDENTITY:--}" +if [[ "${signing_identity}" == "-" ]]; then + codesign --force --deep --sign - --timestamp=none "${app_path}" +else + codesign --force --deep --options runtime --timestamp --sign "${signing_identity}" "${app_path}" +fi +"${repository_root}/scripts/verify-macos-package.sh" "${app_path}" + +version="$(awk ' + /^\[workspace.package\]$/ { in_package = 1; next } + /^\[/ { in_package = 0 } + in_package && /^version[[:space:]]*=/ { + gsub(/[[:space:]\"]/ , "", $0) + sub(/^version=/, "", $0) + print + exit + } +' "${repository_root}/Cargo.toml")" +if [[ -z "${version}" ]]; then + echo "could not resolve workspace package version" >&2 + exit 1 +fi + +case "${package_directory}" in + "${repository_root}/target/macos-package") ;; + *) + echo "refusing to replace unexpected package directory: ${package_directory}" >&2 + exit 1 + ;; +esac +rm -rf -- "${package_directory}" +mkdir -p "${package_directory}" + +artifact_base="Chat2DB-Rust_${version}_${artifact_arch}" +zip_path="${package_directory}/${artifact_base}.app.zip" +dmg_path="${package_directory}/${artifact_base}.dmg" +ditto -c -k --sequesterRsrc --keepParent "${app_path}" "${zip_path}" + +staging_directory="$(mktemp -d "${repository_root}/target/.macos-dmg.staging.XXXXXX")" +ditto "${app_path}" "${staging_directory}/Chat2DB Rust.app" +ln -s /Applications "${staging_directory}/Applications" +hdiutil create \ + -volname "Chat2DB Rust" \ + -srcfolder "${staging_directory}" \ + -ov \ + -format UDZO \ + "${dmg_path}" +rm -rf -- "${staging_directory}" +staging_directory="" + +( + cd "${package_directory}" + shasum -a 256 -- "$(basename "${zip_path}")" "$(basename "${dmg_path}")" > SHA256SUMS +) +git_commit="$(git -C "${repository_root}" rev-parse HEAD)" +community_commit="$(git -C "${repository_root}/third_party/chat2db-community" rev-parse HEAD)" +app_kib="$(du -sk "${app_path}" | awk '{ print $1 }')" +cat > "${package_directory}/BUILD-MANIFEST.txt" <&2 + exit 1 +fi +target_root="${repository_root}/target" +if [[ -e "${target_root}" && ( ! -d "${target_root}" || -L "${target_root}" ) ]]; then + echo "refusing to use unsafe target directory: ${target_root}" >&2 + exit 1 +fi +mkdir -p "${target_root}" +git_directory="$(git -C "${repository_root}" rev-parse --absolute-git-dir)" +lock_file="${git_directory}/chat2db-macos-package.lock" +if [[ -L "${lock_file}" || ( -e "${lock_file}" && ! -f "${lock_file}" ) ]]; then + echo "refusing to use unsafe macOS package lock: ${lock_file}" >&2 + exit 1 +fi +exec 9>"${lock_file}" +if ! lockf -s -t 0 9; then + echo "another macOS runtime or package build is already using this checkout" >&2 + exit 1 +fi +if [[ ! -f "${module_file}" || -L "${module_file}" ]]; then + echo "jlink module list must be a non-symbolic regular file: ${module_file}" >&2 + exit 1 +fi + +java_home="${JAVA_HOME:-}" +if [[ -z "${java_home}" ]]; then + java_home="$(/usr/libexec/java_home -v 17)" +fi +if [[ ! -x "${java_home}/bin/java" || ! -x "${java_home}/bin/jlink" ]]; then + echo "JAVA_HOME must point to a JDK 17 containing java and jlink" >&2 + exit 1 +fi +java_major="$("${java_home}/bin/java" -version 2>&1 | awk -F '[\".]' '/version/ { print $2; exit }')" +if [[ "${java_major}" != "17" ]]; then + echo "macOS runtime must be built with JDK 17; found ${java_major:-unknown}" >&2 + exit 1 +fi + +modules="$( + awk ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + { gsub(/[[:space:]]/, ""); print } + ' "${module_file}" | LC_ALL=C sort -u | paste -sd, - +)" +if [[ -z "${modules}" ]]; then + echo "jlink module list is empty" >&2 + exit 1 +fi + +case "${output_directory}" in + "${repository_root}/target/macos-runtime") ;; + *) + echo "refusing to replace unexpected runtime directory: ${output_directory}" >&2 + exit 1 + ;; +esac +staging_directory="$(mktemp -d "${target_root}/.macos-runtime.staging.XXXXXX")" +rm -rf -- "${staging_directory}" + +"${java_home}/bin/jlink" \ + --module-path "${java_home}/jmods" \ + --add-modules "${modules}" \ + --bind-services \ + --strip-debug \ + --no-header-files \ + --no-man-pages \ + --compress=2 \ + --output "${staging_directory}" + +# Tauri refreshes bundled resources in-place on incremental builds. jlink +# emits read-only legal files, so normalize the tree before it is staged. +chmod -R u+rwX,go+rX "${staging_directory}" + +"${staging_directory}/bin/java" -version +while IFS= read -r module; do + [[ -z "${module}" || "${module}" == \#* ]] && continue + if ! "${staging_directory}/bin/java" --list-modules | cut -d@ -f1 | grep -Fxq "${module}"; then + echo "generated runtime is missing required module ${module}" >&2 + exit 1 + fi +done < "${module_file}" + +if [[ -e "${output_directory}" ]]; then + if [[ ! -d "${output_directory}" || -L "${output_directory}" ]]; then + echo "refusing to replace unsafe runtime path: ${output_directory}" >&2 + exit 1 + fi + backup_directory="${repository_root}/target/.macos-runtime.previous.$$" + if [[ -e "${backup_directory}" ]]; then + echo "refusing to replace existing runtime backup: ${backup_directory}" >&2 + exit 1 + fi + mv -- "${output_directory}" "${backup_directory}" +fi +mv -- "${staging_directory}" "${output_directory}" +staging_directory="" +if [[ -n "${backup_directory}" ]]; then + rm -rf -- "${backup_directory}" + backup_directory="" +fi + +echo "Built macOS Java 17 runtime at ${output_directory}" diff --git a/scripts/community-tauri-bridge.js b/scripts/community-tauri-bridge.js index b23baae..df6ae76 100644 --- a/scripts/community-tauri-bridge.js +++ b/scripts/community-tauri-bridge.js @@ -2,6 +2,10 @@ const invoke = globalThis.__TAURI__?.core?.invoke; if (typeof invoke !== 'function' || typeof globalThis.window !== 'object') return; + if (globalThis.window.location?.hash === '') { + globalThis.window.location.replace('#/workspace'); + } + globalThis.window.javaQuery = ({ request, onSuccess, onFailure }) => { invoke('legacy_request', { request }) .then((response) => onSuccess?.(response)) diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh new file mode 100755 index 0000000..2501581 --- /dev/null +++ b/scripts/verify-macos-package.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_path="${1:-${repository_root}/target/macos-package-build/release/bundle/macos/Chat2DB Rust.app}" +resource_root="${app_path}/Contents/Resources/chat2db" +java_bin="${resource_root}/java/bin/java" +engine_jar="${resource_root}/engine/chat2db-compat-runtime.jar" +community_classpath="${resource_root}/community-classpath" +driver_root="${resource_root}/driver-packs" +binary="${app_path}/Contents/MacOS/chat2db-desktop" +module_file="${repository_root}/packaging/macos/jlink-modules.txt" + +require_file() { + local path="$1" + if [[ ! -f "${path}" || -L "${path}" ]]; then + echo "required package file is missing or unsafe: ${path}" >&2 + exit 1 + fi +} + +require_directory() { + local path="$1" + if [[ ! -d "${path}" || -L "${path}" ]]; then + echo "required package directory is missing or unsafe: ${path}" >&2 + exit 1 + fi +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS package verification requires Darwin" >&2 + exit 1 +fi +require_directory "${app_path}" +require_file "${binary}" +require_file "${java_bin}" +require_file "${engine_jar}" +require_directory "${community_classpath}" +require_directory "${driver_root}" +require_file "${resource_root}/licenses/Chat2DB-Rust-LICENSE.txt" +require_file "${resource_root}/licenses/Chat2DB-Community-LICENSE.txt" +require_file "${resource_root}/licenses/THIRD_PARTY_NOTICES.md" + +if [[ ! -x "${binary}" || ! -x "${java_bin}" ]]; then + echo "packaged desktop and Java binaries must be executable" >&2 + exit 1 +fi + +"${repository_root}/scripts/community-classpath-lock.sh" verify \ + "${community_classpath}" \ + "${repository_root}/third_party/community-h2-classpath.lock" \ + "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" + +driver_manifest="${driver_root}/01-mysql/driver-pack.json" +driver_jar="${driver_root}/01-mysql/mysql-connector-java-8.0.30.jar" +require_file "${driver_manifest}" +require_file "${driver_jar}" +expected_driver_sha="$(awk -F '"' '/"sha256"/ { print $4; exit }' "${driver_manifest}")" +actual_driver_sha="$(shasum -a 256 -- "${driver_jar}" | awk '{ print $1 }')" +if [[ -z "${expected_driver_sha}" || "${actual_driver_sha}" != "${expected_driver_sha}" ]]; then + echo "packaged MySQL driver digest does not match its manifest" >&2 + exit 1 +fi + +"${java_bin}" -version +while IFS= read -r module; do + [[ -z "${module}" || "${module}" == \#* ]] && continue + if ! "${java_bin}" --list-modules | cut -d@ -f1 | grep -Fxq "${module}"; then + echo "packaged Java runtime is missing ${module}" >&2 + exit 1 + fi +done < "${module_file}" +"${java_bin}" -jar "${engine_jar}" < /dev/null + +bundle_identifier="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${app_path}/Contents/Info.plist")" +if [[ "${bundle_identifier}" != "ai.chat2db.desktop" ]]; then + echo "unexpected bundle identifier: ${bundle_identifier}" >&2 + exit 1 +fi +host_arch="$(uname -m)" +if ! lipo -archs "${binary}" | tr ' ' '\n' | grep -Fxq "${host_arch}"; then + echo "desktop binary does not contain host architecture ${host_arch}" >&2 + exit 1 +fi +if ! lipo -archs "${java_bin}" | tr ' ' '\n' | grep -Fxq "${host_arch}"; then + echo "Java runtime does not contain host architecture ${host_arch}" >&2 + exit 1 +fi + +codesign --verify --deep --strict --verbose=2 "${app_path}" +echo "Verified self-contained macOS package at ${app_path}" From 540fce815b4c9083ecce9a75da5bb73f83ccf6c0 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 00:53:41 +0800 Subject: [PATCH 02/19] ci(macos): allow authorized test package tags --- .github/workflows/macos-package.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 96b4287..fb3b0b6 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -1,6 +1,9 @@ name: macOS Package on: + push: + tags: + - "macos-test-*" workflow_dispatch: inputs: publish_authorized_artifact: @@ -26,7 +29,7 @@ jobs: with: submodules: recursive - name: Enforce Object-form artifact authorization - if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED != 'true' }} + if: ${{ (github.event_name == 'push' || inputs.publish_authorized_artifact) && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED != 'true' }} run: | echo "Artifact upload requires CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED=true" >&2 exit 1 @@ -65,7 +68,7 @@ jobs: - name: Add package manifest to summary run: cat target/macos-package/BUILD-MANIFEST.txt >> "$GITHUB_STEP_SUMMARY" - name: Upload authorized package - if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED == 'true' }} + if: ${{ (github.event_name == 'push' || inputs.publish_authorized_artifact) && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Chat2DB-Rust-macOS-arm64-${{ github.sha }} From 7ed57f43fd2a19a8af0dd1e4712f85ba6095907d Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 00:57:34 +0800 Subject: [PATCH 03/19] ci(macos): refresh Rust toolchain action --- .github/workflows/macos-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index fb3b0b6..7f06b63 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -35,7 +35,7 @@ jobs: exit 1 - name: Verify ARM64 runner run: test "$(uname -m)" = arm64 - - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9ed843567869ab1699d4 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c with: toolchain: 1.88.0 components: clippy From cf9ab8a2e5e93d64e5709613646ebf3a9ed179a6 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 14:12:31 +0800 Subject: [PATCH 04/19] fix(frontend): keep Community form cloning CSP safe --- scripts/community-frontend.lock.json | 4 ++-- third_party/chat2db-community | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/community-frontend.lock.json b/scripts/community-frontend.lock.json index 6911429..a724662 100644 --- a/scripts/community-frontend.lock.json +++ b/scripts/community-frontend.lock.json @@ -2,7 +2,7 @@ "repository": "https://github.com/OtterMind/Chat2DB.git", "submodulePath": "third_party/chat2db-community", "sourcePath": "chat2db-community-client", - "commit": "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7", - "tree": "9f2b7c5d4e47b550995390381f9642cac9d31b6a", + "commit": "f275e08d774f839612374e991d09c5e6ea2d8b57", + "tree": "3f94fddda70cf2b2498793de1c64ceb588c3270b", "packageManager": "yarn@1.22.22" } diff --git a/third_party/chat2db-community b/third_party/chat2db-community index f63cbf4..f275e08 160000 --- a/third_party/chat2db-community +++ b/third_party/chat2db-community @@ -1 +1 @@ -Subproject commit f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7 +Subproject commit f275e08d774f839612374e991d09c5e6ea2d8b57 From 36ecac6089fc9b7df1a9d55cdebd7004534d0688 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 14:20:09 +0800 Subject: [PATCH 05/19] feat(storage): persist saved Community consoles --- crates/chat2db-core/src/error.rs | 7 + .../migrations/003_saved_console.sql | 34 + crates/chat2db-storage/src/error.rs | 6 + crates/chat2db-storage/src/lib.rs | 120 ++- crates/chat2db-storage/src/saved_console.rs | 856 ++++++++++++++++++ crates/chat2db-storage/tests/public_api.rs | 40 +- 6 files changed, 1053 insertions(+), 10 deletions(-) create mode 100644 crates/chat2db-storage/migrations/003_saved_console.sql create mode 100644 crates/chat2db-storage/src/saved_console.rs diff --git a/crates/chat2db-core/src/error.rs b/crates/chat2db-core/src/error.rs index ce95cf3..87799c5 100644 --- a/crates/chat2db-core/src/error.rs +++ b/crates/chat2db-core/src/error.rs @@ -269,6 +269,13 @@ impl From for AppError { StorageError::InvalidDatasource(message) => { Self::invalid("invalid_datasource", message) } + StorageError::SavedConsoleNotFound(id) => Self::not_found( + "saved_console_not_found", + format!("Saved Console {id} does not exist"), + ), + StorageError::InvalidSavedConsole(message) => { + Self::invalid("invalid_saved_console", message) + } StorageError::ProviderNotFound(id) => Self::not_found( "provider_not_found", format!("Provider profile {id} does not exist"), diff --git a/crates/chat2db-storage/migrations/003_saved_console.sql b/crates/chat2db-storage/migrations/003_saved_console.sql new file mode 100644 index 0000000..013bbf7 --- /dev/null +++ b/crates/chat2db-storage/migrations/003_saved_console.sql @@ -0,0 +1,34 @@ +BEGIN IMMEDIATE; + +CREATE TABLE saved_consoles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + data_source_id TEXT, + data_source_name TEXT, + database_name TEXT, + schema_name TEXT, + database_type TEXT, + ddl TEXT NOT NULL, + status TEXT NOT NULL, + tab_opened TEXT NOT NULL CHECK (tab_opened IN ('y', 'n')), + operation_type TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= created_at_ms) +) STRICT; + +CREATE INDEX saved_consoles_created_idx + ON saved_consoles (created_at_ms, id); + +CREATE INDEX saved_consoles_updated_idx + ON saved_consoles (updated_at_ms DESC, id DESC); + +CREATE INDEX saved_consoles_open_scope_idx + ON saved_consoles ( + tab_opened, data_source_id, database_name, schema_name, created_at_ms, id + ); + +CREATE INDEX saved_consoles_status_idx + ON saved_consoles (status, updated_at_ms DESC, id DESC); + +PRAGMA user_version = 3; +COMMIT; diff --git a/crates/chat2db-storage/src/error.rs b/crates/chat2db-storage/src/error.rs index 5597a95..7908e23 100644 --- a/crates/chat2db-storage/src/error.rs +++ b/crates/chat2db-storage/src/error.rs @@ -62,6 +62,12 @@ pub enum StorageError { /// A datasource field violates the durable contract. #[error("invalid datasource: {0}")] InvalidDatasource(&'static str), + /// The requested saved Console does not exist. + #[error("saved Console not found: {0}")] + SavedConsoleNotFound(i64), + /// A saved Console field or page request violates the durable contract. + #[error("invalid saved Console: {0}")] + InvalidSavedConsole(&'static str), /// The requested provider profile does not exist. #[error("provider profile not found: {0}")] ProviderNotFound(String), diff --git a/crates/chat2db-storage/src/lib.rs b/crates/chat2db-storage/src/lib.rs index ce8e06f..039e399 100644 --- a/crates/chat2db-storage/src/lib.rs +++ b/crates/chat2db-storage/src/lib.rs @@ -5,6 +5,7 @@ mod datasource; mod error; mod provider; mod result_store; +mod saved_console; mod secret; mod vault; @@ -42,6 +43,10 @@ pub use result_store::{ MAX_RESULT_PAGE_BYTES, MAX_RESULT_PAGE_ROWS, MIN_RESULT_PAGE_BYTES, PageRequest, PurgeReport, RecoveryReport, ResultMetadata, ResultPage, ResultWriter, }; +pub use saved_console::{ + CreateSavedConsole, SavedConsoleListQuery, SavedConsolePage, SavedConsoleRecord, + UpdateSavedConsole, +}; pub use secret::{SecretRef, SecretValue, SecretVault, SecretVaultError}; pub use vault::EncryptedFileVault; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] @@ -50,7 +55,7 @@ pub use vault::OsSecretVault; const DATABASE_FILE: &str = "chat2db.sqlite3"; const LOCK_FILE: &str = ".chat2db.lock"; const RESULTS_DIRECTORY: &str = "results"; -const CURRENT_SCHEMA_VERSION: i64 = 2; +const CURRENT_SCHEMA_VERSION: i64 = 3; #[cfg(test)] #[derive(Clone, Copy)] @@ -371,6 +376,13 @@ fn migrate(connection: &Connection) -> Result<(), StorageError> { } if version == 1 { apply_migration(connection, include_str!("../migrations/002_agent.sql"))?; + version = 2; + } + if version == 2 { + apply_migration( + connection, + include_str!("../migrations/003_saved_console.sql"), + )?; } Ok(()) } @@ -513,7 +525,7 @@ mod tests { let synchronous: i64 = connection .pragma_query_value(None, "synchronous", |row| row.get(0)) .expect("synchronous reads"); - assert_eq!(version, 2); + assert_eq!(version, 3); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode.to_ascii_lowercase(), "wal"); assert_eq!(synchronous, 2); @@ -532,22 +544,22 @@ mod tests { let database = directory.path().join(DATABASE_FILE); Connection::open(&database) .expect("database opens") - .execute_batch("PRAGMA user_version = 3") + .execute_batch("PRAGMA user_version = 4") .expect("test version updates"); let error = Storage::open(directory.path(), vault()).expect_err("newer schema must fail"); assert!(matches!( error, StorageError::UnsupportedSchema { - found: 3, - supported: 2 + found: 4, + supported: 3 } )); let version: i64 = Connection::open(database) .expect("database opens") .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("schema version reads"); - assert_eq!(version, 3); + assert_eq!(version, 4); } #[test] @@ -581,7 +593,7 @@ mod tests { |row| row.get(0), ) .expect("provider table count reads"); - assert_eq!(version, 2); + assert_eq!(version, 3); assert_eq!(provider_table, 1); assert!( storage @@ -591,6 +603,100 @@ mod tests { ); } + #[test] + fn version_two_upgrades_atomically_and_preserves_existing_state() { + let directory = TempDir::new().expect("temp dir"); + let database = directory.path().join(DATABASE_FILE); + let connection = Connection::open(&database).expect("database opens"); + connection + .execute_batch(include_str!("../migrations/001_initial.sql")) + .expect("version one schema creates"); + connection + .execute_batch(include_str!("../migrations/002_agent.sql")) + .expect("version two schema creates"); + connection + .execute( + "INSERT INTO datasources ( + id, name, driver_id, revision, created_at_ms, updated_at_ms + ) VALUES ('existing-v2', 'Existing', 'driver', 1, 1, 1)", + [], + ) + .expect("version two state inserts"); + drop(connection); + + let storage = Storage::open(directory.path(), vault()).expect("version two upgrades"); + let connection = storage.connection().expect("connection opens"); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .expect("schema version reads"); + let saved_console_table: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'saved_consoles'", + [], + |row| row.get(0), + ) + .expect("saved Console table count reads"); + assert_eq!(version, 3); + assert_eq!(saved_console_table, 1); + assert!( + storage + .get_datasource("existing-v2") + .expect("datasource reads") + .is_some() + ); + } + + #[test] + fn failed_version_three_upgrade_rolls_back_saved_console_schema() { + let directory = TempDir::new().expect("temp dir"); + let database = directory.path().join(DATABASE_FILE); + let connection = Connection::open(&database).expect("database opens"); + connection + .execute_batch(include_str!("../migrations/001_initial.sql")) + .expect("version one schema creates"); + connection + .execute_batch(include_str!("../migrations/002_agent.sql")) + .expect("version two schema creates"); + connection + .execute_batch( + "CREATE TABLE migration_sentinel (value INTEGER); + CREATE INDEX saved_consoles_status_idx + ON migration_sentinel (value);", + ) + .expect("conflicting index creates"); + drop(connection); + + Storage::open(directory.path(), vault()).expect_err("version three upgrade must fail"); + let connection = Connection::open(database).expect("database reopens"); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .expect("schema version reads"); + let saved_console_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'saved_consoles'", + [], + |row| row.get(0), + ) + .expect("saved Console table count reads"); + let prior_indexes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name IN ( + 'saved_consoles_created_idx', + 'saved_consoles_updated_idx', + 'saved_consoles_open_scope_idx' + )", + [], + |row| row.get(0), + ) + .expect("partial saved Console index count reads"); + assert_eq!(version, 2); + assert_eq!(saved_console_tables, 0); + assert_eq!(prior_indexes, 0); + } + #[test] fn failed_version_two_upgrade_rolls_back_every_new_agent_table() { let directory = TempDir::new().expect("temp dir"); diff --git a/crates/chat2db-storage/src/saved_console.rs b/crates/chat2db-storage/src/saved_console.rs new file mode 100644 index 0000000..ba926f7 --- /dev/null +++ b/crates/chat2db-storage/src/saved_console.rs @@ -0,0 +1,856 @@ +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; + +use crate::{Storage, StorageError, now_millis}; + +const MAX_NAME_BYTES: usize = 512; +const MAX_DATASOURCE_ID_BYTES: usize = 512; +const MAX_SCOPE_BYTES: usize = 1_024; +const MAX_DATABASE_TYPE_BYTES: usize = 255; +const MAX_STATE_BYTES: usize = 255; +const MAX_DDL_BYTES: usize = 16 * 1024 * 1024; +const MAX_SEARCH_KEY_BYTES: usize = 256; +const MAX_PAGE_SIZE: u32 = 1_000; + +const SAVED_CONSOLE_COLUMNS: &str = "id, name, data_source_id, data_source_name, database_name, schema_name,\ + database_type, ddl, status, tab_opened, operation_type, created_at_ms, updated_at_ms"; + +/// Fields used to create a durable Community SQL Console. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSavedConsole { + /// Existing numeric identity to restore, or `None` to allocate one. + pub id: Option, + /// User-visible Console name. + pub name: String, + /// Opaque Rust datasource id used by the compatibility layer. + pub data_source_id: Option, + /// Datasource alias captured for the retained Community UI. + pub data_source_name: Option, + /// Bound database/catalog name. + pub database_name: Option, + /// Bound schema name. + pub schema_name: Option, + /// Community database type code, exposed as the historical `type` field. + pub database_type: Option, + /// SQL editor contents. + pub ddl: String, + /// Community saved-state value, normally `DRAFT` or `RELEASE`. + pub status: String, + /// Community tab-open flag, either `y` or `n`. + pub tab_opened: String, + /// Community workspace operation type, normally `console`. + pub operation_type: String, +} + +/// Partial update for a durable Community SQL Console. +/// +/// Nested options distinguish an unchanged nullable field (`None`) from a +/// field that should be cleared (`Some(None)`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UpdateSavedConsole { + pub name: Option, + pub data_source_id: Option>, + pub data_source_name: Option>, + pub database_name: Option>, + pub schema_name: Option>, + pub database_type: Option>, + pub ddl: Option, + pub status: Option, + pub tab_opened: Option, + pub operation_type: Option, +} + +/// Filters and stable paging controls for saved Consoles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavedConsoleListQuery { + pub data_source_id: Option, + pub database_name: Option, + pub schema_name: Option, + pub status: Option, + pub tab_opened: Option, + pub operation_type: Option, + pub search_key: Option, + pub page_no: u32, + pub page_size: u32, + /// Sort by update time descending instead of creation time ascending. + pub order_by_desc: bool, +} + +impl Default for SavedConsoleListQuery { + fn default() -> Self { + Self { + data_source_id: None, + database_name: None, + schema_name: None, + status: None, + tab_opened: None, + operation_type: None, + search_key: None, + page_no: 1, + page_size: 20, + order_by_desc: false, + } + } +} + +/// One durable saved Console record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavedConsoleRecord { + pub id: i64, + pub name: String, + pub data_source_id: Option, + pub data_source_name: Option, + pub database_name: Option, + pub schema_name: Option, + pub database_type: Option, + pub ddl: String, + pub status: String, + pub tab_opened: String, + pub operation_type: String, + /// Creation time as Unix epoch milliseconds. + pub created_at_ms: i64, + /// Last update time as Unix epoch milliseconds. + pub updated_at_ms: i64, +} + +/// One stable page of durable saved Consoles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavedConsolePage { + pub records: Vec, + pub total: u64, + pub page_no: u32, + pub page_size: u32, +} + +impl Storage { + /// Creates a saved Console and returns its durable record. + /// + /// A positive requested id is preserved for Community's manual-save + /// recovery flow. Otherwise `SQLite` allocates a non-reused numeric id. + /// + /// # Errors + /// + /// Returns validation or `SQLite` failures, including an id conflict. + pub fn create_saved_console( + &self, + input: CreateSavedConsole, + ) -> Result { + validate_create(&input)?; + let CreateSavedConsole { + id: requested_id, + name, + data_source_id, + data_source_name, + database_name, + schema_name, + database_type, + ddl, + status, + tab_opened, + operation_type, + } = input; + let timestamp = now_millis()?; + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let id = if let Some(id) = requested_id { + transaction.execute( + "INSERT INTO saved_consoles ( + id, name, data_source_id, data_source_name, database_name, schema_name, + database_type, ddl, status, tab_opened, operation_type, + created_at_ms, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)", + params![ + id, + name, + data_source_id, + data_source_name, + database_name, + schema_name, + database_type, + ddl, + status, + tab_opened, + operation_type, + timestamp, + ], + )?; + id + } else { + transaction.execute( + "INSERT INTO saved_consoles ( + name, data_source_id, data_source_name, database_name, schema_name, + database_type, ddl, status, tab_opened, operation_type, + created_at_ms, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)", + params![ + name, + data_source_id, + data_source_name, + database_name, + schema_name, + database_type, + ddl, + status, + tab_opened, + operation_type, + timestamp, + ], + )?; + transaction.last_insert_rowid() + }; + + let record = load_saved_console(&transaction, id)?.ok_or_else(|| { + StorageError::Integrity("saved Console disappeared before create commit".to_owned()) + })?; + transaction.commit()?; + Ok(record) + } + + /// Loads one saved Console by numeric Community id. + /// + /// # Errors + /// + /// Returns `SQLite` or persisted-data validation failures. + pub fn get_saved_console(&self, id: i64) -> Result, StorageError> { + if id <= 0 { + return Ok(None); + } + load_saved_console(&self.connection()?, id) + } + + /// Lists saved Consoles with Community-compatible filters and stable paging. + /// + /// # Errors + /// + /// Returns validation, `SQLite`, numeric-range, or persisted-data failures. + pub fn list_saved_consoles( + &self, + query: &SavedConsoleListQuery, + ) -> Result { + validate_list_query(query)?; + let offset = u64::from(query.page_no - 1) + .checked_mul(u64::from(query.page_size)) + .ok_or(StorageError::NumericRange("saved Console page offset"))?; + let offset = i64::try_from(offset) + .map_err(|_| StorageError::NumericRange("saved Console page offset"))?; + let limit = i64::from(query.page_size); + let search_pattern = query + .search_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(like_pattern); + let connection = self.connection()?; + + let total: i64 = connection.query_row( + "SELECT COUNT(*) FROM saved_consoles + WHERE (?1 IS NULL OR data_source_id = ?1) + AND (?2 IS NULL OR database_name = ?2) + AND (?3 IS NULL OR schema_name = ?3) + AND (?4 IS NULL OR status = ?4) + AND (?5 IS NULL OR tab_opened = ?5) + AND (?6 IS NULL OR operation_type = ?6) + AND ( + ?7 IS NULL + OR name COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(data_source_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(database_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(schema_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + )", + params![ + query.data_source_id.as_deref(), + query.database_name.as_deref(), + query.schema_name.as_deref(), + query.status.as_deref(), + query.tab_opened.as_deref(), + query.operation_type.as_deref(), + search_pattern.as_deref(), + ], + |row| row.get(0), + )?; + + let order = if query.order_by_desc { + "updated_at_ms DESC, id DESC" + } else { + "created_at_ms ASC, id ASC" + }; + let sql = format!( + "SELECT {SAVED_CONSOLE_COLUMNS} FROM saved_consoles + WHERE (?1 IS NULL OR data_source_id = ?1) + AND (?2 IS NULL OR database_name = ?2) + AND (?3 IS NULL OR schema_name = ?3) + AND (?4 IS NULL OR status = ?4) + AND (?5 IS NULL OR tab_opened = ?5) + AND (?6 IS NULL OR operation_type = ?6) + AND ( + ?7 IS NULL + OR name COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(data_source_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(database_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + OR COALESCE(schema_name, '') COLLATE NOCASE LIKE ?7 ESCAPE '\\' + ) + ORDER BY {order} + LIMIT ?8 OFFSET ?9" + ); + let mut statement = connection.prepare(&sql)?; + let rows = statement.query_map( + params![ + query.data_source_id.as_deref(), + query.database_name.as_deref(), + query.schema_name.as_deref(), + query.status.as_deref(), + query.tab_opened.as_deref(), + query.operation_type.as_deref(), + search_pattern.as_deref(), + limit, + offset, + ], + raw_saved_console, + )?; + let mut records = Vec::new(); + for row in rows { + records.push(decode_saved_console(row?)?); + } + + Ok(SavedConsolePage { + records, + total: u64::try_from(total) + .map_err(|_| StorageError::NumericRange("saved Console total"))?, + page_no: query.page_no, + page_size: query.page_size, + }) + } + + /// Applies a partial update and returns the complete durable record. + /// + /// # Errors + /// + /// Returns [`StorageError::SavedConsoleNotFound`] when the id is absent, + /// or validation and `SQLite` failures otherwise. + pub fn update_saved_console( + &self, + id: i64, + input: UpdateSavedConsole, + ) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = + load_saved_console(&transaction, id)?.ok_or(StorageError::SavedConsoleNotFound(id))?; + + let timestamp = now_millis()?.max(current.updated_at_ms); + let next = SavedConsoleRecord { + id, + name: input.name.unwrap_or(current.name), + data_source_id: input.data_source_id.unwrap_or(current.data_source_id), + data_source_name: input.data_source_name.unwrap_or(current.data_source_name), + database_name: input.database_name.unwrap_or(current.database_name), + schema_name: input.schema_name.unwrap_or(current.schema_name), + database_type: input.database_type.unwrap_or(current.database_type), + ddl: input.ddl.unwrap_or(current.ddl), + status: input.status.unwrap_or(current.status), + tab_opened: input.tab_opened.unwrap_or(current.tab_opened), + operation_type: input.operation_type.unwrap_or(current.operation_type), + created_at_ms: current.created_at_ms, + updated_at_ms: timestamp, + }; + validate_record(&next)?; + + let changed = transaction.execute( + "UPDATE saved_consoles + SET name = ?1, data_source_id = ?2, data_source_name = ?3, + database_name = ?4, schema_name = ?5, database_type = ?6, + ddl = ?7, status = ?8, tab_opened = ?9, operation_type = ?10, + updated_at_ms = ?11 + WHERE id = ?12", + params![ + next.name, + next.data_source_id, + next.data_source_name, + next.database_name, + next.schema_name, + next.database_type, + next.ddl, + next.status, + next.tab_opened, + next.operation_type, + next.updated_at_ms, + id, + ], + )?; + if changed != 1 { + return Err(StorageError::SavedConsoleNotFound(id)); + } + transaction.commit()?; + Ok(next) + } + + /// Deletes a saved Console, returning whether a record existed. + /// + /// # Errors + /// + /// Returns `SQLite` failures. + pub fn delete_saved_console(&self, id: i64) -> Result { + if id <= 0 { + return Ok(false); + } + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let deleted = transaction.execute("DELETE FROM saved_consoles WHERE id = ?1", [id])?; + transaction.commit()?; + Ok(deleted == 1) + } +} + +fn validate_create(input: &CreateSavedConsole) -> Result<(), StorageError> { + if input.id.is_some_and(|id| id <= 0) { + return Err(StorageError::InvalidSavedConsole( + "id must be a positive signed 64-bit integer", + )); + } + validate_fields( + &input.name, + input.data_source_id.as_deref(), + input.data_source_name.as_deref(), + input.database_name.as_deref(), + input.schema_name.as_deref(), + input.database_type.as_deref(), + &input.ddl, + &input.status, + &input.tab_opened, + &input.operation_type, + ) +} + +fn validate_record(record: &SavedConsoleRecord) -> Result<(), StorageError> { + if record.id <= 0 { + return Err(StorageError::InvalidSavedConsole( + "persisted id must be a positive signed 64-bit integer", + )); + } + if record.created_at_ms < 0 || record.updated_at_ms < record.created_at_ms { + return Err(StorageError::InvalidSavedConsole( + "persisted timestamps are invalid", + )); + } + validate_fields( + &record.name, + record.data_source_id.as_deref(), + record.data_source_name.as_deref(), + record.database_name.as_deref(), + record.schema_name.as_deref(), + record.database_type.as_deref(), + &record.ddl, + &record.status, + &record.tab_opened, + &record.operation_type, + ) +} + +#[allow(clippy::too_many_arguments)] +fn validate_fields( + name: &str, + data_source_id: Option<&str>, + data_source_name: Option<&str>, + database_name: Option<&str>, + schema_name: Option<&str>, + database_type: Option<&str>, + ddl: &str, + status: &str, + tab_opened: &str, + operation_type: &str, +) -> Result<(), StorageError> { + if name.len() > MAX_NAME_BYTES { + return Err(StorageError::InvalidSavedConsole( + "name must be at most 512 UTF-8 bytes", + )); + } + if data_source_id.is_some_and(|value| value.len() > MAX_DATASOURCE_ID_BYTES) { + return Err(StorageError::InvalidSavedConsole( + "datasource id must be at most 512 UTF-8 bytes", + )); + } + for value in [data_source_name, database_name, schema_name] + .into_iter() + .flatten() + { + if value.len() > MAX_SCOPE_BYTES { + return Err(StorageError::InvalidSavedConsole( + "datasource, database, and schema names must be at most 1024 UTF-8 bytes", + )); + } + } + if database_type.is_some_and(|value| value.len() > MAX_DATABASE_TYPE_BYTES) { + return Err(StorageError::InvalidSavedConsole( + "database type must be at most 255 UTF-8 bytes", + )); + } + if ddl.len() > MAX_DDL_BYTES { + return Err(StorageError::InvalidSavedConsole( + "SQL text must be at most 16 MiB", + )); + } + if status.is_empty() || status.len() > MAX_STATE_BYTES { + return Err(StorageError::InvalidSavedConsole( + "status must be non-empty and at most 255 UTF-8 bytes", + )); + } + if !matches!(tab_opened, "y" | "n") { + return Err(StorageError::InvalidSavedConsole( + "tab-opened must be either y or n", + )); + } + if operation_type.is_empty() || operation_type.len() > MAX_STATE_BYTES { + return Err(StorageError::InvalidSavedConsole( + "operation type must be non-empty and at most 255 UTF-8 bytes", + )); + } + Ok(()) +} + +fn validate_list_query(query: &SavedConsoleListQuery) -> Result<(), StorageError> { + if query.page_no == 0 { + return Err(StorageError::InvalidSavedConsole( + "page number must be greater than zero", + )); + } + if query.page_size == 0 || query.page_size > MAX_PAGE_SIZE { + return Err(StorageError::InvalidSavedConsole( + "page size must be between 1 and 1000", + )); + } + if query + .search_key + .as_ref() + .is_some_and(|value| value.len() > MAX_SEARCH_KEY_BYTES) + { + return Err(StorageError::InvalidSavedConsole( + "search key must be at most 256 UTF-8 bytes", + )); + } + Ok(()) +} + +fn like_pattern(value: &str) -> String { + let mut pattern = String::with_capacity(value.len() + 2); + pattern.push('%'); + for character in value.chars() { + if matches!(character, '%' | '_' | '\\') { + pattern.push('\\'); + } + pattern.push(character); + } + pattern.push('%'); + pattern +} + +struct RawSavedConsole { + id: i64, + name: String, + data_source_id: Option, + data_source_name: Option, + database_name: Option, + schema_name: Option, + database_type: Option, + ddl: String, + status: String, + tab_opened: String, + operation_type: String, + created_at_ms: i64, + updated_at_ms: i64, +} + +fn raw_saved_console(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(RawSavedConsole { + id: row.get(0)?, + name: row.get(1)?, + data_source_id: row.get(2)?, + data_source_name: row.get(3)?, + database_name: row.get(4)?, + schema_name: row.get(5)?, + database_type: row.get(6)?, + ddl: row.get(7)?, + status: row.get(8)?, + tab_opened: row.get(9)?, + operation_type: row.get(10)?, + created_at_ms: row.get(11)?, + updated_at_ms: row.get(12)?, + }) +} + +fn decode_saved_console(raw: RawSavedConsole) -> Result { + let record = SavedConsoleRecord { + id: raw.id, + name: raw.name, + data_source_id: raw.data_source_id, + data_source_name: raw.data_source_name, + database_name: raw.database_name, + schema_name: raw.schema_name, + database_type: raw.database_type, + ddl: raw.ddl, + status: raw.status, + tab_opened: raw.tab_opened, + operation_type: raw.operation_type, + created_at_ms: raw.created_at_ms, + updated_at_ms: raw.updated_at_ms, + }; + validate_record(&record)?; + Ok(record) +} + +fn load_saved_console( + connection: &Connection, + id: i64, +) -> Result, StorageError> { + let sql = format!("SELECT {SAVED_CONSOLE_COLUMNS} FROM saved_consoles WHERE id = ?1"); + connection + .query_row(&sql, [id], raw_saved_console) + .optional()? + .map(decode_saved_console) + .transpose() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tempfile::TempDir; + + use super::{CreateSavedConsole, SavedConsoleListQuery, UpdateSavedConsole}; + use crate::{SecretRef, SecretValue, SecretVault, SecretVaultError, Storage, StorageError}; + + #[derive(Debug)] + struct EmptyVault; + + impl SecretVault for EmptyVault { + fn probe(&self) -> Result<(), SecretVaultError> { + Ok(()) + } + + fn create( + &self, + _reference: &SecretRef, + _value: &SecretValue, + ) -> Result<(), SecretVaultError> { + Ok(()) + } + + fn get(&self, _reference: &SecretRef) -> Result, SecretVaultError> { + Ok(None) + } + + fn delete(&self, _reference: &SecretRef) -> Result<(), SecretVaultError> { + Ok(()) + } + } + + fn open(directory: &TempDir) -> Storage { + Storage::open(directory.path(), Arc::new(EmptyVault)).expect("storage opens") + } + + fn input(name: &str, datasource_id: &str) -> CreateSavedConsole { + CreateSavedConsole { + id: None, + name: name.to_owned(), + data_source_id: Some(datasource_id.to_owned()), + data_source_name: Some("Local MySQL".to_owned()), + database_name: Some("chat2db".to_owned()), + schema_name: Some("public".to_owned()), + database_type: Some("MYSQL".to_owned()), + ddl: "SELECT 1".to_owned(), + status: "DRAFT".to_owned(), + tab_opened: "y".to_owned(), + operation_type: "console".to_owned(), + } + } + + #[test] + fn saved_console_crud_survives_reopen() { + let directory = TempDir::new().expect("temp dir"); + let storage = open(&directory); + let created = storage + .create_saved_console(input("orders", "datasource-1")) + .expect("Console creates"); + assert!(created.id > 0); + assert_eq!( + storage + .get_saved_console(created.id) + .expect("Console reads"), + Some(created.clone()) + ); + + let updated = storage + .update_saved_console( + created.id, + UpdateSavedConsole { + name: Some("orders-release".to_owned()), + schema_name: Some(None), + ddl: Some("SELECT * FROM orders".to_owned()), + status: Some("RELEASE".to_owned()), + tab_opened: Some("n".to_owned()), + ..UpdateSavedConsole::default() + }, + ) + .expect("Console updates"); + assert_eq!(updated.name, "orders-release"); + assert_eq!(updated.schema_name, None); + assert_eq!(updated.status, "RELEASE"); + assert!(updated.updated_at_ms >= created.updated_at_ms); + + drop(storage); + let reopened = open(&directory); + assert_eq!( + reopened + .get_saved_console(created.id) + .expect("reopened Console reads"), + Some(updated) + ); + assert!( + reopened + .delete_saved_console(created.id) + .expect("Console deletes") + ); + assert!( + !reopened + .delete_saved_console(created.id) + .expect("missing Console delete is idempotent") + ); + assert_eq!( + reopened + .get_saved_console(created.id) + .expect("deleted Console reads"), + None + ); + } + + #[test] + fn requested_numeric_identity_can_be_restored_without_reuse() { + let directory = TempDir::new().expect("temp dir"); + let storage = open(&directory); + let mut requested = input("restored", "datasource-1"); + requested.id = Some(42); + let first = storage + .create_saved_console(requested.clone()) + .expect("requested id creates"); + assert_eq!(first.id, 42); + assert!(storage.delete_saved_console(42).expect("record deletes")); + + let restored = storage + .create_saved_console(requested) + .expect("same deleted id restores"); + assert_eq!(restored.id, 42); + let allocated = storage + .create_saved_console(input("next", "datasource-1")) + .expect("next id allocates"); + assert!(allocated.id > 42); + } + + #[test] + fn list_filters_pages_searches_and_sorts_stably() { + let directory = TempDir::new().expect("temp dir"); + let storage = open(&directory); + let alpha = storage + .create_saved_console(input("Alpha report", "datasource-a")) + .expect("alpha creates"); + let mut beta_input = input("Beta report", "datasource-a"); + beta_input.database_name = Some("analytics".to_owned()); + beta_input.status = "RELEASE".to_owned(); + beta_input.tab_opened = "n".to_owned(); + let beta = storage + .create_saved_console(beta_input) + .expect("beta creates"); + let gamma = storage + .create_saved_console(input("Gamma", "datasource-b")) + .expect("gamma creates"); + + let first_page = storage + .list_saved_consoles(&SavedConsoleListQuery { + page_size: 2, + ..SavedConsoleListQuery::default() + }) + .expect("first page lists"); + assert_eq!(first_page.total, 3); + assert_eq!( + first_page + .records + .iter() + .map(|record| record.id) + .collect::>(), + vec![alpha.id, beta.id] + ); + + let descending = storage + .list_saved_consoles(&SavedConsoleListQuery { + page_size: 3, + order_by_desc: true, + ..SavedConsoleListQuery::default() + }) + .expect("descending page lists"); + assert_eq!( + descending + .records + .iter() + .map(|record| record.id) + .collect::>(), + vec![gamma.id, beta.id, alpha.id] + ); + + let filtered = storage + .list_saved_consoles(&SavedConsoleListQuery { + data_source_id: Some("datasource-a".to_owned()), + database_name: Some("analytics".to_owned()), + status: Some("RELEASE".to_owned()), + tab_opened: Some("n".to_owned()), + operation_type: Some("console".to_owned()), + search_key: Some("beta".to_owned()), + ..SavedConsoleListQuery::default() + }) + .expect("filtered page lists"); + assert_eq!(filtered.total, 1); + assert_eq!(filtered.records[0].id, beta.id); + } + + #[test] + fn invalid_updates_and_pages_fail_closed() { + let directory = TempDir::new().expect("temp dir"); + let storage = open(&directory); + let record = storage + .create_saved_console(input("query", "datasource-1")) + .expect("Console creates"); + + let invalid_update = storage + .update_saved_console( + record.id, + UpdateSavedConsole { + tab_opened: Some("maybe".to_owned()), + ..UpdateSavedConsole::default() + }, + ) + .expect_err("invalid tab flag must fail"); + assert!(matches!( + invalid_update, + StorageError::InvalidSavedConsole(_) + )); + assert_eq!( + storage + .get_saved_console(record.id) + .expect("record remains readable") + .expect("record remains present") + .tab_opened, + "y" + ); + + let invalid_page = storage + .list_saved_consoles(&SavedConsoleListQuery { + page_no: 0, + ..SavedConsoleListQuery::default() + }) + .expect_err("zero page must fail"); + assert!(matches!(invalid_page, StorageError::InvalidSavedConsole(_))); + assert!(matches!( + storage + .update_saved_console(9_999, UpdateSavedConsole::default()) + .expect_err("missing update must fail"), + StorageError::SavedConsoleNotFound(9_999) + )); + } +} diff --git a/crates/chat2db-storage/tests/public_api.rs b/crates/chat2db-storage/tests/public_api.rs index de0009f..c40799b 100644 --- a/crates/chat2db-storage/tests/public_api.rs +++ b/crates/chat2db-storage/tests/public_api.rs @@ -1,8 +1,9 @@ use chat2db_storage::{ AgentCompaction, AgentMessageRole, AgentRunStatus, AgentRunUpdate, AppendAgentMessage, - CompactAgentRun, CreateAgentSession, CreateProviderProfile, MAX_AGENT_MESSAGE_BYTES, - MAX_RESULT_PAGE_BYTES, MAX_RESULT_PAGE_ROWS, MIN_RESULT_PAGE_BYTES, PageRequest, ProviderKind, - PurgeReport, ToolPermissionDecision, UpdateAgentSession, + CompactAgentRun, CreateAgentSession, CreateProviderProfile, CreateSavedConsole, + MAX_AGENT_MESSAGE_BYTES, MAX_RESULT_PAGE_BYTES, MAX_RESULT_PAGE_ROWS, MIN_RESULT_PAGE_BYTES, + PageRequest, ProviderKind, PurgeReport, SavedConsoleListQuery, ToolPermissionDecision, + UpdateAgentSession, UpdateSavedConsole, }; #[test] @@ -19,6 +20,39 @@ fn paging_and_purge_contracts_are_nameable_outside_the_crate() { assert_eq!(report.results_removed, 0); } +#[test] +fn saved_console_contracts_are_nameable_outside_the_crate() { + let create = CreateSavedConsole { + id: Some(42), + name: "query".to_owned(), + data_source_id: Some("opaque-datasource-id".to_owned()), + data_source_name: Some("Local MySQL".to_owned()), + database_name: Some("chat2db".to_owned()), + schema_name: None, + database_type: Some("MYSQL".to_owned()), + ddl: "SELECT 1".to_owned(), + status: "DRAFT".to_owned(), + tab_opened: "y".to_owned(), + operation_type: "console".to_owned(), + }; + let query = SavedConsoleListQuery { + data_source_id: Some("opaque-datasource-id".to_owned()), + page_size: 100, + ..SavedConsoleListQuery::default() + }; + let update = UpdateSavedConsole { + schema_name: Some(None), + ddl: Some("SELECT 1".to_owned()), + ..UpdateSavedConsole::default() + }; + + assert_eq!(create.id, Some(42)); + assert_eq!(query.page_no, 1); + assert_eq!(query.page_size, 100); + assert_eq!(update.schema_name, Some(None)); + assert_eq!(update.ddl.as_deref(), Some("SELECT 1")); +} + #[test] fn provider_and_agent_contracts_are_nameable_outside_the_crate() { let provider = CreateProviderProfile { From 78e92d61f28ca3a3966e0d2f9178410326ed186a Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 14:20:27 +0800 Subject: [PATCH 06/19] feat(web): implement Community Console APIs --- apps/chat2db-web/Cargo.toml | 2 +- apps/chat2db-web/src/legacy.rs | 774 ++++++++++++++++++++++++++++++++- apps/chat2db-web/src/lib.rs | 192 ++++++++ 3 files changed, 956 insertions(+), 12 deletions(-) diff --git a/apps/chat2db-web/Cargo.toml b/apps/chat2db-web/Cargo.toml index 9328fbc..1af8b43 100644 --- a/apps/chat2db-web/Cargo.toml +++ b/apps/chat2db-web/Cargo.toml @@ -15,6 +15,7 @@ chat2db-contract = { path = "../../crates/chat2db-contract" } chat2db-core = { path = "../../crates/chat2db-core" } chat2db-java-bridge = { path = "../../crates/chat2db-java-bridge" } chat2db-local = { path = "../../crates/chat2db-local" } +chat2db-storage = { path = "../../crates/chat2db-storage" } futures-util.workspace = true serde.workspace = true serde_json.workspace = true @@ -27,7 +28,6 @@ utoipa.workspace = true utoipa-axum.workspace = true [dev-dependencies] -chat2db-storage = { path = "../../crates/chat2db-storage" } http-body-util.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index 220a481..70cb056 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -4,7 +4,10 @@ //! Axum handlers at the bottom. Desktop IPC can reuse the same request and //! response translations without duplicating datasource or JDBC behavior. -use std::{collections::HashSet, time::Duration}; +use std::{ + collections::HashSet, + time::{Duration, Instant}, +}; use axum::{ Json, Router, @@ -15,18 +18,25 @@ use chat2db_contract::{ ApiError, ColumnNullability, CommunityTable, Datasource, DatasourceConnection, DatasourceConnectionProperty, DatasourceSecretChange, JdbcDriver, JdbcValue, JdbcValueType, ListCommunityDatabasesRequest, ListCommunitySchemasRequest, ListCommunityTablesRequest, - OperationEvent, ResultColumn, ResultMetadata, ResultPageRequest, - StartCommunityTablePreviewRequest, UpdateDatasourceRequest, + OperationEvent, QueryAccepted, QueryLimits, ResultColumn, ResultMetadata, ResultPageRequest, + StartCommunityTablePreviewRequest, StartQueryRequest, UpdateDatasourceRequest, }; use chat2db_core::{AppError, Application}; +use chat2db_storage::{ + CreateSavedConsole, SavedConsoleListQuery, SavedConsoleRecord, Storage, StorageError, + UpdateSavedConsole, +}; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; const DEFAULT_PAGE_NO: u32 = 1; const DEFAULT_PAGE_SIZE: u32 = 20; const DEFAULT_PREVIEW_PAGE_SIZE: u32 = 200; +const DEFAULT_SQL_PAGE_SIZE: u32 = 200; const MAX_PREVIEW_ROWS: u32 = 1_000; +const MAX_SQL_ROWS: u32 = 10_000; const RESULT_PAGE_MAX_BYTES: u64 = 8 * 1024 * 1024; const PREVIEW_TIMEOUT: Duration = Duration::from_secs(30); +const SQL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); /// Community's historical response wrapper. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -310,7 +320,8 @@ pub struct LegacyManageResult { pub page_size: u32, pub fuzzy_total: String, pub has_next_page: bool, - pub execute_sql_params: LegacyTablePreviewRequest, + pub execute_sql_params: LegacySqlExecuteRequest, + pub extra: serde_json::Value, } #[derive(Debug, Clone, Deserialize)] @@ -324,6 +335,121 @@ pub struct LegacyPageQuery { pub search_key: String, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySavedConsoleCreateRequest { + #[serde(default)] + pub id: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub name: String, + #[serde(default)] + pub data_source_id: Option, + #[serde(default)] + pub data_source_name: Option, + #[serde(default)] + pub database_name: Option, + #[serde(default)] + pub schema_name: Option, + #[serde(rename = "type", default)] + pub database_type: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub ddl: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub status: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub tab_opened: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub operation_type: String, +} + +#[derive(Debug, Clone, Default)] +pub enum LegacyPatch { + #[default] + Unset, + Set(Option), +} + +impl<'de, T> Deserialize<'de> for LegacyPatch +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer).map(Self::Set) + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySavedConsoleUpdateRequest { + pub id: i64, + #[serde(default)] + pub name: LegacyPatch, + #[serde(default)] + pub data_source_id: LegacyPatch, + #[serde(default)] + pub data_source_name: LegacyPatch, + #[serde(default)] + pub database_name: LegacyPatch, + #[serde(default)] + pub schema_name: LegacyPatch, + #[serde(rename = "type", default)] + pub database_type: LegacyPatch, + #[serde(default)] + pub ddl: LegacyPatch, + #[serde(default)] + pub status: LegacyPatch, + #[serde(default)] + pub tab_opened: LegacyPatch, + #[serde(default)] + pub operation_type: LegacyPatch, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySavedConsoleListQuery { + #[serde(default = "default_page_no")] + pub page_no: u32, + #[serde(default = "default_page_size")] + pub page_size: u32, + #[serde(default)] + pub data_source_id: Option, + #[serde(default)] + pub database_name: Option, + #[serde(default)] + pub schema_name: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub tab_opened: Option, + #[serde(default)] + pub operation_type: Option, + #[serde(default)] + pub search_key: Option, + #[serde(default)] + pub order_by_desc: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySavedConsoleResponse { + pub id: i64, + pub name: String, + pub data_source_id: Option, + pub data_source_name: Option, + pub connectable: bool, + pub database_name: Option, + pub schema_name: Option, + #[serde(rename = "type")] + pub database_type: Option, + pub ddl: String, + pub status: String, + pub tab_opened: String, + pub operation_type: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LegacyIdQuery { @@ -387,6 +513,37 @@ pub struct LegacyTablePreviewRequest { pub sql: String, } +/// Community's synchronous SQL execution payload. Extra frontend fields are +/// intentionally ignored by Serde so this remains compatible across pinned UI +/// revisions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySqlExecuteRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub data_source_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub sql: String, + #[serde(default)] + pub single: bool, + #[serde(default = "default_page_no")] + pub page_no: u32, + #[serde(default = "default_sql_page_size")] + pub page_size: u32, + #[serde(default)] + pub console_id: Option, + #[serde(default)] + pub result_set_id: Option, +} + /// Transport-neutral request used by the retained desktop command-line bridge. #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -424,6 +581,29 @@ fn default_preview_page_size() -> u32 { DEFAULT_PREVIEW_PAGE_SIZE } +fn default_sql_page_size() -> u32 { + DEFAULT_SQL_PAGE_SIZE +} + +impl From<&LegacyTablePreviewRequest> for LegacySqlExecuteRequest { + fn from(request: &LegacyTablePreviewRequest) -> Self { + Self { + data_source_id: request.data_source_id.clone(), + data_source_name: String::new(), + database_name: request.database_name.clone(), + schema_name: request.schema_name.clone(), + database_type: request.database_type.clone(), + table_name: request.table_name.clone(), + sql: request.sql.clone(), + single: true, + page_no: request.page_no, + page_size: request.page_size, + console_id: None, + result_set_id: None, + } + } +} + fn default_environment() -> LegacyEnvironment { LegacyEnvironment { id: 1, @@ -599,6 +779,111 @@ pub(crate) async fn delete_datasource( Ok(()) } +pub(crate) async fn create_saved_console( + application: &Application, + request: &LegacySavedConsoleCreateRequest, +) -> LegacyResult { + let storage = legacy_storage(application)?; + let input = CreateSavedConsole { + id: request.id, + name: request.name.clone(), + data_source_id: request + .data_source_id + .as_ref() + .map(LegacyIdentifier::as_string), + data_source_name: request.data_source_name.clone(), + database_name: request.database_name.clone(), + schema_name: request.schema_name.clone(), + database_type: request.database_type.clone(), + ddl: request.ddl.clone(), + status: default_if_blank(&request.status, "DRAFT"), + // Community always opens a newly created Console. + tab_opened: "y".to_owned(), + operation_type: default_if_blank(&request.operation_type, "console"), + }; + legacy_storage_call(move || storage.create_saved_console(input)) + .await + .map(|record| record.id) +} + +pub(crate) async fn get_saved_console( + application: &Application, + id: i64, +) -> LegacyResult> { + let storage = legacy_storage(application)?; + legacy_storage_call(move || storage.get_saved_console(id)) + .await + .map(|record| record.map(saved_console_response)) +} + +pub(crate) async fn list_saved_consoles( + application: &Application, + query: &LegacySavedConsoleListQuery, +) -> LegacyResult> { + let storage = legacy_storage(application)?; + let storage_query = SavedConsoleListQuery { + data_source_id: query + .data_source_id + .as_ref() + .map(LegacyIdentifier::as_string), + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + status: query.status.clone(), + tab_opened: query.tab_opened.clone(), + operation_type: query.operation_type.clone(), + search_key: query.search_key.clone(), + page_no: query.page_no, + page_size: query.page_size, + order_by_desc: query.order_by_desc, + }; + legacy_storage_call(move || storage.list_saved_consoles(&storage_query)) + .await + .map(|page| { + let total = usize::try_from(page.total).unwrap_or(usize::MAX); + let data = page + .records + .into_iter() + .map(saved_console_response) + .collect::>(); + LegacyPage { + has_next_page: u64::from(page.page_no) * u64::from(page.page_size) < page.total, + data, + page_no: page.page_no, + page_size: page.page_size, + total, + } + }) +} + +pub(crate) async fn update_saved_console( + application: &Application, + request: LegacySavedConsoleUpdateRequest, +) -> LegacyResult<()> { + let storage = legacy_storage(application)?; + let input = UpdateSavedConsole { + name: required_string_patch(request.name), + data_source_id: identifier_patch(request.data_source_id), + data_source_name: nullable_string_patch(request.data_source_name), + database_name: nullable_string_patch(request.database_name), + schema_name: nullable_string_patch(request.schema_name), + database_type: nullable_string_patch(request.database_type), + ddl: required_string_patch(request.ddl), + status: required_string_patch(request.status), + tab_opened: required_string_patch(request.tab_opened), + operation_type: required_string_patch(request.operation_type), + }; + legacy_storage_call(move || storage.update_saved_console(request.id, input)) + .await + .map(|_| ()) +} + +pub(crate) async fn delete_saved_console(application: &Application, id: i64) -> LegacyResult<()> { + let storage = legacy_storage(application)?; + legacy_storage_call(move || storage.delete_saved_console(id)) + .await + .map(|_| ()) +} + /// Builds the flat namespace tree used when no custom grouping exists. pub(crate) async fn namespace_tree( application: &Application, @@ -758,7 +1043,7 @@ pub(crate) async fn preview_table( let preview_result = tokio::time::timeout( PREVIEW_TIMEOUT, - wait_for_result(application, &accepted.operation_id), + wait_for_sql_execution(application, &accepted.operation_id), ) .await; let Ok(preview_result) = preview_result else { @@ -812,11 +1097,61 @@ pub(crate) async fn preview_table( page_size: request.page_size, fuzzy_total: page.metadata.row_count, has_next_page, - execute_sql_params: request.clone(), + execute_sql_params: LegacySqlExecuteRequest::from(request), + extra: serde_json::json!({}), }]) } -async fn wait_for_result( +/// Starts one Community Console query through Core and returns the opaque +/// operation id used by both HTTP and desktop transports. +/// +/// # Errors +/// +/// Returns request validation, datasource, storage, or engine failures before +/// the operation is accepted. +pub async fn start_sql_execution( + application: &Application, + request: &LegacySqlExecuteRequest, +) -> LegacyResult { + let (_, row_limit) = sql_page_window(request)?; + let datasource_id = request.data_source_id.as_string(); + if datasource_id.trim().is_empty() { + return Err(LegacyFailure::invalid( + "invalid_sql_execute_request", + "dataSourceId is required", + )); + } + if request.sql.trim().is_empty() { + return Err(LegacyFailure::invalid( + "invalid_sql_execute_request", + "sql is required", + )); + } + application + .start_query(StartQueryRequest { + datasource_id, + sql: request.sql.clone(), + parameters: Vec::new(), + limits: QueryLimits { + max_rows: row_limit.to_string(), + max_result_bytes: RESULT_PAGE_MAX_BYTES.to_string(), + batch_rows: request.page_size.min(512), + batch_bytes: 1024 * 1024, + result_ttl_seconds: 60, + }, + }) + .await + .map_err(Into::into) +} + +/// Waits for a Core query terminal event without translating away its error +/// code or message. Desktop streaming can subscribe independently and use +/// this as the final retained-result barrier. +/// +/// # Errors +/// +/// Returns subscription, database execution, or cancellation failures. +pub async fn wait_for_sql_execution( application: &Application, operation_id: &str, ) -> LegacyResult { @@ -827,19 +1162,324 @@ async fn wait_for_result( OperationEvent::Failed { error } => return Err(LegacyFailure::from_api(error)), OperationEvent::Cancelled { .. } => { return Err(LegacyFailure::invalid( - "table_preview_cancelled", - "The table preview was cancelled", + "sql_execution_cancelled", + "The SQL execution was cancelled", )); } OperationEvent::Started | OperationEvent::Progress { .. } => {} } } Err(LegacyFailure::invalid( - "table_preview_incomplete", - "The table preview ended without a result", + "sql_execution_incomplete", + "The SQL execution ended without a result", )) } +/// Reads and translates a retained Core result into Community's historical +/// result-grid shape. This is shared by synchronous HTTP and desktop IPC. +/// +/// # Errors +/// +/// Returns invalid paging or retained-result read failures. +pub async fn read_sql_result( + application: &Application, + request: &LegacySqlExecuteRequest, + metadata: &ResultMetadata, + duration: u64, +) -> LegacyResult { + let (offset, _) = sql_page_window(request)?; + let page = application + .result_page( + &metadata.id, + ResultPageRequest { + offset: offset.to_string(), + max_rows: request.page_size.to_string(), + max_bytes: RESULT_PAGE_MAX_BYTES.to_string(), + }, + ) + .await?; + let has_next_page = page.has_more + || page.metadata.truncated_by_max_rows + || page.metadata.truncated_by_max_result_bytes; + let fuzzy_total = + if page.metadata.truncated_by_max_rows || page.metadata.truncated_by_max_result_bytes { + format!("{}+", page.metadata.row_count) + } else { + page.metadata.row_count.clone() + }; + let header_list = page.columns.iter().map(result_header).collect(); + let data_list = page + .rows + .into_iter() + .map(|row| { + row.values + .into_iter() + .zip(page.columns.iter()) + .map(|(value, column)| result_cell(value, column)) + .collect() + }) + .collect(); + Ok(LegacyManageResult { + data_list, + header_list, + description: "Query executed successfully".to_owned(), + message: String::new(), + sql: request.sql.clone(), + original_sql: request.sql.clone(), + success: true, + duration, + update_count: 0, + can_edit: false, + table_name: request.table_name.clone(), + sql_type: legacy_sql_type(&request.sql).to_owned(), + refresh_targets: Vec::new(), + page_no: request.page_no, + page_size: request.page_size, + fuzzy_total, + has_next_page, + execute_sql_params: request.clone(), + extra: serde_json::json!({}), + }) +} + +/// Executes the synchronous Community web contract while retaining Core's +/// asynchronous operation and result-store lifecycle internally. +/// +/// # Errors +/// +/// Returns request validation or failures that occur before Core accepts the +/// query. Failures after acceptance are returned as Community result items. +pub async fn execute_sql( + application: &Application, + request: &LegacySqlExecuteRequest, +) -> LegacyResult> { + let started_at = Instant::now(); + let accepted = start_sql_execution(application, request).await?; + let terminal = tokio::time::timeout( + SQL_EXECUTION_TIMEOUT, + wait_for_sql_execution(application, &accepted.operation_id), + ) + .await; + let duration = elapsed_millis(started_at); + match terminal { + Ok(Ok(metadata)) => read_sql_result(application, request, &metadata, duration) + .await + .map(|result| vec![result]), + Ok(Err(error)) => Ok(vec![sql_failure_result(request, &error, duration)]), + Err(_) => { + application.cancel_operation(&accepted.operation_id).await; + Ok(vec![sql_failure_result( + request, + &LegacyFailure::invalid( + "sql_execution_timeout", + "The SQL execution did not finish in time", + ), + duration, + )]) + } + } +} + +fn sql_page_window(request: &LegacySqlExecuteRequest) -> LegacyResult<(u32, u32)> { + if request.page_no == 0 || request.page_size == 0 { + return Err(LegacyFailure::invalid( + "invalid_sql_execute_request", + "pageNo and pageSize must be positive", + )); + } + let offset = request + .page_no + .saturating_sub(1) + .checked_mul(request.page_size) + .ok_or_else(|| { + LegacyFailure::invalid( + "invalid_sql_execute_request", + "requested page is outside the SQL result window", + ) + })?; + let row_limit = offset.checked_add(request.page_size).ok_or_else(|| { + LegacyFailure::invalid( + "invalid_sql_execute_request", + "requested page is outside the SQL result window", + ) + })?; + if offset >= MAX_SQL_ROWS || row_limit > MAX_SQL_ROWS { + return Err(LegacyFailure::invalid( + "invalid_sql_execute_request", + "SQL results are limited to the first 10000 rows", + )); + } + Ok((offset, row_limit)) +} + +/// Builds the Community result-grid error item used for accepted operations +/// that fail asynchronously. Keeping this public prevents desktop IPC from +/// inventing a second error projection. +#[must_use] +pub fn sql_failure_result( + request: &LegacySqlExecuteRequest, + error: &LegacyFailure, + duration: u64, +) -> LegacyManageResult { + let message = error.message.clone(); + LegacyManageResult { + data_list: Vec::new(), + header_list: Vec::new(), + description: String::new(), + message: message.clone(), + sql: request.sql.clone(), + original_sql: request.sql.clone(), + success: false, + duration, + update_count: 0, + can_edit: false, + table_name: request.table_name.clone(), + sql_type: legacy_sql_type(&request.sql).to_owned(), + refresh_targets: Vec::new(), + page_no: request.page_no, + page_size: request.page_size, + fuzzy_total: "0".to_owned(), + has_next_page: false, + execute_sql_params: request.clone(), + extra: serde_json::json!({ + "messages": [{ + "level": "ERROR", + "message": message, + "source": "database", + "errorCode": error.code, + }] + }), + } +} + +fn elapsed_millis(started_at: Instant) -> u64 { + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn legacy_sql_type(sql: &str) -> &'static str { + let keyword = sql + .trim_start() + .split(|character: char| !character.is_ascii_alphabetic()) + .next() + .unwrap_or_default(); + if keyword.eq_ignore_ascii_case("select") + || keyword.eq_ignore_ascii_case("with") + || keyword.eq_ignore_ascii_case("show") + || keyword.eq_ignore_ascii_case("describe") + || keyword.eq_ignore_ascii_case("desc") + || keyword.eq_ignore_ascii_case("explain") + { + "SELECT" + } else { + "OTHER" + } +} + +fn saved_console_response(record: SavedConsoleRecord) -> LegacySavedConsoleResponse { + let connectable = record + .data_source_id + .as_ref() + .is_some_and(|id| !id.trim().is_empty()); + LegacySavedConsoleResponse { + id: record.id, + name: record.name, + data_source_id: record.data_source_id, + data_source_name: record.data_source_name, + connectable, + database_name: record.database_name, + schema_name: record.schema_name, + database_type: record.database_type, + ddl: record.ddl, + status: record.status, + tab_opened: record.tab_opened, + operation_type: record.operation_type, + } +} + +fn required_string_patch(patch: LegacyPatch) -> Option { + match patch { + LegacyPatch::Unset | LegacyPatch::Set(None) => None, + LegacyPatch::Set(Some(value)) => Some(value), + } +} + +#[allow(clippy::option_option)] +fn nullable_string_patch(patch: LegacyPatch) -> Option> { + match patch { + LegacyPatch::Unset => None, + LegacyPatch::Set(value) => Some(value), + } +} + +#[allow(clippy::option_option)] +fn identifier_patch(patch: LegacyPatch) -> Option> { + match patch { + LegacyPatch::Unset => None, + LegacyPatch::Set(value) => Some(value.map(|id| id.as_string())), + } +} + +fn legacy_console_id(id: &LegacyIdentifier) -> LegacyResult { + let parsed = id.as_string().parse::().map_err(|_| { + LegacyFailure::invalid( + "invalid_saved_console", + "id must be a positive signed 64-bit integer", + ) + })?; + if parsed <= 0 { + return Err(LegacyFailure::invalid( + "invalid_saved_console", + "id must be a positive signed 64-bit integer", + )); + } + Ok(parsed) +} + +fn default_if_blank(value: &str, fallback: &str) -> String { + if value.trim().is_empty() { + fallback.to_owned() + } else { + value.to_owned() + } +} + +fn legacy_storage(application: &Application) -> LegacyResult { + application.storage().cloned().ok_or_else(|| { + LegacyFailure::invalid( + "storage_unavailable", + "Local product storage is not configured", + ) + }) +} + +async fn legacy_storage_call(operation: F) -> LegacyResult +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(|_| LegacyFailure { + code: "internal_error".to_owned(), + message: "The operation could not be completed".to_owned(), + })? + .map_err(storage_failure) +} + +fn storage_failure(error: StorageError) -> LegacyFailure { + match error { + StorageError::SavedConsoleNotFound(id) => LegacyFailure { + code: "saved_console_not_found".to_owned(), + message: format!("Saved Console {id} does not exist"), + }, + StorageError::InvalidSavedConsole(message) => LegacyFailure { + code: "invalid_saved_console".to_owned(), + message: message.to_owned(), + }, + other => LegacyFailure::from(AppError::from(other)), + } +} + fn datasource_response( application: &Application, datasource: Datasource, @@ -1173,6 +1813,7 @@ fn paginate(items: Vec, page_no: u32, page_size: u32) -> LegacyPage { /// /// Tauri IPC can pass its `requestUrl`, `method`, and `message` fields here and /// return the resulting JSON value unchanged. +#[allow(clippy::too_many_lines)] pub async fn dispatch( application: &Application, request: LegacyDispatchRequest, @@ -1225,6 +1866,38 @@ pub async fn dispatch( Err(error) => Err(error), } } + ("post", "/api/operation/saved/create") => { + match decode::(request.message) { + Ok(body) => serialized(create_saved_console(application, &body).await), + Err(error) => Err(error), + } + } + ("get", "/api/operation/saved/list") => { + match decode::(request.message) { + Ok(query) => serialized(list_saved_consoles(application, &query).await), + Err(error) => Err(error), + } + } + ("get", "/api/operation/saved") => match decode::(request.message) { + Ok(query) => match legacy_console_id(&query.id) { + Ok(id) => serialized(get_saved_console(application, id).await), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }, + ("post" | "put", "/api/operation/saved/update") => { + match decode::(request.message) { + Ok(body) => serialized(update_saved_console(application, body).await), + Err(error) => Err(error), + } + } + ("delete", "/api/operation/saved") => match decode::(request.message) { + Ok(query) => match legacy_console_id(&query.id) { + Ok(id) => serialized(delete_saved_console(application, id).await), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }, ("get", "/api/namespaces/tree_list") => serialized(namespace_tree(application).await), ("get", "/api/rdb/database/list") => match decode::(request.message) { Ok(query) => serialized(list_databases(application, &query).await), @@ -1244,6 +1917,12 @@ pub async fn dispatch( Err(error) => Err(error), } } + ("post" | "put", "/api/rdb/dml/execute") => { + match decode::(request.message) { + Ok(body) => serialized(execute_sql(application, &body).await), + Err(error) => Err(error), + } + } (_, known_path) if LEGACY_PATHS.contains(&known_path) => Err(LegacyFailure::invalid( "method_not_allowed", "The request method is not supported for this route", @@ -1265,10 +1944,15 @@ const LEGACY_PATHS: &[&str] = &[ "/api/connection/datasource/create", "/api/connection/datasource/pre_connect", "/api/connection/datasource/update", + "/api/operation/saved/create", + "/api/operation/saved/list", + "/api/operation/saved", + "/api/operation/saved/update", "/api/namespaces/tree_list", "/api/rdb/database/list", "/api/rdb/schema/list", "/api/rdb/table/list", + "/api/rdb/dml/execute", "/api/rdb/dml/execute_table", ]; @@ -1332,10 +2016,30 @@ pub(crate) fn routes() -> Router { "/api/connection/datasource/update", post(update_datasource_handler).put(update_datasource_handler), ) + .route( + "/api/operation/saved/create", + post(create_saved_console_handler), + ) + .route( + "/api/operation/saved/list", + get(list_saved_consoles_handler), + ) + .route( + "/api/operation/saved", + get(get_saved_console_handler).delete(delete_saved_console_handler), + ) + .route( + "/api/operation/saved/update", + post(update_saved_console_handler).put(update_saved_console_handler), + ) .route("/api/namespaces/tree_list", get(namespace_tree_handler)) .route("/api/rdb/database/list", get(database_list_handler)) .route("/api/rdb/schema/list", get(schema_list_handler)) .route("/api/rdb/table/list", get(table_list_handler)) + .route( + "/api/rdb/dml/execute", + post(sql_execute_handler).put(sql_execute_handler), + ) .route( "/api/rdb/dml/execute_table", post(table_preview_handler).put(table_preview_handler), @@ -1408,6 +2112,47 @@ async fn delete_datasource_handler( envelope(delete_datasource(&application, &query.id).await) } +async fn create_saved_console_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(create_saved_console(&application, &request).await) +} + +async fn list_saved_consoles_handler( + State(application): State, + Query(query): Query, +) -> Json>> { + envelope(list_saved_consoles(&application, &query).await) +} + +async fn get_saved_console_handler( + State(application): State, + Query(query): Query, +) -> Json>> { + envelope(match legacy_console_id(&query.id) { + Ok(id) => get_saved_console(&application, id).await, + Err(error) => Err(error), + }) +} + +async fn update_saved_console_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(update_saved_console(&application, request).await) +} + +async fn delete_saved_console_handler( + State(application): State, + Query(query): Query, +) -> Json> { + envelope(match legacy_console_id(&query.id) { + Ok(id) => delete_saved_console(&application, id).await, + Err(error) => Err(error), + }) +} + async fn namespace_tree_handler( State(application): State, ) -> Json>> { @@ -1441,3 +2186,10 @@ async fn table_preview_handler( ) -> Json>> { envelope(preview_table(&application, &request).await) } + +async fn sql_execute_handler( + State(application): State, + Json(request): Json, +) -> Json>> { + envelope(execute_sql(&application, &request).await) +} diff --git a/apps/chat2db-web/src/lib.rs b/apps/chat2db-web/src/lib.rs index 646f7a1..5bc7faa 100644 --- a/apps/chat2db-web/src/lib.rs +++ b/apps/chat2db-web/src/lib.rs @@ -407,6 +407,198 @@ mod tests { assert_eq!(missing["errorCode"], "datasource_not_found"); } + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn legacy_saved_console_routes_cover_create_list_get_update_and_delete() { + let directory = TempDir::new().expect("temp directory"); + let storage = + Storage::open(directory.path(), Arc::new(TestVault)).expect("test storage must open"); + let application = router(Application::with_storage(storage)); + + let created_response = application + .clone() + .oneshot(json_request( + Method::POST, + "/api/operation/saved/create", + &serde_json::json!({ + "name": "inventory[MySQL]", + "dataSourceId": "datasource-opaque-1", + "dataSourceName": "MySQL", + "databaseName": "inventory", + "schemaName": "legacy_schema", + "type": "MYSQL", + "ddl": "SELECT 1", + "status": "DRAFT", + "operationType": "console" + }), + )) + .await + .expect("router must respond"); + let created: serde_json::Value = response_json(created_response).await; + assert_eq!(created["success"], true); + let console_id = created["data"] + .as_i64() + .expect("Community Console id must remain numeric"); + + let listed_response = application + .clone() + .oneshot(request( + Method::GET, + "/api/operation/saved/list?pageNo=1&pageSize=20&tabOpened=y", + )) + .await + .expect("router must respond"); + let listed: serde_json::Value = response_json(listed_response).await; + assert_eq!(listed["success"], true); + assert_eq!(listed["data"]["total"], 1); + assert_eq!(listed["data"]["data"][0]["id"], console_id); + assert_eq!(listed["data"]["data"][0]["tabOpened"], "y"); + assert_eq!( + listed["data"]["data"][0]["dataSourceId"], + "datasource-opaque-1" + ); + + let get_uri = format!("/api/operation/saved?id={console_id}"); + let loaded_response = application + .clone() + .oneshot(dynamic_request(Method::GET, &get_uri)) + .await + .expect("router must respond"); + let loaded: serde_json::Value = response_json(loaded_response).await; + assert_eq!(loaded["data"]["ddl"], "SELECT 1"); + assert_eq!(loaded["data"]["schemaName"], "legacy_schema"); + assert_eq!(loaded["data"]["connectable"], true); + + let updated_response = application + .clone() + .oneshot(json_request( + Method::POST, + "/api/operation/saved/update", + &serde_json::json!({ + "id": console_id, + "ddl": "SELECT id, label FROM items ORDER BY id", + "schemaName": null, + "status": "RELEASE", + "tabOpened": "n" + }), + )) + .await + .expect("router must respond"); + let updated: serde_json::Value = response_json(updated_response).await; + assert_eq!(updated["success"], true); + + let reopened_response = application + .clone() + .oneshot(dynamic_request(Method::GET, &get_uri)) + .await + .expect("router must respond"); + let reopened: serde_json::Value = response_json(reopened_response).await; + assert_eq!( + reopened["data"]["ddl"], + "SELECT id, label FROM items ORDER BY id" + ); + assert_eq!(reopened["data"]["schemaName"], serde_json::Value::Null); + assert_eq!(reopened["data"]["status"], "RELEASE"); + assert_eq!(reopened["data"]["name"], "inventory[MySQL]"); + + let saved_response = application + .clone() + .oneshot(request( + Method::GET, + "/api/operation/saved/list?pageNo=1&pageSize=100&status=RELEASE&orderByDesc=true", + )) + .await + .expect("router must respond"); + let saved: serde_json::Value = response_json(saved_response).await; + assert_eq!(saved["data"]["total"], 1); + assert_eq!(saved["data"]["data"][0]["id"], console_id); + + let deleted_response = application + .clone() + .oneshot(dynamic_request(Method::DELETE, &get_uri)) + .await + .expect("router must respond"); + let deleted: serde_json::Value = response_json(deleted_response).await; + assert_eq!(deleted["success"], true); + + let missing_response = application + .oneshot(dynamic_request(Method::GET, &get_uri)) + .await + .expect("router must respond"); + let missing: serde_json::Value = response_json(missing_response).await; + assert_eq!(missing["success"], true); + assert!(missing["data"].is_null()); + } + + #[tokio::test] + async fn legacy_sql_execute_rejects_empty_sql_and_preserves_core_start_errors() { + let empty_response = router(Application::new()) + .oneshot(json_request( + Method::POST, + "/api/rdb/dml/execute", + &serde_json::json!({ + "dataSourceId": "datasource-1", + "databaseType": "MYSQL", + "sql": "", + "pageNo": 1, + "pageSize": 200 + }), + )) + .await + .expect("router must respond"); + let empty: serde_json::Value = response_json(empty_response).await; + assert_eq!(empty["success"], false); + assert_eq!(empty["errorCode"], "invalid_sql_execute_request"); + + let directory = TempDir::new().expect("temp directory"); + let storage = + Storage::open(directory.path(), Arc::new(TestVault)).expect("test storage must open"); + let unavailable_response = router(Application::with_storage(storage)) + .oneshot(json_request( + Method::POST, + "/api/rdb/dml/execute", + &serde_json::json!({ + "dataSourceId": "datasource-1", + "databaseType": "MYSQL", + "sql": "SELECT 1", + "pageNo": 1, + "pageSize": 200 + }), + )) + .await + .expect("router must respond"); + let unavailable: serde_json::Value = response_json(unavailable_response).await; + assert_eq!(unavailable["success"], false); + assert_eq!(unavailable["errorCode"], "database_engine_unavailable"); + } + + #[test] + fn legacy_sql_failure_is_a_renderable_community_result_item() { + let request = serde_json::from_value(serde_json::json!({ + "dataSourceId": "datasource-1", + "databaseType": "MYSQL", + "databaseName": "inventory", + "sql": "SELECT missing_column FROM items", + "pageNo": 1, + "pageSize": 200 + })) + .expect("legacy SQL request must deserialize"); + let failure = crate::legacy::LegacyFailure { + code: "database.query_failed".to_owned(), + message: "Unknown column 'missing_column'".to_owned(), + }; + let result = crate::legacy::sql_failure_result(&request, &failure, 12); + let result = serde_json::to_value(result).expect("failure result must serialize"); + + assert_eq!(result["success"], false); + assert_eq!(result["message"], "Unknown column 'missing_column'"); + assert_eq!(result["originalSql"], "SELECT missing_column FROM items"); + assert_eq!( + result["extra"]["messages"][0]["errorCode"], + "database.query_failed" + ); + } + #[tokio::test] async fn legacy_metadata_and_preview_failures_keep_the_community_envelope() { let application = router(Application::new()); From c51fdffa815f0c81d25166c03dadac1649c958a6 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 14:20:48 +0800 Subject: [PATCH 07/19] feat(desktop): bridge Community SQL execution --- Cargo.lock | 1 + apps/chat2db-desktop/Cargo.toml | 1 + apps/chat2db-desktop/src/lib.rs | 444 +++++++++++++++++++++++- apps/frontend/package.json | 2 +- scripts/community-tauri-bridge.js | 14 +- scripts/community-tauri-bridge.test.mjs | 78 +++++ 6 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 scripts/community-tauri-bridge.test.mjs diff --git a/Cargo.lock b/Cargo.lock index 043a1a4..db528dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,6 +648,7 @@ dependencies = [ "chat2db-java-bridge", "chat2db-local", "chat2db-web", + "serde", "serde_json", "tauri", "tauri-build", diff --git a/apps/chat2db-desktop/Cargo.toml b/apps/chat2db-desktop/Cargo.toml index 7422fc8..2520237 100644 --- a/apps/chat2db-desktop/Cargo.toml +++ b/apps/chat2db-desktop/Cargo.toml @@ -21,6 +21,7 @@ chat2db-core = { path = "../../crates/chat2db-core" } chat2db-java-bridge = { path = "../../crates/chat2db-java-bridge" } chat2db-local = { path = "../../crates/chat2db-local" } chat2db-web = { path = "../chat2db-web" } +serde.workspace = true serde_json.workspace = true tauri = { workspace = true, features = ["wry"] } tauri-runtime = "=2.9.2" diff --git a/apps/chat2db-desktop/src/lib.rs b/apps/chat2db-desktop/src/lib.rs index 9295b2b..a3070d2 100644 --- a/apps/chat2db-desktop/src/lib.rs +++ b/apps/chat2db-desktop/src/lib.rs @@ -10,6 +10,7 @@ use std::{ Arc, atomic::{AtomicU64, Ordering}, }, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use chat2db_contract::{ @@ -17,8 +18,8 @@ use chat2db_contract::{ AgentRunSnapshot, AgentSession, AgentSessionList, AgentStreamMessage, AgentSubscriptionAccepted, ApiError, BuildCommunityCreateSchemaRequest, BuildCommunityDmlRequest, BuildCommunityNamespaceSqlRequest, CancelAgentRunResponse, - CancelOperationResponse, CommunityBuiltSql, CommunityDatabaseList, CommunityForeignKeyList, - CommunityFormattedSql, CommunityFunction, CommunityFunctionList, + CancelDisposition, CancelOperationResponse, CommunityBuiltSql, CommunityDatabaseList, + CommunityForeignKeyList, CommunityFormattedSql, CommunityFunction, CommunityFunctionList, CommunityFunctionParameterList, CommunityPluginCatalog, CommunityPrimaryKeyList, CommunityProcedure, CommunityProcedureList, CommunityProcedureParameterList, CommunitySchemaList, CommunitySqlAnalysis, CommunitySqlCompletion, CommunitySqlValidation, @@ -31,7 +32,7 @@ use chat2db_contract::{ ListCommunityDatabasesRequest, ListCommunityFunctionsRequest, ListCommunityIndexesRequest, ListCommunityProceduresRequest, ListCommunitySchemasRequest, ListCommunityTableKeysRequest, ListCommunityTablesRequest, ListCommunityTriggersRequest, ListCommunityViewsRequest, - OperationEventEnvelope, OperationSnapshot, OperationStreamMessage, + OperationEvent, OperationEventEnvelope, OperationSnapshot, OperationStreamMessage, OperationSubscriptionAccepted, ParseCommunitySqlRequest, ProviderProfile, ProviderProfileList, QueryAccepted, ResultPage, ResultPageRequest, StartAgentRunRequest, StartCommunityTablePreviewRequest, StartQueryRequest, UpdateAgentSessionRequest, @@ -42,7 +43,7 @@ use chat2db_core::{ }; use chat2db_java_bridge::{BridgeError, EngineCommand, EngineConfig}; use chat2db_local::{LocalError, LocalServer}; -use tauri::{State, ipc::Channel}; +use tauri::{Emitter, State, WebviewWindow, ipc::Channel}; use tokio::sync::{Mutex, oneshot}; const DATA_DIR_ENV: &str = "CHAT2DB_DATA_DIR"; @@ -56,6 +57,7 @@ const BUNDLED_JAVA_BIN: &str = "Java binary"; const BUNDLED_JAVA_ENGINE_JAR: &str = "compatibility-engine JAR"; const BUNDLED_COMMUNITY_CLASSPATH: &str = "Community classpath"; const BUNDLED_DRIVER_PACKS: &str = "driver packs"; +const COMMUNITY_JAVA_MESSAGE_EVENT: &str = "chat2db://java-message"; #[derive(Debug, Default)] struct RuntimeResourceOverrides { @@ -565,11 +567,402 @@ fn api_error(error: &AppError) -> ApiError { #[tauri::command] async fn legacy_request( state: State<'_, Arc>, + window: WebviewWindow, request: String, ) -> Result { + if let Some(response) = legacy_client_command_for(&state.application, &window, &request).await? + { + return Ok(response); + } legacy_request_for(&state.application, &request).await } +async fn legacy_client_command_for( + application: &Application, + window: &WebviewWindow, + request: &str, +) -> Result, String> { + let value: serde_json::Value = serde_json::from_str(request) + .map_err(|_| "Community desktop request must be valid JSON".to_owned())?; + let request = value + .as_object() + .ok_or_else(|| "Community desktop request must be a JSON object".to_owned())?; + let method = legacy_request_string(request, "method")?; + if method != "client-command" { + return Ok(None); + } + let request_url = legacy_request_string(request, "requestUrl")?; + match request_url.as_str() { + "handle-java-message-is-ready" => { + Ok(Some(client_command_response(&serde_json::json!(true)))) + } + "sql-execute" => { + let request_uuid = legacy_request_string(request, "uuid")?; + let sql_request = decode_client_message::( + request.get("message"), + )?; + let accepted = chat2db_web::legacy::start_sql_execution(application, &sql_request) + .await + .map_err(|error| legacy_failure_message(&error))?; + let execution_id = accepted.operation_id.clone(); + let task_application = application.clone(); + let task_window = window.clone(); + let task_execution_id = execution_id.clone(); + tauri::async_runtime::spawn(async move { + forward_legacy_sql_execution( + task_application, + task_window, + request_uuid, + task_execution_id, + sql_request, + ) + .await; + }); + Ok(Some(client_command_response(&serde_json::json!({ + "executionId": execution_id, + })))) + } + "sql-cancel" => { + let message = decode_client_message::(request.get("message"))?; + let execution_id = message + .get("executionId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "sql-cancel requires a non-empty executionId".to_owned())?; + let cancelled = application.cancel_operation(execution_id).await.disposition + == CancelDisposition::Accepted; + Ok(Some(client_command_response(&serde_json::json!(cancelled)))) + } + _ => Ok(None), + } +} + +fn decode_client_message( + message: Option<&serde_json::Value>, +) -> Result { + match message { + Some(serde_json::Value::String(message)) => serde_json::from_str(message), + Some(message) => serde_json::from_value(message.clone()), + None => serde_json::from_value(serde_json::Value::Null), + } + .map_err(|_| "Community client-command message is invalid".to_owned()) +} + +fn client_command_response(data: &serde_json::Value) -> String { + serde_json::json!({ "data": data }).to_string() +} + +fn legacy_failure_message(error: &chat2db_web::legacy::LegacyFailure) -> String { + format!("{}: {}", error.code, error.message) +} + +#[allow(clippy::too_many_lines)] +async fn forward_legacy_sql_execution( + application: Application, + window: WebviewWindow, + request_uuid: String, + execution_id: String, + request: chat2db_web::legacy::LegacySqlExecuteRequest, +) { + let started_at = Instant::now(); + let mut sequence = 0_u64; + if emit_legacy_sql_event( + &window, + &request_uuid, + &execution_id, + &mut sequence, + "started", + None, + None, + &serde_json::json!({ "executionId": execution_id }), + ) + .is_err() + { + application.cancel_operation(&execution_id).await; + return; + } + if emit_legacy_sql_event( + &window, + &request_uuid, + &execution_id, + &mut sequence, + "statementStarted", + Some(1), + None, + &serde_json::json!({ + "sql": request.sql, + "originalSql": request.sql, + "sequence": 1, + }), + ) + .is_err() + { + application.cancel_operation(&execution_id).await; + return; + } + + let mut subscription = match application.subscribe_operation(&execution_id, None).await { + Ok(subscription) => subscription, + Err(error) => { + emit_legacy_terminal_error( + &window, + &request_uuid, + &execution_id, + &mut sequence, + &error.api_error(), + ); + return; + } + }; + loop { + let event = match subscription.next_event().await { + Ok(Some(event)) => event.event, + Ok(None) => { + emit_legacy_terminal_error( + &window, + &request_uuid, + &execution_id, + &mut sequence, + &ApiError::new( + "sql_execution_incomplete", + "The SQL execution ended without a result", + ), + ); + return; + } + Err(error) => { + emit_legacy_terminal_error( + &window, + &request_uuid, + &execution_id, + &mut sequence, + &error.api_error(), + ); + return; + } + }; + match event { + OperationEvent::Started | OperationEvent::Progress { .. } => {} + OperationEvent::Completed { result } => { + let duration = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let result = match chat2db_web::legacy::read_sql_result( + &application, + &request, + &result, + duration, + ) + .await + { + Ok(result) => result, + Err(error) => { + emit_legacy_terminal_error( + &window, + &request_uuid, + &execution_id, + &mut sequence, + &ApiError::new(error.code, error.message), + ); + return; + } + }; + if emit_legacy_sql_result_events( + &window, + &request_uuid, + &execution_id, + &mut sequence, + result, + ) + .is_err() + { + return; + } + let _ = emit_legacy_sql_event( + &window, + &request_uuid, + &execution_id, + &mut sequence, + "statementFinished", + Some(1), + None, + &serde_json::json!({ "sql": request.sql, "duration": duration }), + ); + let _ = emit_legacy_sql_event( + &window, + &request_uuid, + &execution_id, + &mut sequence, + "finished", + None, + None, + &serde_json::json!({ "executionId": execution_id }), + ); + return; + } + OperationEvent::Failed { error } => { + emit_legacy_terminal_error( + &window, + &request_uuid, + &execution_id, + &mut sequence, + &error, + ); + return; + } + OperationEvent::Cancelled { reason } => { + let _ = emit_legacy_sql_event( + &window, + &request_uuid, + &execution_id, + &mut sequence, + "cancelled", + None, + None, + &serde_json::json!({ + "executionId": execution_id, + "message": reason.unwrap_or_else(|| "The SQL execution was cancelled".to_owned()), + }), + ); + return; + } + } + } +} + +fn emit_legacy_sql_result_events( + window: &WebviewWindow, + request_uuid: &str, + execution_id: &str, + sequence: &mut u64, + result: chat2db_web::legacy::LegacyManageResult, +) -> Result<(), tauri::Error> { + let mut started = serde_json::to_value(&result).unwrap_or_else(|_| serde_json::json!({})); + if let Some(started) = started.as_object_mut() { + started.insert("dataList".to_owned(), serde_json::json!([])); + } + emit_legacy_sql_event( + window, + request_uuid, + execution_id, + sequence, + "resultStarted", + Some(1), + Some(1), + &started, + )?; + let rows = serde_json::to_value(&result).unwrap_or_else(|_| serde_json::json!({})); + emit_legacy_sql_event( + window, + request_uuid, + execution_id, + sequence, + "rows", + Some(1), + Some(1), + &rows, + )?; + emit_legacy_sql_event( + window, + request_uuid, + execution_id, + sequence, + "resultFinished", + Some(1), + Some(1), + &serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})), + ) +} + +fn emit_legacy_terminal_error( + window: &WebviewWindow, + request_uuid: &str, + execution_id: &str, + sequence: &mut u64, + error: &ApiError, +) { + let _ = emit_legacy_sql_event( + window, + request_uuid, + execution_id, + sequence, + "failed", + Some(1), + None, + &serde_json::json!({ + "executionId": execution_id, + "message": error.message, + "errorCode": error.code, + }), + ); +} + +#[allow(clippy::too_many_arguments)] +fn emit_legacy_sql_event( + window: &WebviewWindow, + request_uuid: &str, + execution_id: &str, + sequence: &mut u64, + event_type: &str, + statement_sequence: Option, + result_sequence: Option, + message: &serde_json::Value, +) -> Result<(), tauri::Error> { + *sequence = sequence.saturating_add(1); + let result_key = statement_sequence.map(|statement_sequence| { + format!( + "{execution_id}:{statement_sequence}:{}", + result_sequence.unwrap_or(0) + ) + }); + window.emit( + COMMUNITY_JAVA_MESSAGE_EVENT, + legacy_sql_push_message( + request_uuid, + execution_id, + *sequence, + event_type, + statement_sequence, + result_sequence, + result_key.as_deref(), + message, + ), + ) +} + +#[allow(clippy::too_many_arguments)] +fn legacy_sql_push_message( + request_uuid: &str, + execution_id: &str, + event_sequence: u64, + event_type: &str, + statement_sequence: Option, + result_sequence: Option, + result_key: Option<&str>, + message: &serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "uuid": request_uuid, + "actionType": "sql_execution_event", + "message": { + "executionId": execution_id, + "eventSequence": event_sequence, + "occurredAtEpochMs": unix_epoch_millis(), + "eventType": event_type, + "statementSequence": statement_sequence, + "resultSequence": result_sequence, + "resultKey": result_key, + "message": message, + }, + }) +} + +fn unix_epoch_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .unwrap_or(0) +} + async fn legacy_request_for(application: &Application, request: &str) -> Result { let request: serde_json::Value = serde_json::from_str(request) .map_err(|_| "Community desktop request must be valid JSON".to_owned())?; @@ -1497,8 +1890,9 @@ mod tests { BUNDLED_COMMUNITY_CLASSPATH, BUNDLED_DRIVER_PACKS, BUNDLED_JAVA_BIN, BUNDLED_JAVA_ENGINE_JAR, BundledRuntimeResources, DesktopError, RuntimeResourceOverrides, SubscriptionRegistry, agent_stream_message, build_community_dml_for, - build_community_namespace_sql_for, complete_community_sql_for, format_community_sql_for, - legacy_request_for, operation_stream_message, parse_after_sequence, + build_community_namespace_sql_for, client_command_response, complete_community_sql_for, + decode_client_message, format_community_sql_for, legacy_request_for, + legacy_sql_push_message, operation_stream_message, parse_after_sequence, resolve_runtime_resource_paths, start_community_table_preview_for, validate_community_sql_for, validate_java_engine_jar, validate_optional_os_env, }; @@ -1535,6 +1929,44 @@ mod tests { (directory, executable, resources) } + #[test] + fn community_client_command_payloads_decode_and_return_direct_data() { + let message = serde_json::json!( + r#"{"dataSourceId":"datasource-1","sql":"SELECT 1","pageNo":1,"pageSize":20}"# + ); + let request = + decode_client_message::(Some(&message)) + .expect("string client-command payload must decode"); + assert_eq!(request.sql, "SELECT 1"); + + let response: serde_json::Value = serde_json::from_str(&client_command_response( + &serde_json::json!({ "executionId": "operation-1" }), + )) + .expect("client-command response must serialize"); + assert_eq!(response["data"]["executionId"], "operation-1"); + } + + #[test] + fn community_sql_push_message_matches_the_existing_event_bus_contract() { + let message = legacy_sql_push_message( + "request-1", + "operation-1", + 3, + "resultFinished", + Some(1), + Some(1), + Some("operation-1:1:1"), + &serde_json::json!({ "success": true }), + ); + assert_eq!(message["uuid"], "request-1"); + assert_eq!(message["actionType"], "sql_execution_event"); + assert_eq!(message["message"]["executionId"], "operation-1"); + assert_eq!(message["message"]["eventSequence"], 3); + assert_eq!(message["message"]["eventType"], "resultFinished"); + assert_eq!(message["message"]["resultKey"], "operation-1:1:1"); + assert_eq!(message["message"]["message"]["success"], true); + } + #[tokio::test] async fn legacy_request_preserves_the_community_jcef_response_shape() { let response = legacy_request_for( diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 452c772..45de922 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -14,7 +14,7 @@ "build": "node ../../scripts/community-frontend.mjs build", "dev": "node ../../scripts/community-frontend.mjs dev", "generate-contract": "openapi-typescript ../../contracts/openapi/chat2db-v1.json -o src/generated/contract.ts", - "test": "vitest run && node ../../scripts/community-frontend.mjs test", + "test": "vitest run && node ../../scripts/community-tauri-bridge.test.mjs && node ../../scripts/community-frontend.mjs test", "typecheck": "tsc --noEmit", "verify-upstream": "node ../../scripts/community-frontend.mjs verify" }, diff --git a/scripts/community-tauri-bridge.js b/scripts/community-tauri-bridge.js index df6ae76..9239d53 100644 --- a/scripts/community-tauri-bridge.js +++ b/scripts/community-tauri-bridge.js @@ -1,7 +1,19 @@ (() => { - const invoke = globalThis.__TAURI__?.core?.invoke; + const tauri = globalThis.__TAURI__; + const invoke = tauri?.core?.invoke; if (typeof invoke !== 'function' || typeof globalThis.window !== 'object') return; + const listen = tauri?.event?.listen; + if (typeof listen === 'function') { + listen('chat2db://java-message', ({ payload }) => { + if (typeof globalThis.window.handleJavaMessage !== 'function') return; + const message = typeof payload === 'string' ? payload : JSON.stringify(payload); + globalThis.window.handleJavaMessage(message); + }).catch((error) => { + console.error('Unable to subscribe to Chat2DB desktop events', error); + }); + } + if (globalThis.window.location?.hash === '') { globalThis.window.location.replace('#/workspace'); } diff --git a/scripts/community-tauri-bridge.test.mjs b/scripts/community-tauri-bridge.test.mjs new file mode 100644 index 0000000..780a645 --- /dev/null +++ b/scripts/community-tauri-bridge.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const source = await readFile(new URL('./community-tauri-bridge.js', import.meta.url), 'utf8'); +let eventListener; +const requests = []; +const window = { + location: { + hash: '#/workspace', + replace() { + throw new Error('the existing route must not be replaced'); + }, + }, +}; +const context = { + console, + window, + __TAURI__: { + core: { + invoke(command, payload) { + requests.push({ command, payload }); + return Promise.resolve('{"ok":true}'); + }, + }, + event: { + listen(eventName, listener) { + assert.equal(eventName, 'chat2db://java-message'); + eventListener = listener; + return Promise.resolve(() => {}); + }, + }, + }, +}; +context.globalThis = context; + +vm.runInNewContext(source, context, { filename: 'community-tauri-bridge.js' }); +assert.equal(typeof window.javaQuery, 'function'); +assert.equal(typeof eventListener, 'function'); + +let response; +await new Promise((resolve, reject) => { + window.javaQuery({ + request: '{"requestUrl":"/api/system","method":"get"}', + onSuccess(value) { + response = value; + resolve(); + }, + onFailure: reject, + }); +}); +assert.equal(response, '{"ok":true}'); +assert.equal( + JSON.stringify(requests), + JSON.stringify([ + { + command: 'legacy_request', + payload: { request: '{"requestUrl":"/api/system","method":"get"}' }, + }, + ]), +); + +let pushedMessage; +window.handleJavaMessage = (message) => { + pushedMessage = message; +}; +eventListener({ + payload: { + uuid: 'request-1', + actionType: 'sql_execution_event', + message: { eventType: 'finished' }, + }, +}); +assert.deepEqual(JSON.parse(pushedMessage), { + uuid: 'request-1', + actionType: 'sql_execution_event', + message: { eventType: 'finished' }, +}); From eaf21a75a764216659061268bd84e4f4d48f4257 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 14:30:56 +0800 Subject: [PATCH 08/19] docs: record Community Console milestone --- README.md | 34 ++++++++++++++++++++++++++++------ docs/stages.md | 33 +++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bb4cdf0..77c9b72 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ git submodule update --init --recursive ## Current state -The repository has completed Stages 1 through 6 and the first thirteen -independently buildable Stage 7 slices: +The repository has completed Stages 1 through 6, the first thirteen +independently buildable Stage 7 slices, and the first end-user Community +Console compatibility slice: - canonical Rust API contracts; - a transport-neutral Rust application service root; @@ -57,9 +58,10 @@ independently buildable Stage 7 slices: Tauri 2 commands/channels; - a checked-in OpenAPI contract with generated TypeScript types and drift verification; and -- the exact pinned original Community Umi/React frontend, served by Axum over - historical HTTP routes on Web and bridged from `window.javaQuery` to Tauri - IPC on desktop without product UI or style forks; +- the pinned original Community Umi/React layout and components, served by + Axum over historical HTTP routes on Web and bridged from `window.javaQuery` + to Tauri IPC on desktop without a replacement UI or style fork; the pinned + source carries one CSP-safe callback-cloning compatibility fix; - a provider-neutral bounded agent loop with direct OpenAI, Anthropic, and Gemini adapters, durable sessions/messages/runs/permissions, and atomic context compaction; @@ -90,6 +92,13 @@ independently buildable Stage 7 slices: datasource CRUD/tree, database/schema/table discovery, and synchronous table preview over the historical `{success,data,errorCode,errorMessage}` envelope; and +- durable SQLite-backed Community Console create/get/list/update/delete, + including SQL text, datasource/database/schema binding, saved status, and + open-tab state across process restarts; and +- Community Console SELECT execution through the existing bounded Core query + and retained-result path: Web uses the historical synchronous result shape, + while desktop maps operation lifecycle, rows, failure, and cancellation to + the original JCEF event bus; - a shared Web/Tauri legacy dispatcher: Axum maps the original `/api` routes, while desktop preserves the original JCEF correlation envelope through one `legacy_request` Tauri command; and @@ -102,7 +111,12 @@ forced-read-only execution, and retained paging of the selected table's rows. Commit `928e62c5d775d0e81d95db7fee186db756834a72` additionally passed the complete local repository gate and a live original-frontend legacy HTTP vertical covering connection, datasource persistence, database/table listing, -and three-row table preview. +and three-row table preview. On 2026-07-28 commits `36ecac6`, `78e92d6`, and +`c51fdff` passed formatting, strict workspace Clippy, all 509 Rust tests, 49 +frontend tests, and the Community production build. A live MySQL 8.4 run then +created a Console, returned real table rows, returned a renderable SQL error, +saved edited SQL, closed it, restarted the Rust host, reopened it, and executed +the restored SQL successfully. Stage 6 is complete. Web and desktop own the product runtime and publish its owner-only local endpoint; CLI and MCP attach to that host and never contact @@ -142,6 +156,14 @@ path. The fixed 149-JAR classpath keeps H2 and MySQL; PostgreSQL and other dialects do not block the MySQL preview. MySQL writes, Agent, CLI, and MCP conformance remain outside this small read-only milestone. +The first Console compatibility slice adds SQLite migration 3 for saved +Consoles and the historical `/api/operation/saved/*` plus +`/api/rdb/dml/execute` routes. Web waits on the Core operation and returns the +existing Community grid result. Desktop starts the same Core query, emits the +original `sql_execution_event` sequence through Tauri, and supports +`sql-cancel`. This slice intentionally supports query/SELECT execution only; +arbitrary DDL, DML, and multi-statement Console scripts are not implemented. + The Stage 5 and Stage 7G through Stage 7M custom React workbench was an intermediate implementation and is no longer the product frontend. Commit `928e62c5d775d0e81d95db7fee186db756834a72` deletes that replacement UI and diff --git a/docs/stages.md b/docs/stages.md index 48a4033..89a95de 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -12,7 +12,7 @@ in runtime health until then. | 4 | Complete | Product and result storage foundation | SQLite migration/integrity gates, mandatory vault boundary, revisioned datasource records, durable result frames, bounded paging/quota, expiry, writer cleanup and recovery tests | | 5 | Complete | Product transports | Generated OpenAPI/TypeScript contract, Axum JSON/SSE, Tauri 2 commands/channels, shared SQL workbench, product H2 tests | | 6 | Complete | Agent, MCP, and CLI | Direct providers, durable bounded tool loop, SQL tools/permissions, compaction, Web/Tauri run transports, owner-only local attachment, read-query CLI, and bounded `rmcp` stdio tools | -| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; the current product reuses the exact original Community frontend with historical HTTP/Tauri compatibility for the first MySQL browse-and-preview slice | +| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, and bounded SELECT execution | | 8 | Planned | Packaging and release | License authorization, NOTICE/SBOM, jlink runtime, Tauri installers, signed product/engine/driver manifests, atomic update and rollback, size measurement | Stage 3 completion means the versioned Rust-Java bridge can load an external @@ -22,9 +22,11 @@ cancellation. Stage 5 composes that bridge into the Web and desktop product hosts. Stage 6 adds CLI and MCP adapters that attach to one of those running hosts and do not own another product runtime. -Frontend checkpoint `928e62c` supersedes the repository-owned Stage 5/7G -replacement workbench. Current builds export the exact pinned Community Umi -frontend without local page or style patches. Web uses historical `/api` +Frontend checkpoints `928e62c` and `cf9ab8a` supersede the repository-owned +Stage 5/7G replacement workbench. Current builds export the pinned Community +Umi frontend without local page or style patches; the pinned source includes a +CSP-safe utility fix that preserves callback references without `new Function`. +Web uses historical `/api` compatibility routes; desktop preserves `window.javaQuery` through one Tauri command; both converge on the same Rust dispatcher. Earlier stage descriptions below remain implementation history for backend capabilities and the removed @@ -283,6 +285,29 @@ the installed driver from runtime inventory. Product writes and Agent, CLI, and MCP MySQL conformance are explicitly deferred from this small preview; PostgreSQL and long-tail plugin conformance do not block it. +The first Community Console compatibility slice adds process-durable saved +Console records in SQLite migration 3 and implements the original +`/api/operation/saved/create`, `/list`, get, update, and delete contracts. SQL +text, datasource/database/schema binding, saved status, and `tabOpened` survive +a full Rust host restart. Web maps `/api/rdb/dml/execute` to the bounded Core +query operation and returns the historical grid result. Desktop maps +`sql-execute` and `sql-cancel` to the same Core operation, then emits ordered +`started`, statement, result, row, terminal, failure, and cancellation events +through the existing Community JCEF event bus. + +Runtime-tested: yes. On 2026-07-28 a real MySQL 8.4 run listed the `app` +database and its 16 tables, created and updated a saved Console, returned three +and then five real rows, projected an invalid-column error as a renderable +failure result, closed the Console, restarted the Rust host, reopened the same +numeric Console id, and executed the persisted SQL. Workspace formatting, +strict Clippy, all 509 Rust tests, 49 frontend tests, the Tauri bridge contract, +and the Community production build passed. Browser click-through was not +performed because the browser runtime exposed no browser instance. + +This slice is query-only. Arbitrary DDL, DML, and multi-statement Console +execution are not implemented and must not be inferred from the historical +endpoint name. + Stage 7 remains incomplete. General type conversion, script execution, data import/export, non-relational behavior, remaining builder operations and plugin inventory, driver distribution, and per-dialect conformance are not From f690f7e30c6dd27943191d6e5dfe466a62776e0a Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 15:01:16 +0800 Subject: [PATCH 09/19] fix(packaging): pin patched Community snapshot --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- apps/frontend/src/backend/community.test.ts | 2 +- .../tests/java_community_mysql_product.rs | 2 +- .../chat2db-core/tests/java_community_product.rs | 2 +- .../tests/java_community_h2.rs | 2 +- crates/chat2db-java-bridge/tests/supervisor.rs | 2 +- docs/architecture.md | 5 +++-- docs/protocol.md | 2 +- docs/stages.md | 5 +++-- .../rust/compat/CommunityPluginRegistryTest.java | 2 +- scripts/CommunityClasspathSanitizer.java | 11 +++++++++-- scripts/build-community-h2-classpath.sh | 2 +- scripts/verify-community-h2-reproducibility.sh | 2 +- scripts/verify-macos-package.sh | 2 +- third_party/community-h2-classpath.lock | 16 ++++++++-------- 16 files changed, 35 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 989e0f5..9a7de94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: working-directory: java env: CHAT2DB_COMMUNITY_CLASSPATH_DIR: "${{ github.workspace }}/target/community-h2-classpath" - CHAT2DB_COMMUNITY_SOURCE_COMMIT: "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" + CHAT2DB_COMMUNITY_SOURCE_COMMIT: "f275e08d774f839612374e991d09c5e6ea2d8b57" run: >- ./mvnw -B -pl compat-runtime -Dtest='CommunityPluginRegistryTest#realCommunityH2BuildsAndExecutesBoundedDml,CommunityPluginRegistryTest#realCommunityMysqlRejectsBackslashCrossColumnInjection,CommunityPluginRegistryTest#realCommunityMysqlNormalizesBooleanAliasesAndBits,CommunityPluginRegistryTest#realCommunityH2BuildsNamespaceSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityMysqlBuildsDatabaseNamespaceSql,CommunityPluginRegistryTest#realCommunityNamespaceMapsUnsupportedAndRejectsOversizedInput,JdbcProtocolLoopTest#communityDmlDispatchDoesNotRequireAJdbcSession,JdbcProtocolLoopTest#communityNamespaceDispatchDoesNotRequireAJdbcSession' diff --git a/Makefile b/Makefile index b65c4d0..5b2e530 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ community-h2-reproducibility: community-java-h2-integration: java community-h2-classpath cd java && \ CHAT2DB_COMMUNITY_CLASSPATH_DIR="$(COMMUNITY_CLASSPATH_DIR)" \ - CHAT2DB_COMMUNITY_SOURCE_COMMIT="f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" \ + CHAT2DB_COMMUNITY_SOURCE_COMMIT="f275e08d774f839612374e991d09c5e6ea2d8b57" \ ./mvnw -B -pl compat-runtime \ -Dtest='CommunityPluginRegistryTest#realCommunityH2BuildsAndExecutesBoundedDml,CommunityPluginRegistryTest#realCommunityMysqlRejectsBackslashCrossColumnInjection,CommunityPluginRegistryTest#realCommunityMysqlNormalizesBooleanAliasesAndBits,CommunityPluginRegistryTest#realCommunityH2BuildsNamespaceSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityMysqlBuildsDatabaseNamespaceSql,CommunityPluginRegistryTest#realCommunityMysqlBuildsBoundedTablePreviewSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityNamespaceMapsUnsupportedAndRejectsOversizedInput,JdbcProtocolLoopTest#communityDmlDispatchDoesNotRequireAJdbcSession,JdbcProtocolLoopTest#communityNamespaceDispatchDoesNotRequireAJdbcSession' \ test diff --git a/apps/frontend/src/backend/community.test.ts b/apps/frontend/src/backend/community.test.ts index d7d50a1..40256f4 100644 --- a/apps/frontend/src/backend/community.test.ts +++ b/apps/frontend/src/backend/community.test.ts @@ -184,7 +184,7 @@ const completeSqlRequest = { } satisfies CompleteCommunitySqlRequest; const catalog = { - sourceCommit: 'f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7', + sourceCommit: 'f275e08d774f839612374e991d09c5e6ea2d8b57', plugins: [{ databaseType: 'H2', name: 'H2', diff --git a/crates/chat2db-core/tests/java_community_mysql_product.rs b/crates/chat2db-core/tests/java_community_mysql_product.rs index 8d9e6da..75484b2 100644 --- a/crates/chat2db-core/tests/java_community_mysql_product.rs +++ b/crates/chat2db-core/tests/java_community_mysql_product.rs @@ -22,7 +22,7 @@ use futures_util::FutureExt as _; use tempfile::TempDir; use uuid::Uuid; -const COMMUNITY_COMMIT: &str = "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7"; +const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; const MYSQL_DATABASE_TYPE: &str = "MYSQL"; const MYSQL_DRIVER_CLASS: &str = "com.mysql.cj.jdbc.Driver"; const MYSQL_DRIVER_VERSION: &str = "8.0.30"; diff --git a/crates/chat2db-core/tests/java_community_product.rs b/crates/chat2db-core/tests/java_community_product.rs index aae5cc6..4461459 100644 --- a/crates/chat2db-core/tests/java_community_product.rs +++ b/crates/chat2db-core/tests/java_community_product.rs @@ -29,7 +29,7 @@ use chat2db_java_bridge::{ use chat2db_storage::{EncryptedFileVault, Storage}; use tempfile::TempDir; -const COMMUNITY_COMMIT: &str = "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7"; +const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; const H2_DRIVER_CLASS: &str = "org.h2.Driver"; const EVENT_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/crates/chat2db-java-bridge/tests/java_community_h2.rs b/crates/chat2db-java-bridge/tests/java_community_h2.rs index 975a3e5..2e9a1ee 100644 --- a/crates/chat2db-java-bridge/tests/java_community_h2.rs +++ b/crates/chat2db-java-bridge/tests/java_community_h2.rs @@ -23,7 +23,7 @@ use chat2db_java_bridge::{ }; use tempfile::TempDir; -const COMMUNITY_COMMIT: &str = "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7"; +const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; const H2_DRIVER_CLASS: &str = "org.h2.Driver"; const COMMUNITY_CLASSPATH_LOCK: &str = include_str!("../../../third_party/community-h2-classpath.lock"); diff --git a/crates/chat2db-java-bridge/tests/supervisor.rs b/crates/chat2db-java-bridge/tests/supervisor.rs index 29a2265..54c6129 100644 --- a/crates/chat2db-java-bridge/tests/supervisor.rs +++ b/crates/chat2db-java-bridge/tests/supervisor.rs @@ -9,7 +9,7 @@ use chat2db_java_bridge::{ Session, SessionConfig, SessionState, TransactionOptions, UpdateRequest, }; -const COMMUNITY_COMMIT: &str = "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7"; +const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; fn fixture_command(arguments: &[&str]) -> EngineCommand { arguments.iter().fold( diff --git a/docs/architecture.md b/docs/architecture.md index 67fbd16..a22c446 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,14 +125,15 @@ The JDBC baseline implements: Stage 7B additionally implements: - a Git submodule fixed at Community commit - `f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7`; + `f275e08d774f839612374e991d09c5e6ea2d8b57`; - a reproducible H2 compatibility classpath, established with 148 JARs and extended in Stage 7J to 149 JARs for the retained Community domain-core completion implementation, whose filenames, byte lengths, and SHA-256 digests are bound to that commit by the checked-in `third_party/community-h2-classpath.lock`; - deterministic build-time removal of dependency-manifest `Class-Path` entries, - with affected JARs rebuilt as sorted, commit-timestamped `STORED` archives; + with affected JARs rebuilt as sorted, ZIP-precision commit-timestamped `STORED` + archives; - a separately supplied Community classpath loaded by a `URLClassLoader` whose parent is only the Java platform classloader; - `ServiceLoader` discovery projected into stable Protobuf DTOs; diff --git a/docs/protocol.md b/docs/protocol.md index c2efbe1..8272b63 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -33,7 +33,7 @@ not committed. The retained Community SPI and its implementations come from the Community 5.3.0 submodule fixed at commit -`f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7`. The Protobuf messages are +`f275e08d774f839612374e991d09c5e6ea2d8b57`. The Protobuf messages are compatibility-layer-owned DTOs, not serialized Community Java types; Community plugin, JDBC, parser, and exception objects remain inside Java. The catalog's `source_commit` is provenance that Rust checks against the configured commit. diff --git a/docs/stages.md b/docs/stages.md index 89a95de..1e08aab 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -80,10 +80,11 @@ and generated frontend contracts. Downloading, signing, installation, update, rollback, and hot reload remain incomplete. Stage 7B fixes the Community source at commit -`f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7`, builds its H2 compatibility +`f275e08d774f839612374e991d09c5e6ea2d8b57`, builds its H2 compatibility classpath reproducibly, and initially locks 148 JAR filenames, lengths, and SHA-256 digests. Before lock verification, the fixed build strips dependency-manifest -`Class-Path` entries deterministically and proves two clean builds have +`Class-Path` entries deterministically, rounds the commit timestamp down to ZIP's +two-second precision, and proves two clean builds have identical artifact bytes. Rust snapshots and re-verifies those JARs for one supervised Java generation. Java isolates them behind a platform-parent `URLClassLoader`, rejects manifest `Class-Path` escapes, discovers real `IPlugin` diff --git a/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java b/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java index 39bfe77..09324ec 100644 --- a/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java +++ b/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java @@ -942,7 +942,7 @@ private static CommunityPluginRegistry openRegistry(Path directory) throws Excep CommunitySqlCompletionBridge.class); constructor.setAccessible(true); return constructor.newInstance( - "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7", + "f275e08d774f839612374e991d09c5e6ea2d8b57", loader, plugins, CommunitySqlCompletionBridge.open(loader)); diff --git a/scripts/CommunityClasspathSanitizer.java b/scripts/CommunityClasspathSanitizer.java index 8074254..6314ec4 100644 --- a/scripts/CommunityClasspathSanitizer.java +++ b/scripts/CommunityClasspathSanitizer.java @@ -131,7 +131,7 @@ private static long parseTimestamp(String value) { if (year < 1980 || year > 2107) { throw new IllegalArgumentException("archive timestamp is outside the ZIP date range"); } - return seconds; + return seconds - Math.floorMod(seconds, 2L); } private static void sanitize(Path directory, long timestamp) throws IOException { @@ -667,7 +667,14 @@ private static String sha256(Path artifact) throws IOException { } private static void selfTest() throws IOException { - LocalDateTime archiveTime = LocalDateTime.of(2000, 1, 2, 3, 4, 6); + LocalDateTime expectedArchiveTime = LocalDateTime.of(2000, 1, 2, 3, 4, 6); + long oddTimestamp = expectedArchiveTime.plusSeconds(1).toEpochSecond(ZoneOffset.UTC); + LocalDateTime archiveTime = LocalDateTime.ofInstant( + Instant.ofEpochSecond(parseTimestamp(Long.toString(oddTimestamp))), ZoneOffset.UTC); + if (!archiveTime.equals(expectedArchiveTime)) { + throw new IOException( + "odd ZIP timestamps must be rounded down to two-second precision"); + } Path directory = Files.createTempDirectory("chat2db-community-sanitizer-test-"); try { Path first = directory.resolve("first.jar"); diff --git a/scripts/build-community-h2-classpath.sh b/scripts/build-community-h2-classpath.sh index 5eb3bdd..8cc30d8 100755 --- a/scripts/build-community-h2-classpath.sh +++ b/scripts/build-community-h2-classpath.sh @@ -10,7 +10,7 @@ maven_repository="${repository_root}/target/community-m2" classpath_lock="${repository_root}/third_party/community-h2-classpath.lock" classpath_lock_tool="${repository_root}/scripts/community-classpath-lock.sh" classpath_sanitizer="${repository_root}/scripts/CommunityClasspathSanitizer.java" -expected_commit="f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" +expected_commit="f275e08d774f839612374e991d09c5e6ea2d8b57" community_version="5.3.0" if [[ ! -e "${community_root}/.git" ]]; then diff --git a/scripts/verify-community-h2-reproducibility.sh b/scripts/verify-community-h2-reproducibility.sh index 36d930f..c90a8ff 100755 --- a/scripts/verify-community-h2-reproducibility.sh +++ b/scripts/verify-community-h2-reproducibility.sh @@ -6,7 +6,7 @@ build_tool="${repository_root}/scripts/build-community-h2-classpath.sh" lock_tool="${repository_root}/scripts/community-classpath-lock.sh" sanitizer="${repository_root}/scripts/CommunityClasspathSanitizer.java" output_directory="${repository_root}/target/community-h2-classpath" -expected_commit="f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" +expected_commit="f275e08d774f839612374e991d09c5e6ea2d8b57" first_lock="$(mktemp "${TMPDIR:-/tmp}/chat2db-community-first-lock.XXXXXX")" second_lock="$(mktemp "${TMPDIR:-/tmp}/chat2db-community-second-lock.XXXXXX")" diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 2501581..d24370d 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -49,7 +49,7 @@ fi "${repository_root}/scripts/community-classpath-lock.sh" verify \ "${community_classpath}" \ "${repository_root}/third_party/community-h2-classpath.lock" \ - "f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7" + "f275e08d774f839612374e991d09c5e6ea2d8b57" driver_manifest="${driver_root}/01-mysql/driver-pack.json" driver_jar="${driver_root}/01-mysql/mysql-connector-java-8.0.30.jar" diff --git a/third_party/community-h2-classpath.lock b/third_party/community-h2-classpath.lock index 52bcd17..7f7a0ab 100644 --- a/third_party/community-h2-classpath.lock +++ b/third_party/community-h2-classpath.lock @@ -1,5 +1,5 @@ format_version 1 -source_commit f63cbf4a8334b45d9b1fbb268116e4dfc1fad1d7 +source_commit f275e08d774f839612374e991d09c5e6ea2d8b57 artifact_count 149 artifact FastInfoset-2.1.1.jar 75d6635e09101ef1545d9a59bb4d9524ec6bf15246540af5435750f271612765 317728 artifact HikariCP-5.0.1.jar 26d492397e6775b4296737a8919bf04047afe5827fdd2c08b4557595436b3a2b 161902 @@ -17,12 +17,12 @@ artifact bson-4.10.1.jar 739a338b2aa0b74d8df9273aac71e37a4e2f3d609658da3d48c9ab5 artifact cache-api-1.1.1.jar 9f34e007edfa82a7b2a2e1b969477dcf5099ce7f4f926fb54ce7e27c4a0cd54b 51281 artifact calcite-core-1.38.0.jar b55427c5e98fdc59e4722062d1414b18db3bc08998a00f42959751200ff09624 8315387 artifact calcite-linq4j-1.38.0.jar 1c4bb46a6ed58f62923ec20c64401f015ef59813647ddb2ca2fc60d7959a21df 518500 -artifact chat2db-community-domain-api-5.3.0.jar cc02f78fd247906f6a403cca6919c91afa76b17cbb737030a1c7582afae7e75c 2100744 -artifact chat2db-community-domain-core-5.3.0.jar 0e7e3de673e3adf08ebc5c86b766fac58a3ea04f1a99ba58bcb21b8267ce08d3 1003427 -artifact chat2db-community-h2-5.3.0.jar 6f4e0734af09bde873ec36c30b4e8188f5552c68524a617a0794880da1543d95 30480 -artifact chat2db-community-mysql-5.3.0.jar c5aa7b6120d380696a8e68ffc0f72bc83997c6c97c77e8ff2cd7d8f1b8793dac 5696156 -artifact chat2db-community-spi-5.3.0.jar 901abca2bf862fbb757eaf083823bae4b279f848e2429bbaaff45a551b004cdd 779815 -artifact chat2db-community-tools-5.3.0.jar b73c70ce61296f34e4df96e2f8c076a22d04a32576ab6f42a082f9d173f74549 412683 +artifact chat2db-community-domain-api-5.3.0.jar 6d9f45a38619c9600d43dc7be674c85dc824d85361ceeefd5a7407d0c77516a8 2100744 +artifact chat2db-community-domain-core-5.3.0.jar 7b39cfc12460c1be3c5516040b68436efb4ad098f23af51ce2c20b8bbc3ff128 1003427 +artifact chat2db-community-h2-5.3.0.jar c5e5ff07df1e98cd8960def9a18b4b982f380aadd12f8c2d5d03ad8c8b725c02 30480 +artifact chat2db-community-mysql-5.3.0.jar f1f57f895f48554a70e68f70ac19d62a999b94e5d4b83e8c3680eb7fa3071776 5696156 +artifact chat2db-community-spi-5.3.0.jar 93363dc5ed05ea86892caa0cdf209740c77d2a2fede002d1c3804e3cb4d0681e 779815 +artifact chat2db-community-tools-5.3.0.jar 1547bfe63cb7d00ee5dad1026d41373084516982ec6f2776633020011fdcc71f 412683 artifact checker-qual-3.43.0.jar 3fbc2e98f05854c3df16df9abaa955b91b15b3ecac33623208ed6424640ef0f6 231525 artifact classmate-1.7.3.jar 75fbda45456f123fb6e2028a6189442d8d0730b357adce4c0a6d7e789f70669b 68249 artifact commons-beanutils-1.9.4.jar 7d938c81789028045c08c065e94be75fc280527620d5bd62b519d5838532368a 246918 @@ -77,7 +77,7 @@ artifact jakarta.validation-api-3.0.2.jar 291c25e6910cc6a7ebd96d4c6baebf6d7c3767 artifact janino-3.1.12.jar 5699878c31e71c9a94cf472f95a2dad52ca7b0449c099276b8bd7e992a43595b 956369 artifact javax.activation-api-1.2.0.jar 43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393 56674 artifact jaxb-api-2.3.1.jar 88b955a0df57880a26a74708bc34f74dcaf8ebf4e78843a28b50eae945732b06 128076 -artifact jaxb-runtime-2.3.1.jar 29609ce598398496c11542de431d90357cf19e0c6f7132f7158f9ffee19ce887 2461167 +artifact jaxb-runtime-2.3.1.jar cffba9b2e3113e388c303f9980acbad5a454553b70e5fcba397b1248bc230761 2461167 artifact jboss-logging-3.6.3.Final.jar 7c12ee575508f81e22b1db9334b969d0ec54ef0fd1dcba24eeab1a44235fc366 62672 artifact jsch-0.2.9.jar 6d0ccb63c6d6003e2c55e46d41dec770276c7b305c0031236c67e1def544bdd6 525631 artifact json-path-2.9.0.jar 11a9ee6f88bb31f1450108d1cf6441377dec84aca075eb6bb2343be157575bea 276633 From 3acf773eca020b0969563c3377883b021b8cd7b9 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 17:32:00 +0800 Subject: [PATCH 10/19] perf(client): release inactive result canvases --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- apps/frontend/src/backend/community.test.ts | 2 +- .../tests/java_community_mysql_product.rs | 2 +- .../chat2db-core/tests/java_community_product.rs | 2 +- .../tests/java_community_h2.rs | 2 +- crates/chat2db-java-bridge/tests/supervisor.rs | 2 +- docs/architecture.md | 2 +- docs/protocol.md | 2 +- docs/stages.md | 2 +- .../rust/compat/CommunityPluginRegistryTest.java | 2 +- scripts/build-community-h2-classpath.sh | 2 +- scripts/community-frontend.lock.json | 4 ++-- scripts/community-frontend.mjs | 3 +++ scripts/verify-community-h2-reproducibility.sh | 2 +- scripts/verify-macos-package.sh | 2 +- third_party/chat2db-community | 2 +- third_party/community-h2-classpath.lock | 16 ++++++++-------- 18 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a7de94..afba49e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: working-directory: java env: CHAT2DB_COMMUNITY_CLASSPATH_DIR: "${{ github.workspace }}/target/community-h2-classpath" - CHAT2DB_COMMUNITY_SOURCE_COMMIT: "f275e08d774f839612374e991d09c5e6ea2d8b57" + CHAT2DB_COMMUNITY_SOURCE_COMMIT: "37a34be858f2566b6b7fcf6c3f64183c1f560853" run: >- ./mvnw -B -pl compat-runtime -Dtest='CommunityPluginRegistryTest#realCommunityH2BuildsAndExecutesBoundedDml,CommunityPluginRegistryTest#realCommunityMysqlRejectsBackslashCrossColumnInjection,CommunityPluginRegistryTest#realCommunityMysqlNormalizesBooleanAliasesAndBits,CommunityPluginRegistryTest#realCommunityH2BuildsNamespaceSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityMysqlBuildsDatabaseNamespaceSql,CommunityPluginRegistryTest#realCommunityNamespaceMapsUnsupportedAndRejectsOversizedInput,JdbcProtocolLoopTest#communityDmlDispatchDoesNotRequireAJdbcSession,JdbcProtocolLoopTest#communityNamespaceDispatchDoesNotRequireAJdbcSession' diff --git a/Makefile b/Makefile index 5b2e530..b9fc905 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ community-h2-reproducibility: community-java-h2-integration: java community-h2-classpath cd java && \ CHAT2DB_COMMUNITY_CLASSPATH_DIR="$(COMMUNITY_CLASSPATH_DIR)" \ - CHAT2DB_COMMUNITY_SOURCE_COMMIT="f275e08d774f839612374e991d09c5e6ea2d8b57" \ + CHAT2DB_COMMUNITY_SOURCE_COMMIT="37a34be858f2566b6b7fcf6c3f64183c1f560853" \ ./mvnw -B -pl compat-runtime \ -Dtest='CommunityPluginRegistryTest#realCommunityH2BuildsAndExecutesBoundedDml,CommunityPluginRegistryTest#realCommunityMysqlRejectsBackslashCrossColumnInjection,CommunityPluginRegistryTest#realCommunityMysqlNormalizesBooleanAliasesAndBits,CommunityPluginRegistryTest#realCommunityH2BuildsNamespaceSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityMysqlBuildsDatabaseNamespaceSql,CommunityPluginRegistryTest#realCommunityMysqlBuildsBoundedTablePreviewSqlWithoutOpeningJdbc,CommunityPluginRegistryTest#realCommunityNamespaceMapsUnsupportedAndRejectsOversizedInput,JdbcProtocolLoopTest#communityDmlDispatchDoesNotRequireAJdbcSession,JdbcProtocolLoopTest#communityNamespaceDispatchDoesNotRequireAJdbcSession' \ test diff --git a/apps/frontend/src/backend/community.test.ts b/apps/frontend/src/backend/community.test.ts index 40256f4..c828f25 100644 --- a/apps/frontend/src/backend/community.test.ts +++ b/apps/frontend/src/backend/community.test.ts @@ -184,7 +184,7 @@ const completeSqlRequest = { } satisfies CompleteCommunitySqlRequest; const catalog = { - sourceCommit: 'f275e08d774f839612374e991d09c5e6ea2d8b57', + sourceCommit: '37a34be858f2566b6b7fcf6c3f64183c1f560853', plugins: [{ databaseType: 'H2', name: 'H2', diff --git a/crates/chat2db-core/tests/java_community_mysql_product.rs b/crates/chat2db-core/tests/java_community_mysql_product.rs index 75484b2..10da147 100644 --- a/crates/chat2db-core/tests/java_community_mysql_product.rs +++ b/crates/chat2db-core/tests/java_community_mysql_product.rs @@ -22,7 +22,7 @@ use futures_util::FutureExt as _; use tempfile::TempDir; use uuid::Uuid; -const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; +const COMMUNITY_COMMIT: &str = "37a34be858f2566b6b7fcf6c3f64183c1f560853"; const MYSQL_DATABASE_TYPE: &str = "MYSQL"; const MYSQL_DRIVER_CLASS: &str = "com.mysql.cj.jdbc.Driver"; const MYSQL_DRIVER_VERSION: &str = "8.0.30"; diff --git a/crates/chat2db-core/tests/java_community_product.rs b/crates/chat2db-core/tests/java_community_product.rs index 4461459..2cd929d 100644 --- a/crates/chat2db-core/tests/java_community_product.rs +++ b/crates/chat2db-core/tests/java_community_product.rs @@ -29,7 +29,7 @@ use chat2db_java_bridge::{ use chat2db_storage::{EncryptedFileVault, Storage}; use tempfile::TempDir; -const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; +const COMMUNITY_COMMIT: &str = "37a34be858f2566b6b7fcf6c3f64183c1f560853"; const H2_DRIVER_CLASS: &str = "org.h2.Driver"; const EVENT_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/crates/chat2db-java-bridge/tests/java_community_h2.rs b/crates/chat2db-java-bridge/tests/java_community_h2.rs index 2e9a1ee..b521edd 100644 --- a/crates/chat2db-java-bridge/tests/java_community_h2.rs +++ b/crates/chat2db-java-bridge/tests/java_community_h2.rs @@ -23,7 +23,7 @@ use chat2db_java_bridge::{ }; use tempfile::TempDir; -const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; +const COMMUNITY_COMMIT: &str = "37a34be858f2566b6b7fcf6c3f64183c1f560853"; const H2_DRIVER_CLASS: &str = "org.h2.Driver"; const COMMUNITY_CLASSPATH_LOCK: &str = include_str!("../../../third_party/community-h2-classpath.lock"); diff --git a/crates/chat2db-java-bridge/tests/supervisor.rs b/crates/chat2db-java-bridge/tests/supervisor.rs index 54c6129..c445cf4 100644 --- a/crates/chat2db-java-bridge/tests/supervisor.rs +++ b/crates/chat2db-java-bridge/tests/supervisor.rs @@ -9,7 +9,7 @@ use chat2db_java_bridge::{ Session, SessionConfig, SessionState, TransactionOptions, UpdateRequest, }; -const COMMUNITY_COMMIT: &str = "f275e08d774f839612374e991d09c5e6ea2d8b57"; +const COMMUNITY_COMMIT: &str = "37a34be858f2566b6b7fcf6c3f64183c1f560853"; fn fixture_command(arguments: &[&str]) -> EngineCommand { arguments.iter().fold( diff --git a/docs/architecture.md b/docs/architecture.md index a22c446..513eb3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,7 @@ The JDBC baseline implements: Stage 7B additionally implements: - a Git submodule fixed at Community commit - `f275e08d774f839612374e991d09c5e6ea2d8b57`; + `37a34be858f2566b6b7fcf6c3f64183c1f560853`; - a reproducible H2 compatibility classpath, established with 148 JARs and extended in Stage 7J to 149 JARs for the retained Community domain-core completion implementation, whose filenames, byte lengths, and SHA-256 diff --git a/docs/protocol.md b/docs/protocol.md index 8272b63..2353fdb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -33,7 +33,7 @@ not committed. The retained Community SPI and its implementations come from the Community 5.3.0 submodule fixed at commit -`f275e08d774f839612374e991d09c5e6ea2d8b57`. The Protobuf messages are +`37a34be858f2566b6b7fcf6c3f64183c1f560853`. The Protobuf messages are compatibility-layer-owned DTOs, not serialized Community Java types; Community plugin, JDBC, parser, and exception objects remain inside Java. The catalog's `source_commit` is provenance that Rust checks against the configured commit. diff --git a/docs/stages.md b/docs/stages.md index 1e08aab..dc28fdc 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -80,7 +80,7 @@ and generated frontend contracts. Downloading, signing, installation, update, rollback, and hot reload remain incomplete. Stage 7B fixes the Community source at commit -`f275e08d774f839612374e991d09c5e6ea2d8b57`, builds its H2 compatibility +`37a34be858f2566b6b7fcf6c3f64183c1f560853`, builds its H2 compatibility classpath reproducibly, and initially locks 148 JAR filenames, lengths, and SHA-256 digests. Before lock verification, the fixed build strips dependency-manifest `Class-Path` entries deterministically, rounds the commit timestamp down to ZIP's diff --git a/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java b/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java index 09324ec..29287b3 100644 --- a/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java +++ b/java/compat-runtime/src/test/java/ai/chat2db/rust/compat/CommunityPluginRegistryTest.java @@ -942,7 +942,7 @@ private static CommunityPluginRegistry openRegistry(Path directory) throws Excep CommunitySqlCompletionBridge.class); constructor.setAccessible(true); return constructor.newInstance( - "f275e08d774f839612374e991d09c5e6ea2d8b57", + "37a34be858f2566b6b7fcf6c3f64183c1f560853", loader, plugins, CommunitySqlCompletionBridge.open(loader)); diff --git a/scripts/build-community-h2-classpath.sh b/scripts/build-community-h2-classpath.sh index 8cc30d8..3a7d04b 100755 --- a/scripts/build-community-h2-classpath.sh +++ b/scripts/build-community-h2-classpath.sh @@ -10,7 +10,7 @@ maven_repository="${repository_root}/target/community-m2" classpath_lock="${repository_root}/third_party/community-h2-classpath.lock" classpath_lock_tool="${repository_root}/scripts/community-classpath-lock.sh" classpath_sanitizer="${repository_root}/scripts/CommunityClasspathSanitizer.java" -expected_commit="f275e08d774f839612374e991d09c5e6ea2d8b57" +expected_commit="37a34be858f2566b6b7fcf6c3f64183c1f560853" community_version="5.3.0" if [[ ! -e "${community_root}/.git" ]]; then diff --git a/scripts/community-frontend.lock.json b/scripts/community-frontend.lock.json index a724662..e53017f 100644 --- a/scripts/community-frontend.lock.json +++ b/scripts/community-frontend.lock.json @@ -2,7 +2,7 @@ "repository": "https://github.com/OtterMind/Chat2DB.git", "submodulePath": "third_party/chat2db-community", "sourcePath": "chat2db-community-client", - "commit": "f275e08d774f839612374e991d09c5e6ea2d8b57", - "tree": "3f94fddda70cf2b2498793de1c64ceb588c3270b", + "commit": "37a34be858f2566b6b7fcf6c3f64183c1f560853", + "tree": "07aacbd4db8f917c99c62a3b5d2aea0529c6945a", "packageManager": "yarn@1.22.22" } diff --git a/scripts/community-frontend.mjs b/scripts/community-frontend.mjs index 6fc75ea..3d917d4 100644 --- a/scripts/community-frontend.mjs +++ b/scripts/community-frontend.mjs @@ -193,6 +193,9 @@ function test() { setupUmi(worktree); runYarn(['test:chat-answer-update'], worktree); runYarn(['test:tree-title-highlight'], worktree); + runYarn(['test:canvas-lifecycle'], worktree); + runYarn(['test:result-resource-activity'], worktree); + runYarn(['test:workspace-resource-activity'], worktree); } function build() { diff --git a/scripts/verify-community-h2-reproducibility.sh b/scripts/verify-community-h2-reproducibility.sh index c90a8ff..ac0df94 100755 --- a/scripts/verify-community-h2-reproducibility.sh +++ b/scripts/verify-community-h2-reproducibility.sh @@ -6,7 +6,7 @@ build_tool="${repository_root}/scripts/build-community-h2-classpath.sh" lock_tool="${repository_root}/scripts/community-classpath-lock.sh" sanitizer="${repository_root}/scripts/CommunityClasspathSanitizer.java" output_directory="${repository_root}/target/community-h2-classpath" -expected_commit="f275e08d774f839612374e991d09c5e6ea2d8b57" +expected_commit="37a34be858f2566b6b7fcf6c3f64183c1f560853" first_lock="$(mktemp "${TMPDIR:-/tmp}/chat2db-community-first-lock.XXXXXX")" second_lock="$(mktemp "${TMPDIR:-/tmp}/chat2db-community-second-lock.XXXXXX")" diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index d24370d..88b09f0 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -49,7 +49,7 @@ fi "${repository_root}/scripts/community-classpath-lock.sh" verify \ "${community_classpath}" \ "${repository_root}/third_party/community-h2-classpath.lock" \ - "f275e08d774f839612374e991d09c5e6ea2d8b57" + "37a34be858f2566b6b7fcf6c3f64183c1f560853" driver_manifest="${driver_root}/01-mysql/driver-pack.json" driver_jar="${driver_root}/01-mysql/mysql-connector-java-8.0.30.jar" diff --git a/third_party/chat2db-community b/third_party/chat2db-community index f275e08..37a34be 160000 --- a/third_party/chat2db-community +++ b/third_party/chat2db-community @@ -1 +1 @@ -Subproject commit f275e08d774f839612374e991d09c5e6ea2d8b57 +Subproject commit 37a34be858f2566b6b7fcf6c3f64183c1f560853 diff --git a/third_party/community-h2-classpath.lock b/third_party/community-h2-classpath.lock index 7f7a0ab..16dcef1 100644 --- a/third_party/community-h2-classpath.lock +++ b/third_party/community-h2-classpath.lock @@ -1,5 +1,5 @@ format_version 1 -source_commit f275e08d774f839612374e991d09c5e6ea2d8b57 +source_commit 37a34be858f2566b6b7fcf6c3f64183c1f560853 artifact_count 149 artifact FastInfoset-2.1.1.jar 75d6635e09101ef1545d9a59bb4d9524ec6bf15246540af5435750f271612765 317728 artifact HikariCP-5.0.1.jar 26d492397e6775b4296737a8919bf04047afe5827fdd2c08b4557595436b3a2b 161902 @@ -17,12 +17,12 @@ artifact bson-4.10.1.jar 739a338b2aa0b74d8df9273aac71e37a4e2f3d609658da3d48c9ab5 artifact cache-api-1.1.1.jar 9f34e007edfa82a7b2a2e1b969477dcf5099ce7f4f926fb54ce7e27c4a0cd54b 51281 artifact calcite-core-1.38.0.jar b55427c5e98fdc59e4722062d1414b18db3bc08998a00f42959751200ff09624 8315387 artifact calcite-linq4j-1.38.0.jar 1c4bb46a6ed58f62923ec20c64401f015ef59813647ddb2ca2fc60d7959a21df 518500 -artifact chat2db-community-domain-api-5.3.0.jar 6d9f45a38619c9600d43dc7be674c85dc824d85361ceeefd5a7407d0c77516a8 2100744 -artifact chat2db-community-domain-core-5.3.0.jar 7b39cfc12460c1be3c5516040b68436efb4ad098f23af51ce2c20b8bbc3ff128 1003427 -artifact chat2db-community-h2-5.3.0.jar c5e5ff07df1e98cd8960def9a18b4b982f380aadd12f8c2d5d03ad8c8b725c02 30480 -artifact chat2db-community-mysql-5.3.0.jar f1f57f895f48554a70e68f70ac19d62a999b94e5d4b83e8c3680eb7fa3071776 5696156 -artifact chat2db-community-spi-5.3.0.jar 93363dc5ed05ea86892caa0cdf209740c77d2a2fede002d1c3804e3cb4d0681e 779815 -artifact chat2db-community-tools-5.3.0.jar 1547bfe63cb7d00ee5dad1026d41373084516982ec6f2776633020011fdcc71f 412683 +artifact chat2db-community-domain-api-5.3.0.jar 5a8e98442095276ac70473a6f6f9a37fe79de29d158a7108951b619b551e2870 2100744 +artifact chat2db-community-domain-core-5.3.0.jar 85f6ebaee61ae8159cb667eb2a67e956faad4d89d1bad8c19365c3fa338fa8f5 1003427 +artifact chat2db-community-h2-5.3.0.jar b8043a40f149b3f32d48da8537856f56dbf6d51d15f01f40c1dc9368da9f9873 30480 +artifact chat2db-community-mysql-5.3.0.jar e9f4fbbdcccfbbb12a2a2bbbf90903456477a325ad454fdae1ec6d3da72f3ab8 5696156 +artifact chat2db-community-spi-5.3.0.jar 30a102ac21bb32a3de8682df5bc146823b5af3297f1bd77e0391f25bc3a25bcd 779815 +artifact chat2db-community-tools-5.3.0.jar 5f58b14c1e03bcb560b95f131283c6d2e692aeac04b2d9a68c80d871eed08a58 412683 artifact checker-qual-3.43.0.jar 3fbc2e98f05854c3df16df9abaa955b91b15b3ecac33623208ed6424640ef0f6 231525 artifact classmate-1.7.3.jar 75fbda45456f123fb6e2028a6189442d8d0730b357adce4c0a6d7e789f70669b 68249 artifact commons-beanutils-1.9.4.jar 7d938c81789028045c08c065e94be75fc280527620d5bd62b519d5838532368a 246918 @@ -77,7 +77,7 @@ artifact jakarta.validation-api-3.0.2.jar 291c25e6910cc6a7ebd96d4c6baebf6d7c3767 artifact janino-3.1.12.jar 5699878c31e71c9a94cf472f95a2dad52ca7b0449c099276b8bd7e992a43595b 956369 artifact javax.activation-api-1.2.0.jar 43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393 56674 artifact jaxb-api-2.3.1.jar 88b955a0df57880a26a74708bc34f74dcaf8ebf4e78843a28b50eae945732b06 128076 -artifact jaxb-runtime-2.3.1.jar cffba9b2e3113e388c303f9980acbad5a454553b70e5fcba397b1248bc230761 2461167 +artifact jaxb-runtime-2.3.1.jar b82d10a3d67f8a8ab31d17b605de7f88fc0935e251d8261915ca87522fa9b8da 2461167 artifact jboss-logging-3.6.3.Final.jar 7c12ee575508f81e22b1db9334b969d0ec54ef0fd1dcba24eeab1a44235fc366 62672 artifact jsch-0.2.9.jar 6d0ccb63c6d6003e2c55e46d41dec770276c7b305c0031236c67e1def544bdd6 525631 artifact json-path-2.9.0.jar 11a9ee6f88bb31f1450108d1cf6441377dec84aca075eb6bb2343be157575bea 276633 From ffb7ecad6effa19963acb2b19f3f775105ee9ff7 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 18:06:59 +0800 Subject: [PATCH 11/19] fix(packaging): build Community classpath before Java tests --- Makefile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b9fc905..c9e7660 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ community-product-h2-integration product-h2-integration mysql-driver-pack \ community-product-mysql-integration \ frontend-deps frontend-source frontend desktop generate-contracts check-contracts \ - macos-runtime macos-package macos-package-verify + macos-runtime macos-package-java macos-package macos-package-verify JAVA_ENGINE_JAR := $(CURDIR)/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar H2_DRIVER_JAR := $(CURDIR)/java/compat-runtime/target/test-drivers/h2-2.3.232.jar @@ -97,7 +97,10 @@ desktop: frontend macos-runtime: ./scripts/build-macos-runtime.sh -macos-package: java community-h2-classpath mysql-driver-pack frontend macos-runtime +macos-package-java: community-h2-classpath + $(MAKE) java + +macos-package: macos-package-java mysql-driver-pack frontend macos-runtime ./scripts/build-macos-package.sh macos-package-verify: From 235b0f54c649a9205442db28aa99fa878ca92f9f Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 20:09:14 +0800 Subject: [PATCH 12/19] feat(runtime): start Java compatibility engine on demand --- crates/chat2db-core/src/community.rs | 82 +- crates/chat2db-core/src/driver_pack.rs | 94 +- crates/chat2db-core/src/engine_manager.rs | 1048 +++++++++++++++++ crates/chat2db-core/src/lib.rs | 356 +++--- crates/chat2db-core/src/query.rs | 13 +- .../tests/java_community_mysql_product.rs | 14 +- crates/chat2db-core/tests/java_h2_product.rs | 192 ++- .../chat2db-java-bridge/src/supervisor/mod.rs | 6 + 8 files changed, 1544 insertions(+), 261 deletions(-) create mode 100644 crates/chat2db-core/src/engine_manager.rs diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index 2dd773f..7d0ddae 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -62,7 +62,7 @@ use chat2db_java_bridge::{ CommunityTableIndex as BridgeCommunityTableIndex, CommunityTableIndexColumn as BridgeCommunityTableIndexColumn, CommunityTrigger as BridgeCommunityTrigger, - CompleteCommunitySqlRequest as BridgeCompleteCommunitySqlRequest, EngineClient, Session, + CompleteCommunitySqlRequest as BridgeCompleteCommunitySqlRequest, Session, }; use chat2db_storage::Storage; @@ -90,6 +90,7 @@ pub fn load_fixed_community_classpath( use crate::{ AppError, Application, datasource_session::{SessionReadOnly, open_datasource_session, resolve_datasource_connection}, + engine_manager::EngineLease, }; impl Application { @@ -100,7 +101,7 @@ impl Application { /// Returns an engine availability, capability, protocol, or Community /// discovery error. pub async fn list_community_plugins(&self) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .list_plugins() @@ -119,7 +120,7 @@ impl Application { request: ListCommunitySchemasRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunitySchemasRequest { datasource_id, database_type, @@ -154,7 +155,7 @@ impl Application { request: ListCommunityDatabasesRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityDatabasesRequest { datasource_id, database_type, @@ -188,7 +189,7 @@ impl Application { request: ListCommunityTablesRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityTablesRequest { datasource_id, database_type, @@ -232,7 +233,7 @@ impl Application { request: ListCommunityColumnsRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityColumnsRequest { datasource_id, database_type, @@ -276,7 +277,7 @@ impl Application { request: ListCommunityIndexesRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityIndexesRequest { datasource_id, database_type, @@ -320,7 +321,7 @@ impl Application { request: ListCommunityViewsRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityViewsRequest { datasource_id, database_type, @@ -364,7 +365,7 @@ impl Application { request: ListCommunityTableKeysRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityTableKeysRequest { datasource_id, database_type, @@ -408,7 +409,7 @@ impl Application { request: ListCommunityTableKeysRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityTableKeysRequest { datasource_id, database_type, @@ -452,7 +453,7 @@ impl Application { request: ListCommunityTableKeysRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityTableKeysRequest { datasource_id, database_type, @@ -496,7 +497,7 @@ impl Application { request: ListCommunityFunctionsRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityFunctionsRequest { datasource_id, database_type, @@ -532,7 +533,7 @@ impl Application { request: GetCommunityFunctionRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let GetCommunityFunctionRequest { datasource_id, database_type, @@ -574,7 +575,7 @@ impl Application { request: GetCommunityFunctionRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let GetCommunityFunctionRequest { datasource_id, database_type, @@ -621,7 +622,7 @@ impl Application { request: ListCommunityProceduresRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityProceduresRequest { datasource_id, database_type, @@ -657,7 +658,7 @@ impl Application { request: GetCommunityProcedureRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let GetCommunityProcedureRequest { datasource_id, database_type, @@ -699,7 +700,7 @@ impl Application { request: GetCommunityProcedureRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let GetCommunityProcedureRequest { datasource_id, database_type, @@ -746,7 +747,7 @@ impl Application { request: ListCommunityTriggersRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let ListCommunityTriggersRequest { datasource_id, database_type, @@ -782,7 +783,7 @@ impl Application { request: GetCommunityTriggerRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let GetCommunityTriggerRequest { datasource_id, database_type, @@ -824,7 +825,7 @@ impl Application { &self, request: BuildCommunityCreateSchemaRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .build_create_schema(request.database_type, bridge_schema(request.schema)) @@ -843,7 +844,7 @@ impl Application { &self, request: BuildCommunityNamespaceSqlRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .build_namespace_sql(bridge_namespace_request(request)) @@ -862,7 +863,7 @@ impl Application { &self, request: BuildCommunityDmlRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .build_dml(bridge_dml_request(request)?) @@ -889,7 +890,7 @@ impl Application { )); } - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; let built = client .build_table_preview_sql(BridgeBuildCommunityTablePreviewSqlRequest { @@ -939,7 +940,7 @@ impl Application { &self, request: ParseCommunitySqlRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .parse_sql(request.database_type, request.sql) @@ -958,7 +959,7 @@ impl Application { &self, request: ValidateCommunitySqlRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .validate_sql(request.database_type, request.sql) @@ -977,7 +978,7 @@ impl Application { &self, request: FormatCommunitySqlRequest, ) -> Result { - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; client .format_sql(request.database_type, request.sql) @@ -997,7 +998,7 @@ impl Application { request: CompleteCommunitySqlRequest, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_community_engine()?; + let engine = self.require_community_engine().await?; let CompleteCommunitySqlRequest { datasource_id, database_type, @@ -1043,14 +1044,17 @@ impl Application { .await } - fn require_community_engine(&self) -> Result { - let engine = self.require_engine()?; - if !engine.community_compatibility_configured() { + async fn require_community_engine(&self) -> Result { + if !self.inner.engine.is_configured() { + return self.require_engine().await; + } + if !self.inner.engine.community_compatibility_configured() { return Err(AppError::unavailable( "community_compatibility_disabled", "The fixed Community compatibility classpath is not configured", )); } + let engine = self.require_engine().await?; Ok(engine) } } @@ -1075,7 +1079,7 @@ where async fn run_community_metadata_session( storage: Storage, - engine: EngineClient, + engine: EngineLease, datasource_id: String, cleanup_phase: &'static str, operation: F, @@ -1088,18 +1092,20 @@ where run_cancellation_safe_with_cleanup( async move { let resolved = resolve_datasource_connection(&storage, &datasource_id).await?; - open_datasource_session(&engine, resolved, SessionReadOnly::Forced).await + let session = + open_datasource_session(&engine, resolved, SessionReadOnly::Forced).await?; + Ok((session, engine)) }, cleanup_phase, - operation, - |session| async move { session.close().await.map_err(AppError::from) }, + move |(session, _engine)| operation(session), + |(session, _engine)| async move { session.close().await.map_err(AppError::from) }, ) .await } async fn run_community_named_metadata_session( storage: Storage, - engine: EngineClient, + engine: EngineLease, datasource_id: String, cleanup_phase: &'static str, operation: F, @@ -1115,11 +1121,11 @@ where let datasource_name = resolved.datasource_name.clone(); let session = open_datasource_session(&engine, resolved, SessionReadOnly::Forced).await?; - Ok((session, datasource_name)) + Ok((session, datasource_name, engine)) }, cleanup_phase, - move |(session, datasource_name)| operation(session, datasource_name), - |(session, _)| async move { session.close().await.map_err(AppError::from) }, + move |(session, datasource_name, _engine)| operation(session, datasource_name), + |(session, _, _engine)| async move { session.close().await.map_err(AppError::from) }, ) .await } diff --git a/crates/chat2db-core/src/driver_pack.rs b/crates/chat2db-core/src/driver_pack.rs index 830da56..4bbf8e1 100644 --- a/crates/chat2db-core/src/driver_pack.rs +++ b/crates/chat2db-core/src/driver_pack.rs @@ -41,7 +41,32 @@ pub(crate) struct PreparedDriverPack { #[derive(Debug)] pub(crate) struct PreparedDriverPacks { packs: Vec, - staging: Option, + _staging: Option, +} + +impl PreparedDriverPacks { + #[must_use] + pub(crate) fn inventory(&self) -> Vec { + self.packs + .iter() + .map(PreparedDriverPack::inventory_entry) + .collect() + } +} + +impl PreparedDriverPack { + fn inventory_entry(&self) -> JdbcDriver { + JdbcDriver { + pack_id: self.id.clone(), + name: self.name.clone(), + version: self.version.clone(), + driver_id: self.specification.driver_id(), + driver_class: self.specification.driver_class.clone(), + artifact_count: u32::try_from(self.specification.artifacts.len()) + .expect("validated driver packs have a bounded artifact count"), + artifact_bytes: self.artifact_bytes.to_string(), + } + } } #[derive(Debug, Error)] @@ -151,13 +176,13 @@ pub(crate) fn discover( let Some((canonical_root, pack_directories)) = scan_pack_directories(root)? else { return Ok(PreparedDriverPacks { packs: Vec::new(), - staging: None, + _staging: None, }); }; if pack_directories.is_empty() { return Ok(PreparedDriverPacks { packs: Vec::new(), - staging: None, + _staging: None, }); } let staging = tempfile::Builder::new() @@ -192,7 +217,7 @@ pub(crate) fn discover( } Ok(PreparedDriverPacks { packs, - staging: Some(staging), + _staging: Some(staging), }) } @@ -453,14 +478,11 @@ fn open_regular_file_no_follow(path: &Path) -> io::Result { pub(crate) async fn preload( supervisor: &EngineSupervisor, - prepared: PreparedDriverPacks, + prepared: &PreparedDriverPacks, ) -> Result, DriverPackError> { - let PreparedDriverPacks { - packs, - staging: _staging, - } = prepared; - if packs.is_empty() { - return Ok(Vec::new()); + let mut inventory = prepared.inventory(); + if inventory.is_empty() { + return Ok(inventory); } let client = supervisor .client() @@ -469,24 +491,17 @@ pub(crate) async fn preload( pack_id: "[startup]".to_owned(), source, })?; - let mut inventory = Vec::with_capacity(packs.len()); - for pack in packs { + for (pack, driver) in prepared.packs.iter().zip(&mut inventory) { let loaded = client - .load_driver(pack.specification) + .load_driver(pack.specification.clone()) .await .map_err(|source| DriverPackError::Load { pack_id: pack.id.clone(), source, })?; - inventory.push(JdbcDriver { - pack_id: pack.id, - name: pack.name, - version: pack.version, - driver_id: loaded.driver_id, - driver_class: loaded.driver_class, - artifact_count: loaded.artifact_count, - artifact_bytes: pack.artifact_bytes.to_string(), - }); + driver.driver_id = loaded.driver_id; + driver.driver_class = loaded.driver_class; + driver.artifact_count = loaded.artifact_count; } Ok(inventory) } @@ -738,7 +753,7 @@ mod tests { let directory = TempDir::new().expect("temporary directory"); let prepared = discover_test(&directory.path().join("missing")).expect("missing root is optional"); - assert!(prepared.packs.is_empty()); + assert!(prepared.inventory().is_empty()); } #[test] @@ -747,7 +762,7 @@ mod tests { write_pack(directory.path(), "01-h2", "h2", "drivers/h2.jar"); let prepared = discover_test(directory.path()).expect("valid pack must be discovered"); - let packs = prepared.packs; + let packs = &prepared.packs; assert_eq!(packs.len(), 1); assert_eq!(packs[0].id, "h2"); @@ -758,6 +773,35 @@ mod tests { assert_eq!(packs[0].artifact_bytes, 8); } + #[test] + fn catalog_inventory_is_repeatable_and_owns_staged_artifacts() { + let directory = TempDir::new().expect("temporary directory"); + write_pack(directory.path(), "01-h2", "h2", "drivers/h2.jar"); + + let prepared = discover_test(directory.path()).expect("valid pack must be discovered"); + let staged_artifact = prepared.packs[0].specification.artifacts[0] + .canonical_path() + .to_path_buf(); + let expected_driver_id = prepared.packs[0].specification.driver_id(); + + let first = prepared.inventory(); + let second = prepared.inventory(); + + assert_eq!(first, second); + assert_eq!(first.len(), 1); + assert_eq!(first[0].pack_id, "h2"); + assert_eq!(first[0].name, "H2"); + assert_eq!(first[0].version, "2.3.232"); + assert_eq!(first[0].driver_id, expected_driver_id); + assert_eq!(first[0].driver_class, "org.h2.Driver"); + assert_eq!(first[0].artifact_count, 1); + assert_eq!(first[0].artifact_bytes, "8"); + assert!(staged_artifact.is_file()); + + drop(prepared); + assert!(!staged_artifact.exists()); + } + #[test] fn rejects_digest_mismatch_and_path_traversal() { let digest_directory = TempDir::new().expect("temporary directory"); diff --git a/crates/chat2db-core/src/engine_manager.rs b/crates/chat2db-core/src/engine_manager.rs new file mode 100644 index 0000000..a5e4dc8 --- /dev/null +++ b/crates/chat2db-core/src/engine_manager.rs @@ -0,0 +1,1048 @@ +use std::{fmt, ops::Deref, sync::Arc, time::Duration}; + +use chat2db_contract::JdbcDriver; +use chat2db_java_bridge::{EngineClient, EngineConfig, EngineState, EngineSupervisor}; +use tokio::{ + sync::{mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +use crate::{AppError, driver_pack, driver_pack::PreparedDriverPacks}; + +pub(crate) const DEFAULT_ENGINE_IDLE_TIMEOUT: Duration = Duration::from_secs(3 * 60); +const DEFAULT_ENGINE_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(10); + +type AcquireReply = oneshot::Sender>; +type ShutdownReply = oneshot::Sender>; + +/// Synchronous lifecycle snapshot used by product health reporting. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum EngineManagerStatus { + Idle, + Starting, + Ready(EngineState), + Stopping { + generation: u64, + reason: EngineStopReason, + }, + Failed(AppError), + ShuttingDown, + Stopped, +} + +/// Why the manager stopped a ready engine generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EngineStopReason { + Idle, + Crashed, + HostShutdown, +} + +/// Application-facing engine source. +#[derive(Clone)] +pub(crate) enum EngineProvider { + Disabled, + Static(EngineClient), + Managed(EngineManagerHandle), +} + +impl EngineProvider { + pub(crate) async fn acquire(&self) -> Result { + match self { + Self::Disabled => Err(engine_not_configured()), + Self::Static(client) => Ok(EngineLease::unmanaged(client.clone())), + Self::Managed(manager) => manager.acquire().await, + } + } + + #[must_use] + pub(crate) fn status(&self) -> Option { + match self { + Self::Disabled => None, + Self::Static(client) => Some(EngineManagerStatus::Ready(client.state())), + Self::Managed(manager) => Some(manager.status()), + } + } + + #[must_use] + pub(crate) const fn is_configured(&self) -> bool { + !matches!(self, Self::Disabled) + } + + #[must_use] + pub(crate) fn community_compatibility_configured(&self) -> bool { + match self { + Self::Disabled => false, + Self::Static(client) => client.community_compatibility_configured(), + Self::Managed(manager) => manager.community_compatibility_configured, + } + } +} + +impl fmt::Debug for EngineProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => formatter.write_str("Disabled"), + Self::Static(client) => formatter + .debug_tuple("Static") + .field(&client.state()) + .finish(), + Self::Managed(manager) => formatter + .debug_tuple("Managed") + .field(&manager.status()) + .finish(), + } + } +} + +/// One logical claim on a specific compatibility-engine generation. +/// +/// Clones share one release token, so the manager releases the claim only +/// after the last clone is dropped. +#[derive(Clone)] +pub struct EngineLease { + client: EngineClient, + release: Option>, +} + +impl EngineLease { + fn managed( + client: EngineClient, + generation: u64, + commands: mpsc::UnboundedSender, + ) -> Self { + Self { + client, + release: Some(Arc::new(ReleaseOnDrop { + generation, + commands, + })), + } + } + + fn unmanaged(client: EngineClient) -> Self { + Self { + client, + release: None, + } + } + + #[must_use] + pub fn client(&self) -> &EngineClient { + &self.client + } + + #[must_use] + pub fn generation(&self) -> u64 { + self.client.state().generation() + } +} + +impl Deref for EngineLease { + type Target = EngineClient; + + fn deref(&self) -> &Self::Target { + self.client() + } +} + +impl AsRef for EngineLease { + fn as_ref(&self) -> &EngineClient { + self.client() + } +} + +impl fmt::Debug for EngineLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EngineLease") + .field("client", &self.client.state()) + .field( + "release", + &self.release.as_ref().map(|_| "[managed release token]"), + ) + .finish() + } +} + +struct ReleaseOnDrop { + generation: u64, + commands: mpsc::UnboundedSender, +} + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = self.commands.send(ManagerCommand::Release { + generation: self.generation, + }); + } +} + +/// Cloneable request side of the engine lifecycle actor. +#[derive(Clone)] +pub(crate) struct EngineManagerHandle { + commands: mpsc::UnboundedSender, + status: watch::Receiver, + community_compatibility_configured: bool, +} + +impl EngineManagerHandle { + pub(crate) async fn acquire(&self) -> Result { + let (reply, result) = oneshot::channel(); + self.commands + .send(ManagerCommand::Acquire { reply }) + .map_err(|_| engine_manager_unavailable())?; + result.await.map_err(|_| engine_manager_unavailable())? + } + + #[must_use] + pub(crate) fn status(&self) -> EngineManagerStatus { + self.status.borrow().clone() + } + + async fn shutdown(&self) -> Result<(), AppError> { + let (reply, result) = oneshot::channel(); + if self + .commands + .send(ManagerCommand::Shutdown { reply }) + .is_err() + { + return if matches!(self.status(), EngineManagerStatus::Stopped) { + Ok(()) + } else { + Err(engine_manager_unavailable()) + }; + } + result.await.map_err(|_| engine_manager_unavailable())? + } +} + +impl fmt::Debug for EngineManagerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EngineManagerHandle") + .field("status", &self.status()) + .finish_non_exhaustive() + } +} + +/// Owns the lifecycle actor and the restartable managed-driver snapshot. +pub(crate) struct EngineManagerOwner { + handle: EngineManagerHandle, + task: Option>, + inventory: Vec, +} + +impl EngineManagerOwner { + #[must_use] + pub(crate) fn with_idle_timeout( + config: EngineConfig, + drivers: PreparedDriverPacks, + idle_timeout: Duration, + ) -> Self { + let community_compatibility_configured = config.community_compatibility_configured(); + let inventory = drivers.inventory(); + let drivers = Arc::new(drivers); + let (commands, receiver) = mpsc::unbounded_channel(); + let (status_sender, status) = watch::channel(EngineManagerStatus::Idle); + let handle = EngineManagerHandle { + commands: commands.clone(), + status, + community_compatibility_configured, + }; + let task = tokio::spawn(run_manager(ManagerContext { + config, + drivers, + idle_timeout, + commands, + receiver, + status: status_sender, + })); + Self { + handle, + task: Some(task), + inventory, + } + } + + #[must_use] + pub(crate) fn handle(&self) -> EngineManagerHandle { + self.handle.clone() + } + + #[must_use] + pub(crate) fn inventory(&self) -> Vec { + self.inventory.clone() + } + + pub(crate) async fn shutdown(&mut self) -> Result<(), AppError> { + let shutdown = self.handle.shutdown().await; + let joined = match self.task.take() { + Some(task) => task.await.map_err(|error| { + tracing::error!(%error, "engine manager task failed to join"); + AppError::internal() + }), + None => Ok(()), + }; + match shutdown { + Err(error) => Err(error), + Ok(()) => joined, + } + } +} + +impl fmt::Debug for EngineManagerOwner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EngineManagerOwner") + .field("status", &self.handle.status()) + .field("driver_count", &self.inventory.len()) + .finish_non_exhaustive() + } +} + +impl Drop for EngineManagerOwner { + fn drop(&mut self) { + if self.task.is_some() { + let _ = self.handle.commands.send(ManagerCommand::ForceShutdown); + } + } +} + +struct ManagerContext { + config: EngineConfig, + drivers: Arc, + idle_timeout: Duration, + commands: mpsc::UnboundedSender, + receiver: mpsc::UnboundedReceiver, + status: watch::Sender, +} + +#[allow(clippy::large_enum_variant)] +enum ManagerCommand { + Acquire { + reply: AcquireReply, + }, + Release { + generation: u64, + }, + StartFinished { + attempt: u64, + result: Result, + }, + IdleExpired { + generation: u64, + epoch: u64, + }, + DrainExpired { + generation: u64, + epoch: u64, + }, + GenerationTerminal { + generation: u64, + state: EngineState, + }, + StopFinished { + generation: u64, + reason: EngineStopReason, + result: Result<(), AppError>, + }, + Shutdown { + reply: ShutdownReply, + }, + ForceShutdown, +} + +#[allow(clippy::large_enum_variant)] +enum ManagerState { + Cold, + Starting { + attempt: u64, + waiters: Vec, + cancellation: CancellationToken, + }, + Running(RunningGeneration), + Stopping { + generation: u64, + reason: EngineStopReason, + waiters: Vec, + }, + Closed, +} + +struct RunningGeneration { + generation: u64, + supervisor: EngineSupervisor, + leases: usize, + idle_epoch: u64, +} + +#[allow(clippy::too_many_lines)] +async fn run_manager(mut context: ManagerContext) { + let mut state = ManagerState::Cold; + let mut next_attempt = 1_u64; + let mut drain_epoch = 0_u64; + let mut shutting_down = false; + let mut shutdown_replies = Vec::::new(); + + while let Some(command) = context.receiver.recv().await { + match command { + ManagerCommand::Acquire { reply } => { + if shutting_down { + let _ = reply.send(Err(runtime_shutting_down())); + continue; + } + state = match state { + ManagerState::Cold => { + let attempt = next_attempt; + next_attempt = next_attempt.wrapping_add(1).max(1); + let cancellation = CancellationToken::new(); + context.status.send_replace(EngineManagerStatus::Starting); + spawn_start( + attempt, + context.config.clone(), + Arc::clone(&context.drivers), + cancellation.clone(), + context.commands.clone(), + ); + ManagerState::Starting { + attempt, + waiters: vec![reply], + cancellation, + } + } + ManagerState::Starting { + attempt, + mut waiters, + cancellation, + } => { + waiters.push(reply); + ManagerState::Starting { + attempt, + waiters, + cancellation, + } + } + ManagerState::Running(mut running) => { + issue_lease(&mut running, reply, &context.commands); + ManagerState::Running(running) + } + ManagerState::Stopping { + generation, + reason, + mut waiters, + } => { + waiters.push(reply); + ManagerState::Stopping { + generation, + reason, + waiters, + } + } + ManagerState::Closed => { + let _ = reply.send(Err(engine_manager_unavailable())); + ManagerState::Closed + } + }; + } + ManagerCommand::Release { generation } => { + state = match state { + ManagerState::Running(mut running) + if running.generation == generation && running.leases > 0 => + { + running.leases -= 1; + if running.leases == 0 { + running.idle_epoch = running.idle_epoch.wrapping_add(1); + if shutting_down { + begin_stop( + running, + EngineStopReason::HostShutdown, + context.commands.clone(), + Vec::new(), + &context.status, + ) + } else { + schedule_idle( + generation, + running.idle_epoch, + context.idle_timeout, + context.commands.clone(), + ); + ManagerState::Running(running) + } + } else { + ManagerState::Running(running) + } + } + other => other, + }; + } + ManagerCommand::StartFinished { attempt, result } => { + let ManagerState::Starting { + attempt: active_attempt, + waiters, + cancellation: _, + } = state + else { + if let Ok(supervisor) = result { + spawn_stop( + supervisor, + EngineStopReason::HostShutdown, + context.commands.clone(), + false, + ); + } + continue; + }; + if active_attempt != attempt { + fail_waiters(waiters, &engine_manager_unavailable()); + if let Ok(supervisor) = result { + spawn_stop( + supervisor, + EngineStopReason::HostShutdown, + context.commands.clone(), + false, + ); + } + state = ManagerState::Cold; + continue; + } + + match result { + Ok(supervisor) if shutting_down => { + fail_waiters(waiters, &runtime_shutting_down()); + let generation = supervisor.state().generation(); + context + .status + .send_replace(EngineManagerStatus::ShuttingDown); + spawn_stop( + supervisor, + EngineStopReason::HostShutdown, + context.commands.clone(), + false, + ); + state = ManagerState::Stopping { + generation, + reason: EngineStopReason::HostShutdown, + waiters: Vec::new(), + }; + } + Ok(supervisor) => { + let engine_state = supervisor.state(); + let generation = engine_state.generation(); + spawn_terminal_watcher( + generation, + supervisor.subscribe_state(), + context.commands.clone(), + ); + context + .status + .send_replace(EngineManagerStatus::Ready(engine_state)); + let mut running = RunningGeneration { + generation, + supervisor, + leases: 0, + idle_epoch: 0, + }; + for waiter in waiters { + issue_lease(&mut running, waiter, &context.commands); + } + if running.leases == 0 { + running.idle_epoch = running.idle_epoch.wrapping_add(1); + schedule_idle( + generation, + running.idle_epoch, + context.idle_timeout, + context.commands.clone(), + ); + } + state = ManagerState::Running(running); + } + Err(error) if shutting_down => { + tracing::warn!(%error, "engine startup failed while the host was shutting down"); + fail_waiters(waiters, &runtime_shutting_down()); + state = ManagerState::Closed; + finish_shutdown(&context.status, &mut shutdown_replies, &Ok(())); + break; + } + Err(error) => { + fail_waiters(waiters, &error); + context + .status + .send_replace(EngineManagerStatus::Failed(error)); + state = ManagerState::Cold; + } + } + } + ManagerCommand::IdleExpired { generation, epoch } => { + state = match state { + ManagerState::Running(running) + if !shutting_down + && running.generation == generation + && running.idle_epoch == epoch + && running.leases == 0 => + { + begin_stop( + running, + EngineStopReason::Idle, + context.commands.clone(), + Vec::new(), + &context.status, + ) + } + other => other, + }; + } + ManagerCommand::DrainExpired { generation, epoch } => { + state = match state { + ManagerState::Running(running) + if shutting_down + && running.generation == generation + && drain_epoch == epoch => + { + begin_stop( + running, + EngineStopReason::HostShutdown, + context.commands.clone(), + Vec::new(), + &context.status, + ) + } + other => other, + }; + } + ManagerCommand::GenerationTerminal { + generation, + state: terminal, + } => { + state = match state { + ManagerState::Running(running) if running.generation == generation => { + let error = terminal_engine_error(&terminal); + context + .status + .send_replace(EngineManagerStatus::Failed(error)); + begin_stop( + running, + EngineStopReason::Crashed, + context.commands.clone(), + Vec::new(), + &context.status, + ) + } + other => other, + }; + } + ManagerCommand::StopFinished { + generation, + reason, + result, + } => { + let ManagerState::Stopping { + generation: active_generation, + reason: active_reason, + waiters, + } = state + else { + continue; + }; + if active_generation != generation || active_reason != reason { + fail_waiters(waiters, &engine_manager_unavailable()); + state = ManagerState::Cold; + continue; + } + if shutting_down { + fail_waiters(waiters, &runtime_shutting_down()); + let shutdown_result = if reason == EngineStopReason::Crashed { + Ok(()) + } else { + result + }; + state = ManagerState::Closed; + finish_shutdown(&context.status, &mut shutdown_replies, &shutdown_result); + break; + } + + if waiters.is_empty() { + match result { + Ok(()) if reason == EngineStopReason::Idle => { + context.status.send_replace(EngineManagerStatus::Idle); + } + Ok(()) if reason == EngineStopReason::Crashed => { + context + .status + .send_replace(EngineManagerStatus::Failed(crashed_engine_error())); + } + Ok(()) => {} + Err(error) => { + context + .status + .send_replace(EngineManagerStatus::Failed(error)); + } + } + state = ManagerState::Cold; + } else { + if let Err(error) = result { + tracing::warn!(%error, "previous engine generation cleanup failed before restart"); + } + let attempt = next_attempt; + next_attempt = next_attempt.wrapping_add(1).max(1); + let cancellation = CancellationToken::new(); + context.status.send_replace(EngineManagerStatus::Starting); + spawn_start( + attempt, + context.config.clone(), + Arc::clone(&context.drivers), + cancellation.clone(), + context.commands.clone(), + ); + state = ManagerState::Starting { + attempt, + waiters, + cancellation, + }; + } + } + ManagerCommand::Shutdown { reply } => { + if matches!(state, ManagerState::Closed) { + let _ = reply.send(Ok(())); + continue; + } + shutdown_replies.push(reply); + if shutting_down { + continue; + } + shutting_down = true; + context + .status + .send_replace(EngineManagerStatus::ShuttingDown); + state = start_host_shutdown( + state, + false, + &mut drain_epoch, + &context.commands, + &context.status, + ); + if matches!(state, ManagerState::Closed) { + finish_shutdown(&context.status, &mut shutdown_replies, &Ok(())); + break; + } + } + ManagerCommand::ForceShutdown => { + if matches!(state, ManagerState::Closed) { + break; + } + shutting_down = true; + context + .status + .send_replace(EngineManagerStatus::ShuttingDown); + state = start_host_shutdown( + state, + true, + &mut drain_epoch, + &context.commands, + &context.status, + ); + if matches!(state, ManagerState::Closed) { + finish_shutdown(&context.status, &mut shutdown_replies, &Ok(())); + break; + } + } + } + } + + if !matches!(state, ManagerState::Closed) { + context.status.send_replace(EngineManagerStatus::Stopped); + for reply in shutdown_replies { + let _ = reply.send(Err(engine_manager_unavailable())); + } + } +} + +fn start_host_shutdown( + state: ManagerState, + force: bool, + drain_epoch: &mut u64, + commands: &mpsc::UnboundedSender, + status: &watch::Sender, +) -> ManagerState { + match state { + ManagerState::Cold | ManagerState::Closed => ManagerState::Closed, + ManagerState::Starting { + attempt, + waiters, + cancellation, + } => { + fail_waiters(waiters, &runtime_shutting_down()); + cancellation.cancel(); + ManagerState::Starting { + attempt, + waiters: Vec::new(), + cancellation, + } + } + ManagerState::Running(running) if force || running.leases == 0 => begin_stop( + running, + EngineStopReason::HostShutdown, + commands.clone(), + Vec::new(), + status, + ), + ManagerState::Running(running) => { + *drain_epoch = (*drain_epoch).wrapping_add(1); + schedule_drain( + running.generation, + *drain_epoch, + DEFAULT_ENGINE_SHUTDOWN_DRAIN_TIMEOUT, + commands.clone(), + ); + ManagerState::Running(running) + } + ManagerState::Stopping { + generation, + reason, + waiters, + } => { + fail_waiters(waiters, &runtime_shutting_down()); + ManagerState::Stopping { + generation, + reason, + waiters: Vec::new(), + } + } + } +} + +fn issue_lease( + running: &mut RunningGeneration, + reply: AcquireReply, + commands: &mpsc::UnboundedSender, +) { + if reply.is_closed() { + return; + } + running.idle_epoch = running.idle_epoch.wrapping_add(1); + running.leases = running.leases.saturating_add(1); + let lease = EngineLease::managed( + running.supervisor.client(), + running.generation, + commands.clone(), + ); + let _ = reply.send(Ok(lease)); +} + +fn begin_stop( + running: RunningGeneration, + mut reason: EngineStopReason, + commands: mpsc::UnboundedSender, + waiters: Vec, + status: &watch::Sender, +) -> ManagerState { + let generation = running.generation; + let terminal_observed = running.supervisor.state().is_terminal(); + if terminal_observed && reason == EngineStopReason::Idle { + reason = EngineStopReason::Crashed; + } + status.send_replace(EngineManagerStatus::Stopping { generation, reason }); + spawn_stop(running.supervisor, reason, commands, terminal_observed); + ManagerState::Stopping { + generation, + reason, + waiters, + } +} + +fn spawn_start( + attempt: u64, + config: EngineConfig, + drivers: Arc, + cancellation: CancellationToken, + commands: mpsc::UnboundedSender, +) { + let startup = + tokio::spawn(async move { start_generation(config, &drivers, &cancellation).await }); + tokio::spawn(async move { + let result = startup.await.unwrap_or_else(|error| { + tracing::error!(%error, attempt, "engine startup task failed to join"); + Err(AppError::internal()) + }); + let _ = commands.send(ManagerCommand::StartFinished { attempt, result }); + }); +} + +async fn start_generation( + config: EngineConfig, + drivers: &PreparedDriverPacks, + cancellation: &CancellationToken, +) -> Result { + let supervisor = EngineSupervisor::spawn(config) + .await + .map_err(AppError::from)?; + if cancellation.is_cancelled() { + stop_cancelled_start(&supervisor).await; + return Err(runtime_shutting_down()); + } + let preload = tokio::select! { + biased; + () = cancellation.cancelled() => { + stop_cancelled_start(&supervisor).await; + return Err(runtime_shutting_down()); + } + result = driver_pack::preload(&supervisor, drivers) => result, + }; + match preload { + Ok(_) => Ok(supervisor), + Err(error) => { + let primary = error.into_app_error(); + if let Err(cleanup) = supervisor.shutdown().await { + tracing::error!( + error = %cleanup, + "Java cleanup failed after managed JDBC driver preload error" + ); + } + Err(primary) + } + } +} + +async fn stop_cancelled_start(supervisor: &EngineSupervisor) { + if let Err(error) = supervisor.shutdown().await { + tracing::error!(%error, "Java cleanup failed after engine startup was cancelled"); + } +} + +fn spawn_stop( + supervisor: EngineSupervisor, + reason: EngineStopReason, + commands: mpsc::UnboundedSender, + terminal_observed: bool, +) { + let generation = supervisor.state().generation(); + let stopping = tokio::spawn(async move { + supervisor + .shutdown() + .await + .map(|_| ()) + .map_err(AppError::from) + }); + tokio::spawn(async move { + let result = stopping.await.unwrap_or_else(|error| { + tracing::error!(%error, generation, "engine shutdown task failed to join"); + Err(AppError::internal()) + }); + let result = if terminal_observed { + if let Err(error) = &result { + tracing::warn!(%error, generation, "reaped a terminal Java generation"); + } + Ok(()) + } else { + result + }; + let _ = commands.send(ManagerCommand::StopFinished { + generation, + reason, + result, + }); + }); +} + +fn spawn_terminal_watcher( + generation: u64, + mut state: watch::Receiver, + commands: mpsc::UnboundedSender, +) { + tokio::spawn(async move { + loop { + let current = state.borrow().clone(); + if current.is_terminal() { + let _ = commands.send(ManagerCommand::GenerationTerminal { + generation, + state: current, + }); + return; + } + if state.changed().await.is_err() { + return; + } + } + }); +} + +fn schedule_idle( + generation: u64, + epoch: u64, + timeout: Duration, + commands: mpsc::UnboundedSender, +) { + tokio::spawn(async move { + tokio::time::sleep(timeout).await; + let _ = commands.send(ManagerCommand::IdleExpired { generation, epoch }); + }); +} + +fn schedule_drain( + generation: u64, + epoch: u64, + timeout: Duration, + commands: mpsc::UnboundedSender, +) { + tokio::spawn(async move { + tokio::time::sleep(timeout).await; + let _ = commands.send(ManagerCommand::DrainExpired { generation, epoch }); + }); +} + +fn fail_waiters(waiters: Vec, error: &AppError) { + for waiter in waiters { + let _ = waiter.send(Err(error.clone())); + } +} + +fn finish_shutdown( + status: &watch::Sender, + replies: &mut Vec, + result: &Result<(), AppError>, +) { + status.send_replace(EngineManagerStatus::Stopped); + for reply in replies.drain(..) { + let _ = reply.send(result.clone()); + } +} + +fn engine_not_configured() -> AppError { + AppError::unavailable( + "database_engine_unavailable", + "The database compatibility engine is not configured", + ) +} + +fn engine_manager_unavailable() -> AppError { + AppError::unavailable( + "database_engine_unavailable", + "The database compatibility engine is unavailable", + ) +} + +fn runtime_shutting_down() -> AppError { + AppError::unavailable( + "runtime_shutting_down", + "The Chat2DB runtime is shutting down", + ) +} + +fn terminal_engine_error(state: &EngineState) -> AppError { + let message = match state { + EngineState::Crashed { .. } => "The database compatibility engine crashed", + EngineState::Failed { .. } => "The database compatibility engine failed", + EngineState::Stopped { .. } => "The database compatibility engine stopped", + _ => "The database compatibility engine became unavailable", + }; + AppError::unavailable("database_engine_unavailable", message) +} + +fn crashed_engine_error() -> AppError { + AppError::unavailable( + "database_engine_unavailable", + "The database compatibility engine crashed", + ) +} diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index 104a284..cce2f6c 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -5,6 +5,7 @@ mod community; mod convert; mod datasource_session; mod driver_pack; +mod engine_manager; mod error; mod operation; mod query; @@ -37,9 +38,14 @@ use tokio::{sync::Mutex, task::JoinHandle}; use agent::AgentRunHub; pub use agent::AgentRunSubscription; pub use community::load_fixed_community_classpath; +pub use engine_manager::EngineLease; pub use error::{AppError, AppErrorKind}; pub use operation::OperationSubscription; +use engine_manager::{ + DEFAULT_ENGINE_IDLE_TIMEOUT, EngineManagerOwner, EngineManagerStatus, EngineProvider, + EngineStopReason, +}; use operation::OperationHub; const TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); @@ -54,7 +60,7 @@ pub(crate) struct ApplicationInner { started_at: Instant, runtime_status: RuntimeStatus, storage: Option, - engine: Option, + engine: EngineProvider, drivers: Vec, managed_driver_ids: Option>, agent_runs: AgentRunHub, @@ -68,6 +74,7 @@ pub(crate) struct ApplicationInner { pub struct RuntimeHost { application: Application, supervisor: Option, + engine_manager: Option, } /// Inputs required to open durable storage and start one Java engine generation. @@ -76,6 +83,7 @@ pub struct RuntimeConfig { driver_pack_dir: Option, vault_master_key_base64: Option, engine: EngineConfig, + engine_idle_timeout: Duration, } impl RuntimeConfig { @@ -86,6 +94,7 @@ impl RuntimeConfig { driver_pack_dir: None, vault_master_key_base64: None, engine, + engine_idle_timeout: DEFAULT_ENGINE_IDLE_TIMEOUT, } } @@ -107,6 +116,13 @@ impl RuntimeConfig { self.vault_master_key_base64 = Some(master_key.into()); self } + + /// Overrides how long an unused managed Java generation remains resident. + #[must_use] + pub const fn with_engine_idle_timeout(mut self, timeout: Duration) -> Self { + self.engine_idle_timeout = timeout; + self + } } impl std::fmt::Debug for RuntimeConfig { @@ -120,6 +136,7 @@ impl std::fmt::Debug for RuntimeConfig { &self.vault_master_key_base64.as_ref().map(|_| "[REDACTED]"), ) .field("engine", &self.engine) + .field("engine_idle_timeout", &self.engine_idle_timeout) .finish() } } @@ -130,7 +147,7 @@ impl std::fmt::Debug for Application { .debug_struct("Application") .field("runtime_status", &self.inner.runtime_status) .field("storage_configured", &self.inner.storage.is_some()) - .field("engine_configured", &self.inner.engine.is_some()) + .field("engine_configured", &self.inner.engine.is_configured()) .finish_non_exhaustive() } } @@ -141,6 +158,7 @@ impl std::fmt::Debug for RuntimeHost { .debug_struct("RuntimeHost") .field("application", &self.application) .field("supervisor_owned", &self.supervisor.is_some()) + .field("engine_manager_owned", &self.engine_manager.is_some()) .finish() } } @@ -155,36 +173,46 @@ impl Application { /// Creates a product service root with optional components disabled. #[must_use] pub fn new() -> Self { - Self::compose(RuntimeStatus::Ready, None, None, None) + Self::compose(RuntimeStatus::Ready, None, EngineProvider::Disabled, None) } /// Creates a service root with an explicit readiness state. #[must_use] pub fn with_runtime_status(runtime_status: RuntimeStatus) -> Self { - Self::compose(runtime_status, None, None, None) + Self::compose(runtime_status, None, EngineProvider::Disabled, None) } /// Creates a ready service root around fully opened local storage. #[must_use] pub fn with_storage(storage: Storage) -> Self { - Self::compose(RuntimeStatus::Ready, Some(storage), None, None) + Self::compose( + RuntimeStatus::Ready, + Some(storage), + EngineProvider::Disabled, + None, + ) } /// Creates the complete product service root around storage and one engine generation. #[must_use] pub fn with_services(storage: Storage, engine: EngineClient) -> Self { - Self::compose(RuntimeStatus::Ready, Some(storage), Some(engine), None) + Self::compose( + RuntimeStatus::Ready, + Some(storage), + EngineProvider::Static(engine), + None, + ) } - fn with_services_and_drivers( + fn with_managed_services( storage: Storage, - engine: EngineClient, + engine: engine_manager::EngineManagerHandle, drivers: Vec, ) -> Self { Self::compose( RuntimeStatus::Ready, Some(storage), - Some(engine), + EngineProvider::Managed(engine), Some(drivers), ) } @@ -192,7 +220,7 @@ impl Application { fn compose( runtime_status: RuntimeStatus, storage: Option, - engine: Option, + engine: EngineProvider, drivers: Option>, ) -> Self { let managed_driver_ids = drivers.as_ref().map(|drivers| { @@ -225,12 +253,6 @@ impl Application { self.inner.storage.as_ref() } - /// Returns the current compatibility-engine client when configured. - #[must_use] - pub fn engine_client(&self) -> Option { - self.inner.engine.clone() - } - pub(crate) fn require_storage(&self) -> Result { self.inner.storage.clone().ok_or_else(|| { AppError::unavailable( @@ -240,40 +262,23 @@ impl Application { }) } - pub(crate) fn require_engine(&self) -> Result { - self.inner.engine.clone().ok_or_else(|| { - AppError::unavailable( - "database_engine_unavailable", - "The database compatibility engine is not configured", - ) - }) + /// Acquires a generation-scoped lease, starting Java on first use. + /// + /// # Errors + /// + /// Returns an availability or startup error when the engine cannot be acquired. + pub async fn acquire_engine(&self) -> Result { + self.inner.engine.acquire().await + } + + pub(crate) async fn require_engine(&self) -> Result { + self.acquire_engine().await } /// Returns health from the shared product boundary. #[must_use] pub fn health(&self) -> HealthResponse { - let engine_component = self.inner.engine.as_ref().map_or_else( - || ComponentHealth { - id: "database-engine".to_owned(), - label: "Database engine".to_owned(), - state: ComponentState::Disabled, - detail: "Not configured by this delivery adapter".to_owned(), - }, - |engine| match engine.state() { - EngineState::Ready { .. } => ComponentHealth { - id: "database-engine".to_owned(), - label: "Database engine".to_owned(), - state: ComponentState::Ready, - detail: "Ready".to_owned(), - }, - state => ComponentHealth { - id: "database-engine".to_owned(), - label: "Database engine".to_owned(), - state: ComponentState::Unavailable, - detail: format!("Compatibility engine is {}", state.label()), - }, - }, - ); + let engine_component = engine_health(&self.inner.engine); let status = if self.inner.runtime_status == RuntimeStatus::Unavailable { RuntimeStatus::Unavailable } else if engine_component.state == ComponentState::Unavailable { @@ -281,7 +286,7 @@ impl Application { } else { self.inner.runtime_status }; - let community_component = community_health(self.inner.engine.as_ref()); + let community_component = community_health(&self.inner.engine); HealthResponse { product: ProductInfo::community(env!("CARGO_PKG_VERSION")), status, @@ -300,14 +305,17 @@ impl Application { id: "jdbc-drivers".to_owned(), label: "JDBC drivers".to_owned(), state: ComponentState::Disabled, - detail: "No managed driver packs loaded".to_owned(), + detail: "No managed driver packs discovered".to_owned(), } } else { ComponentHealth { id: "jdbc-drivers".to_owned(), label: "JDBC drivers".to_owned(), state: ComponentState::Ready, - detail: format!("{} managed driver packs loaded", self.inner.drivers.len()), + detail: format!( + "{} managed driver packs available", + self.inner.drivers.len() + ), } }, self.inner.storage.as_ref().map_or_else( @@ -361,7 +369,7 @@ impl Application { )); } self.require_managed_driver(driver_id)?; - let engine = self.require_engine()?; + let engine = self.require_engine().await?; let session = datasource_session::open_datasource_session( &engine, datasource_session::ResolvedDatasourceConnection { @@ -626,21 +634,28 @@ impl Application { } impl RuntimeHost { - /// Opens the production vault and storage before spawning the Java engine. + /// Opens production storage and discovers drivers without starting Java. /// /// An explicit base64 master key selects the headless encrypted-file path. /// Without one, supported desktop platforms require their OS keyring. /// /// # Errors /// - /// Returns an error if the data directory, vault, storage, or engine cannot open. + /// Returns an error if the data directory, vault, storage, or driver catalog cannot open. pub async fn open(config: RuntimeConfig) -> Result { let RuntimeConfig { data_dir, driver_pack_dir, vault_master_key_base64, engine, + engine_idle_timeout, } = config; + if engine_idle_timeout.is_zero() { + return Err(AppError::invalid( + "invalid_engine_idle_timeout", + "The engine idle timeout must be greater than zero", + )); + } let storage = tokio::task::spawn_blocking(move || { let data_dir = data_dir.map_or_else(Storage::default_data_dir, Ok)?; let vault: Arc = match vault_master_key_base64 { @@ -669,7 +684,15 @@ impl RuntimeHost { .map_err(|_| AppError::internal())? .map_err(driver_pack::DriverPackError::into_app_error)?; let engine = engine.with_driver_snapshot_parent(driver_runtime_directory); - Self::spawn_with_driver_packs(storage, engine, prepared_packs).await + let manager = + EngineManagerOwner::with_idle_timeout(engine, prepared_packs, engine_idle_timeout); + let application = + Application::with_managed_services(storage, manager.handle(), manager.inventory()); + Ok(Self { + application, + supervisor: None, + engine_manager: Some(manager), + }) } /// Spawns and owns a validated Java engine generation. @@ -691,33 +714,6 @@ impl RuntimeHost { Ok(Self::from_supervisor(storage, supervisor)) } - async fn spawn_with_driver_packs( - storage: Storage, - config: EngineConfig, - packs: driver_pack::PreparedDriverPacks, - ) -> Result { - let supervisor = EngineSupervisor::spawn(config) - .await - .map_err(AppError::from)?; - let drivers = match driver_pack::preload(&supervisor, packs).await { - Ok(drivers) => drivers, - Err(error) => { - tracing::error!(%error, "managed JDBC driver preload failed"); - let application_error = error.into_app_error(); - if let Err(shutdown_error) = supervisor.shutdown().await { - tracing::error!(%shutdown_error, "Java cleanup failed after driver preload error"); - } - return Err(application_error); - } - }; - let application = - Application::with_services_and_drivers(storage, supervisor.client(), drivers); - Ok(Self { - application, - supervisor: Some(supervisor), - }) - } - /// Composes a host around an already-started supervisor. #[must_use] pub fn from_supervisor(storage: Storage, supervisor: EngineSupervisor) -> Self { @@ -725,6 +721,7 @@ impl RuntimeHost { Self { application, supervisor: Some(supervisor), + engine_manager: None, } } @@ -733,9 +730,13 @@ impl RuntimeHost { self.application.clone() } - #[must_use] - pub fn engine_client(&self) -> Option { - self.application.engine_client() + /// Acquires a generation-scoped lease, starting managed Java on demand. + /// + /// # Errors + /// + /// Returns an availability or startup error when the engine cannot be acquired. + pub async fn acquire_engine(&self) -> Result { + self.application.acquire_engine().await } /// Cancels active operations, shuts down the Java process, and joins tasks. @@ -746,74 +747,143 @@ impl RuntimeHost { pub async fn shutdown(&mut self) -> Result<(), AppError> { self.application.begin_shutdown().await; self.application.join_tasks().await; - match self.supervisor.take() { - Some(supervisor) => supervisor + if let Some(supervisor) = self.supervisor.take() { + supervisor .shutdown() .await .map(|_| ()) - .map_err(AppError::from), - None => Ok(()), + .map_err(AppError::from)?; + } + if let Some(mut manager) = self.engine_manager.take() { + manager.shutdown().await?; } + Ok(()) } } -fn community_health(engine: Option<&EngineClient>) -> ComponentHealth { - engine.map_or_else( - || ComponentHealth { +fn engine_health(engine: &EngineProvider) -> ComponentHealth { + let (state, detail) = match engine.status() { + None => ( + ComponentState::Disabled, + "Not configured by this delivery adapter".to_owned(), + ), + Some(EngineManagerStatus::Idle) => ( + ComponentState::Ready, + "Available on demand; Java is not running".to_owned(), + ), + Some(EngineManagerStatus::Starting) => { + (ComponentState::Ready, "Starting on demand".to_owned()) + } + Some(EngineManagerStatus::Ready(EngineState::Ready { .. })) => { + (ComponentState::Ready, "Ready".to_owned()) + } + Some(EngineManagerStatus::Stopping { + reason: EngineStopReason::Idle, + .. + }) => ( + ComponentState::Ready, + "Releasing the idle Java process".to_owned(), + ), + Some(EngineManagerStatus::Failed(_)) => ( + ComponentState::Unavailable, + "The last Java startup or generation failed; the next request can retry".to_owned(), + ), + Some(EngineManagerStatus::Ready(state)) => ( + ComponentState::Unavailable, + format!("Compatibility engine is {}", state.label()), + ), + Some( + EngineManagerStatus::Stopping { .. } + | EngineManagerStatus::ShuttingDown + | EngineManagerStatus::Stopped, + ) => ( + ComponentState::Unavailable, + "Compatibility engine is shutting down".to_owned(), + ), + }; + ComponentHealth { + id: "database-engine".to_owned(), + label: "Database engine".to_owned(), + state, + detail, + } +} + +fn community_health(engine: &EngineProvider) -> ComponentHealth { + if !engine.community_compatibility_configured() { + return ComponentHealth { id: "community-compatibility".to_owned(), label: "Community compatibility".to_owned(), state: ComponentState::Disabled, detail: "Fixed Community classpath not configured".to_owned(), - }, - |engine| match engine.state() { - EngineState::Ready { .. } if !engine.community_compatibility_configured() => { - ComponentHealth { - id: "community-compatibility".to_owned(), - label: "Community compatibility".to_owned(), - state: ComponentState::Disabled, - detail: "Fixed Community classpath not configured".to_owned(), - } - } - EngineState::Ready { identity, .. } - if [ - COMMUNITY_PLUGIN_CATALOG_CAPABILITY, - COMMUNITY_SCHEMA_METADATA_CAPABILITY, - COMMUNITY_OBJECT_METADATA_CAPABILITY, - COMMUNITY_RELATION_METADATA_CAPABILITY, - COMMUNITY_DQL_BUILDER_CAPABILITY, - COMMUNITY_SQL_BUILDER_CAPABILITY, - COMMUNITY_SQL_PARSER_CAPABILITY, - ] - .iter() - .all(|required| { - identity - .capabilities - .iter() - .any(|capability| capability == required) - }) => - { - ComponentHealth { - id: "community-compatibility".to_owned(), - label: "Community compatibility".to_owned(), - state: ComponentState::Ready, - detail: "Fixed Community plugin, metadata, builder, and parser services ready" - .to_owned(), - } - } - EngineState::Ready { .. } => ComponentHealth { - id: "community-compatibility".to_owned(), - label: "Community compatibility".to_owned(), - state: ComponentState::Unavailable, - detail: "Required Community capabilities are unavailable".to_owned(), - }, - state => ComponentHealth { - id: "community-compatibility".to_owned(), - label: "Community compatibility".to_owned(), - state: ComponentState::Unavailable, - detail: format!("Compatibility engine is {}", state.label()), - }, - }, - ) + }; + } + let (state, detail) = match engine.status() { + Some(EngineManagerStatus::Idle) => ( + ComponentState::Ready, + "Available on demand; Java is not running".to_owned(), + ), + Some(EngineManagerStatus::Starting) => { + (ComponentState::Ready, "Starting on demand".to_owned()) + } + Some(EngineManagerStatus::Ready(EngineState::Ready { identity, .. })) + if [ + COMMUNITY_PLUGIN_CATALOG_CAPABILITY, + COMMUNITY_SCHEMA_METADATA_CAPABILITY, + COMMUNITY_OBJECT_METADATA_CAPABILITY, + COMMUNITY_RELATION_METADATA_CAPABILITY, + COMMUNITY_DQL_BUILDER_CAPABILITY, + COMMUNITY_SQL_BUILDER_CAPABILITY, + COMMUNITY_SQL_PARSER_CAPABILITY, + ] + .iter() + .all(|required| { + identity + .capabilities + .iter() + .any(|capability| capability == required) + }) => + { + ( + ComponentState::Ready, + "Fixed Community plugin, metadata, builder, and parser services ready".to_owned(), + ) + } + Some(EngineManagerStatus::Stopping { + reason: EngineStopReason::Idle, + .. + }) => ( + ComponentState::Ready, + "Releasing the idle Java process".to_owned(), + ), + Some(EngineManagerStatus::Ready(EngineState::Ready { .. })) => ( + ComponentState::Unavailable, + "Required Community capabilities are unavailable".to_owned(), + ), + Some(EngineManagerStatus::Failed(_)) => ( + ComponentState::Unavailable, + "The last Community engine startup failed; the next request can retry".to_owned(), + ), + Some(EngineManagerStatus::Ready(state)) => ( + ComponentState::Unavailable, + format!("Compatibility engine is {}", state.label()), + ), + Some( + EngineManagerStatus::Stopping { .. } + | EngineManagerStatus::ShuttingDown + | EngineManagerStatus::Stopped, + ) + | None => ( + ComponentState::Unavailable, + "Compatibility engine is shutting down".to_owned(), + ), + }; + ComponentHealth { + id: "community-compatibility".to_owned(), + label: "Community compatibility".to_owned(), + state, + detail, + } } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] @@ -980,8 +1050,12 @@ mod tests { }) .await .expect("non-managed composition must preserve external driver compatibility"); - let managed = - Application::compose(RuntimeStatus::Ready, Some(storage), None, Some(Vec::new())); + let managed = Application::compose( + RuntimeStatus::Ready, + Some(storage), + super::EngineProvider::Disabled, + Some(Vec::new()), + ); let create_error = managed .create_datasource(CreateDatasourceRequest { diff --git a/crates/chat2db-core/src/query.rs b/crates/chat2db-core/src/query.rs index efd7068..3ebbccb 100644 --- a/crates/chat2db-core/src/query.rs +++ b/crates/chat2db-core/src/query.rs @@ -3,9 +3,8 @@ use std::time::Duration; use chat2db_contract::{ApiError, QueryAccepted, QueryLimits, StartQueryRequest}; use chat2db_engine_protocol::wire; use chat2db_java_bridge::{ - BridgeError, CancelDisposition as BridgeCancelDisposition, ConnectionProperty, EngineClient, - JdbcParameter, QueryEvent, QueryOptions, QueryRequest, QueryStream, SessionConfig, - UpdateRequest, + BridgeError, CancelDisposition as BridgeCancelDisposition, ConnectionProperty, JdbcParameter, + QueryEvent, QueryOptions, QueryRequest, QueryStream, SessionConfig, UpdateRequest, }; use chat2db_storage::{ResultWriter, Storage, StorageError}; use tokio::sync::{oneshot, watch}; @@ -17,6 +16,7 @@ use crate::{ ResolvedDatasourceConnection, SessionReadOnly, open_datasource_session, resolve_datasource_connection, }, + engine_manager::EngineLease, operation::CancellationRequest, }; @@ -102,7 +102,7 @@ impl Application { prepared: PreparedQuery, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_engine()?; + let engine = self.require_engine().await?; let accepting_work = self.inner.accepting_work.lock().await; if !*accepting_work { return Err(AppError::unavailable( @@ -163,7 +163,7 @@ impl Application { cancellation: watch::Receiver, query: PreparedQuery, storage: Storage, - engine: EngineClient, + engine: EngineLease, ) { let outcome = self .execute_query_task(&operation_id, cancellation, query, storage, engine) @@ -191,7 +191,7 @@ impl Application { mut cancellation: watch::Receiver, query: PreparedQuery, storage: Storage, - engine: EngineClient, + engine: EngineLease, ) -> Result { let resolved = resolve_datasource_connection(&storage, &query.datasource_id).await?; @@ -377,6 +377,7 @@ impl Application { .map_err(DatabaseWriteError::not_started)?; let engine = self .require_engine() + .await .map_err(DatabaseWriteError::not_started)?; let ResolvedDatasourceConnection { driver_id, diff --git a/crates/chat2db-core/tests/java_community_mysql_product.rs b/crates/chat2db-core/tests/java_community_mysql_product.rs index 10da147..1fca3be 100644 --- a/crates/chat2db-core/tests/java_community_mysql_product.rs +++ b/crates/chat2db-core/tests/java_community_mysql_product.rs @@ -13,7 +13,9 @@ use chat2db_contract::{ StartCommunityTablePreviewRequest, StartQueryRequest, UpdateDatasourceRequest, ValidateCommunitySqlRequest, }; -use chat2db_core::{Application, RuntimeConfig, RuntimeHost, load_fixed_community_classpath}; +use chat2db_core::{ + Application, EngineLease, RuntimeConfig, RuntimeHost, load_fixed_community_classpath, +}; use chat2db_java_bridge::{ BridgeError, ConnectionProperty, DriverClient, EngineCommand, EngineConfig, Session, SessionConfig, UpdateRequest, @@ -48,6 +50,7 @@ struct MysqlProductHarness { _directory: TempDir, host: RuntimeHost, application: Application, + _engine_lease: EngineLease, driver: DriverClient, driver_id: String, } @@ -200,9 +203,11 @@ impl MysqlProductHarness { .await .expect("managed MySQL product runtime must start"); let application = host.application(); - let driver = host - .engine_client() - .expect("running host must expose the Java engine") + let engine_lease = host + .acquire_engine() + .await + .expect("first database use must start the Java engine"); + let driver = engine_lease .driver_client() .expect("running engine must expose JDBC"); @@ -223,6 +228,7 @@ impl MysqlProductHarness { _directory: directory, host, application, + _engine_lease: engine_lease, driver, driver_id, } diff --git a/crates/chat2db-core/tests/java_h2_product.rs b/crates/chat2db-core/tests/java_h2_product.rs index 6b5bd18..a35826a 100644 --- a/crates/chat2db-core/tests/java_h2_product.rs +++ b/crates/chat2db-core/tests/java_h2_product.rs @@ -18,6 +18,7 @@ use chat2db_java_bridge::{ DriverArtifact, DriverClient, DriverSpec, EngineCommand, EngineConfig, EngineSupervisor, }; use chat2db_storage::{EncryptedFileVault, Storage, StorageOptions}; +use futures_util::future::join_all; use tempfile::TempDir; const H2_DRIVER_CLASS: &str = "org.h2.Driver"; @@ -165,36 +166,33 @@ async fn assert_community_disabled(application: &Application) { } #[tokio::test] -async fn runtime_host_preloads_a_manifested_h2_driver_pack() { +async fn runtime_host_open_keeps_java_dormant() { + let directory = TempDir::new().expect("temporary runtime directory"); + let missing_java = directory.path().join("missing-java"); + let engine = EngineConfig::new(EngineCommand::new(&missing_java)); + let config = RuntimeConfig::new(engine) + .with_data_dir(directory.path().join("data")) + .with_vault_master_key_base64(STANDARD.encode([0x5a; 32])); + + let mut host = RuntimeHost::open(config) + .await + .expect("opening storage must not spawn the missing Java executable"); + assert_engine_available_on_demand(&host.application()); + assert!(host.application().list_drivers().items.is_empty()); + host.shutdown() + .await + .expect("a dormant runtime must shut down cleanly"); +} + +#[tokio::test] +async fn managed_h2_starts_on_demand_and_reloads_after_idle_shutdown() { let engine_jar = required_jar("CHAT2DB_JAVA_ENGINE_JAR"); let h2_jar = required_jar("CHAT2DB_H2_DRIVER_JAR"); let directory = TempDir::new().expect("temporary runtime directory"); let driver_pack_root = directory.path().join("driver-packs"); let pack_directory = driver_pack_root.join("01-h2"); - fs::create_dir_all(&pack_directory).expect("driver pack directory must be created"); - let managed_h2_jar = pack_directory.join("h2-2.3.232.jar"); - fs::copy(h2_jar, &managed_h2_jar).expect("H2 must be copied into the managed pack"); - let artifact = DriverArtifact::from_path(&managed_h2_jar).expect("managed H2 must hash"); - let mut sha256 = String::with_capacity(64); - for byte in artifact.sha256() { - write!(&mut sha256, "{byte:02x}").expect("writing to String cannot fail"); - } - fs::write( - pack_directory.join("driver-pack.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "schemaVersion": 1, - "id": "h2", - "name": "H2", - "version": "2.3.232", - "driverClass": H2_DRIVER_CLASS, - "artifacts": [{ - "path": "h2-2.3.232.jar", - "sha256": sha256 - }] - })) - .expect("driver manifest must serialize"), - ) - .expect("driver manifest must be written"); + write_driver_pack(&driver_pack_root, "01-h2", "h2", H2_DRIVER_CLASS, &h2_jar); + let managed_h2_jar = pack_directory.join("driver.jar"); let engine = EngineConfig::new(EngineCommand::java_jar("java", engine_jar)).with_timeouts( Duration::from_secs(10), @@ -206,16 +204,18 @@ async fn runtime_host_preloads_a_manifested_h2_driver_pack() { let config = RuntimeConfig::new(engine) .with_data_dir(&data_dir) .with_driver_pack_dir(&driver_pack_root) - .with_vault_master_key_base64(STANDARD.encode([0x5a; 32])); + .with_vault_master_key_base64(STANDARD.encode([0x5a; 32])) + .with_engine_idle_timeout(Duration::from_millis(100)); let mut host = RuntimeHost::open(config) .await - .expect("runtime host must preload the H2 pack"); + .expect("runtime host must discover the H2 pack without starting Java"); let application = host.application(); + assert_engine_available_on_demand(&application); let inventory = application.list_drivers(); assert_eq!(inventory.items.len(), 1); let installed = &inventory.items[0]; assert_eq!(installed.pack_id, "h2"); - assert_eq!(installed.version, "2.3.232"); + assert_eq!(installed.version, "test"); assert_eq!(installed.driver_class, H2_DRIVER_CLASS); assert_eq!(installed.artifact_count, 1); assert_eq!( @@ -239,27 +239,49 @@ async fn runtime_host_preloads_a_manifested_h2_driver_pack() { }) .await .expect("managed-driver datasource must be created"); + let mut first_leases = join_all((0..16).map(|_| application.acquire_engine())) + .await + .into_iter() + .map(|result| result.expect("concurrent first use must share one successful startup")) + .collect::>(); + let first_lease = first_leases + .pop() + .expect("at least one first-use lease must exist"); + let first_generation = first_lease.generation(); + assert!( + first_leases + .iter() + .all(|lease| lease.generation() == first_generation), + "concurrent first use must return one Java generation" + ); + drop(first_leases); let accepted = application - .start_query(StartQueryRequest { - datasource_id: datasource.id, - sql: "SELECT X AS id, CAST('row-' || X AS VARCHAR(16)) AS label, \ - MOD(X, 2) = 0 AS active, \ - CAST(X + 0.25 AS DECIMAL(10, 2)) AS amount \ - FROM SYSTEM_RANGE(1, 5) ORDER BY X" - .to_owned(), - parameters: Vec::new(), - limits: QueryLimits { - max_rows: "0".to_owned(), - max_result_bytes: (1024_u64 * 1024).to_string(), - batch_rows: 2, - batch_bytes: 1024, - result_ttl_seconds: 60, - }, - }) + .start_query(h2_range_query(datasource.id.clone())) .await .expect("managed H2 query must be accepted"); let result_id = wait_for_result(&application, &accepted.operation_id).await; assert_result_page(&application, &result_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_engine_running(&application); + drop(first_lease); + + wait_for_engine_idle(&application).await; + let second_lease = host + .acquire_engine() + .await + .expect("database use after idle shutdown must start a new Java generation"); + assert_ne!( + second_lease.generation(), + first_generation, + "idle restart must not reuse a stale engine generation" + ); + let accepted = application + .start_query(h2_range_query(datasource.id)) + .await + .expect("the reloaded H2 driver must execute a second query"); + let result_id = wait_for_result(&application, &accepted.operation_id).await; + assert_result_page(&application, &result_id).await; + drop(second_lease); host.shutdown() .await @@ -285,16 +307,25 @@ async fn partial_managed_driver_preload_cleans_generation_and_releases_storage() let driver_runtime_directory = data_dir.join("jdbc-driver-runtime"); let master_key = STANDARD.encode([0x5a; 32]); - let error = RuntimeHost::open(managed_runtime_config( + let mut host = RuntimeHost::open(managed_runtime_config( &engine_jar, &data_dir, &driver_pack_root, &master_key, )) .await - .expect_err("invalid second pack must fail the complete preload"); + .expect("driver discovery must not start Java"); + assert_eq!(host.application().list_drivers().items.len(), 2); + let error = host + .acquire_engine() + .await + .expect_err("invalid second pack must fail first-use preload"); assert_eq!(error.api_error().code, "driver.load_failed"); + host.shutdown() + .await + .expect("a failed lazy generation must already be reaped"); assert_directory_empty(&driver_runtime_directory); + drop(host); fs::remove_dir_all(driver_pack_root.join("02-invalid")) .expect("invalid pack must be removable after failed preload"); @@ -305,8 +336,13 @@ async fn partial_managed_driver_preload_cleans_generation_and_releases_storage() &master_key, )) .await - .expect("storage and Java generation must reopen immediately"); + .expect("storage and driver discovery must reopen immediately"); assert_eq!(host.application().list_drivers().items.len(), 1); + let lease = host + .acquire_engine() + .await + .expect("the corrected pack must preload on first use"); + drop(lease); host.shutdown() .await .expect("reopened runtime must shut down cleanly"); @@ -539,6 +575,25 @@ async fn next_operation_event( .event } +fn h2_range_query(datasource_id: String) -> StartQueryRequest { + StartQueryRequest { + datasource_id, + sql: "SELECT X AS id, CAST('row-' || X AS VARCHAR(16)) AS label, \ + MOD(X, 2) = 0 AS active, \ + CAST(X + 0.25 AS DECIMAL(10, 2)) AS amount \ + FROM SYSTEM_RANGE(1, 5) ORDER BY X" + .to_owned(), + parameters: Vec::new(), + limits: QueryLimits { + max_rows: "0".to_owned(), + max_result_bytes: (1024_u64 * 1024).to_string(), + batch_rows: 2, + batch_bytes: 1024, + result_ttl_seconds: 60, + }, + } +} + async fn assert_result_page(application: &Application, result_id: &str) { let page = application .result_page( @@ -565,6 +620,49 @@ async fn assert_result_page(application: &Application, result_id: &str) { )); } +fn assert_engine_available_on_demand(application: &Application) { + let engine = application + .health() + .components + .into_iter() + .find(|component| component.id == "database-engine") + .expect("database engine health must be present"); + assert_eq!(engine.state, ComponentState::Ready); + assert_eq!(engine.detail, "Available on demand; Java is not running"); +} + +fn assert_engine_running(application: &Application) { + let engine = application + .health() + .components + .into_iter() + .find(|component| component.id == "database-engine") + .expect("database engine health must be present"); + assert_eq!(engine.state, ComponentState::Ready); + assert_eq!(engine.detail, "Ready"); +} + +async fn wait_for_engine_idle(application: &Application) { + tokio::time::timeout(EVENT_TIMEOUT, async { + loop { + let idle = application + .health() + .components + .into_iter() + .find(|component| component.id == "database-engine") + .is_some_and(|component| { + component.detail == "Available on demand; Java is not running" + }); + if idle { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("managed Java must become idle before the test deadline"); +} + fn managed_runtime_config( engine_jar: &Path, data_dir: &Path, diff --git a/crates/chat2db-java-bridge/src/supervisor/mod.rs b/crates/chat2db-java-bridge/src/supervisor/mod.rs index 8f3c549..d723c8f 100644 --- a/crates/chat2db-java-bridge/src/supervisor/mod.rs +++ b/crates/chat2db-java-bridge/src/supervisor/mod.rs @@ -205,6 +205,12 @@ impl EngineConfig { self } + /// Returns whether this generation will load the fixed Community classpath. + #[must_use] + pub const fn community_compatibility_configured(&self) -> bool { + self.community_classpath.is_some() + } + #[cfg(feature = "test-fixture")] #[doc(hidden)] #[must_use] From 9f801cb5fc54f76828ead398d518e83ecb15aa24 Mon Sep 17 00:00:00 2001 From: zgq Date: Tue, 28 Jul 2026 20:21:12 +0800 Subject: [PATCH 13/19] docs(runtime): document lazy Java generations --- README.md | 10 ++++++---- docs/architecture.md | 13 +++++++++++-- docs/driver-packs.md | 37 ++++++++++++++++++++++++++----------- docs/stages.md | 17 +++++++++++++---- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 77c9b72..7163b95 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,10 @@ Console compatibility slice: paging, a physical-byte quota, expiry, writer cleanup, and crash recovery; - an AES-256-GCM encrypted file vault rooted in either an OS-keyring master key or an explicit headless master key; -- one production `RuntimeHost` that opens the vault and storage, supervises the - Java engine, and shuts down active work deterministically; +- one production `RuntimeHost` that opens the vault, storage, and verified + driver catalog without starting Java, then single-flights first-use startup, + leases each generation to active work, reaps it after three idle minutes, + reloads drivers on the next use, and shuts down deterministically; - secret-safe datasource CRUD, asynchronous query operations, bounded replay, explicit cancellation, and retained-result paging through Axum JSON/SSE and Tauri 2 commands/channels; @@ -77,8 +79,8 @@ Console compatibility slice: cancellation, and retained-result paging; and - an `rmcp` 2.2 stdio server with five bounded datasource/query tools backed by that same running `Application`; -- strict local JDBC driver-pack discovery, hash verification, startup preload, - and immutable Core/Axum/Tauri inventory; and +- strict local JDBC driver-pack discovery, hash verification, immutable + Core/Axum/Tauri inventory, and repeatable per-generation preload; and - a fixed Community 5.3.0 compatibility classpath that discovers real `IPlugin` implementations and exposes H2 plugin catalog, schema, object, view, foreign-key, primary-key, function, procedure, parameter, and trigger diff --git a/docs/architecture.md b/docs/architecture.md index 513eb3d..bbe4b58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,8 +14,15 @@ deadlines, hard limits, and explicit unknown outcomes. The complete storage-to-Java-to-retained-result path is cross-language tested against H2 without embedding H2 in the compatibility-engine JAR. -The Web and Tauri hosts open the production vault, SQLite storage, and Java -supervisor before exposing a shared `Application`. Axum serves JSON, SSE, and +The Web and Tauri hosts open the production vault, SQLite storage, and verified +driver catalog before exposing a shared `Application`; they do not start Java +during host bootstrap. The Core `EngineManager` starts one Java generation on +the first database, parser, formatter, or completion request, shares that +single-flight startup across concurrent callers, and issues generation-scoped +leases that remain live through stream and session cleanup. After the final +lease is released, the default three-minute idle deadline shuts down and fully +reaps Java. A later request starts a new generation and reloads every staged +driver pack. Axum serves JSON, SSE, and the exact pinned original Community Umi/React SPA; Tauri exposes commands and per-subscription channels without a localhost product server. Both hosts also publish an owner-only local endpoint for the CLI and MCP process. That same `Application` owns query and Agent run @@ -103,6 +110,8 @@ inside the Java process. stderr is diagnostic-only. - Read-only metadata/parser work may retry after a Java restart. Transactions, DML, and unknown-outcome operations are never replayed automatically. +- A Java-backed operation must retain its `EngineLease` until every session, + stream, transaction, and cleanup action for that operation is terminal. The implemented lifecycle and JDBC subset is documented in [`protocol.md`](protocol.md). Capabilities are advertised only after their diff --git a/docs/driver-packs.md b/docs/driver-packs.md index 2fc95fe..d4bdaed 100644 --- a/docs/driver-packs.md +++ b/docs/driver-packs.md @@ -58,7 +58,7 @@ Artifact order is significant because it contributes to the engine-derived name regular JAR files. Absolute paths, `.` or `..`, Windows prefixes, backslashes, colons, and symbolic-link components are rejected. -## Startup Semantics +## Discovery And First-Use Semantics Rust performs the following work before exposing the product runtime: @@ -71,18 +71,33 @@ Rust performs the following work before exposing the product runtime: file and copies it into a private `host-*` staging directory. 5. Hash the staged bytes, compare the declared SHA-256, and reject duplicate driver identities derived from class plus ordered artifact digests. -6. Start one Java compatibility-engine generation with its own `engine-*` - snapshot root. -7. Load packs sequentially. Java copies only the Rust-staged JARs into private +6. Publish immutable inventory directly from the verified specifications. Java + is still dormant and no `engine-*` directory exists. + +The first Java-backed operation then performs the following work: + +7. Single-flight one Java compatibility-engine generation with its own + `engine-*` snapshot root. Concurrent first-use callers wait for that same + generation. +8. Load packs sequentially. Java copies only the Rust-staged JARs into private per-driver snapshots with independent byte accounting and SHA-256 verification before classloading them. -8. Publish the immutable inventory only after every pack loads successfully. -9. Delete Rust staging after preload. After the child process is fully reaped, - Rust deletes the complete Java generation root, including snapshots Java - could not remove while Windows file handles were open. - -Any discovery or load error aborts startup. A load error shuts down the Java -generation, so callers never observe a partially loaded inventory. +9. Publish the generation to waiting operations only after handshake and every + pack load succeeds. Each operation retains a generation lease through JDBC, + parser, formatter, completion, stream, and cleanup work. +10. After the last lease is released, retain the generation for a default + three-minute idle window. An idle timeout shuts down and fully reaps the + child, then deletes its generation root. A later request starts a new + generation and reloads cloned specifications from the same host staging. + +Rust retains the `host-*` staging directory for the complete `RuntimeHost` +lifetime so every generation can reload identical verified bytes. Full host +shutdown first reaps Java and then deletes that staging directory. + +Any discovery error aborts host startup. A load error fails the first Java-backed +operation and shuts down that generation, so callers never observe a partially +loaded generation. The next operation may retry a fresh generation; Rust never +replays a dispatched database write. ## Limits diff --git a/docs/stages.md b/docs/stages.md index dc28fdc..c6e726f 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -12,7 +12,7 @@ in runtime health until then. | 4 | Complete | Product and result storage foundation | SQLite migration/integrity gates, mandatory vault boundary, revisioned datasource records, durable result frames, bounded paging/quota, expiry, writer cleanup and recovery tests | | 5 | Complete | Product transports | Generated OpenAPI/TypeScript contract, Axum JSON/SSE, Tauri 2 commands/channels, shared SQL workbench, product H2 tests | | 6 | Complete | Agent, MCP, and CLI | Direct providers, durable bounded tool loop, SQL tools/permissions, compaction, Web/Tauri run transports, owner-only local attachment, read-query CLI, and bounded `rmcp` stdio tools | -| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, and bounded SELECT execution | +| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; the host now starts Java on first use, leases active generations, reaps them after three idle minutes, and reloads packs after restart; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, and bounded SELECT execution | | 8 | Planned | Packaging and release | License authorization, NOTICE/SBOM, jlink runtime, Tauri installers, signed product/engine/driver manifests, atomic update and rollback, size measurement | Stage 3 completion means the versioned Rust-Java bridge can load an external @@ -22,6 +22,14 @@ cancellation. Stage 5 composes that bridge into the Web and desktop product hosts. Stage 6 adds CLI and MCP adapters that attach to one of those running hosts and do not own another product runtime. +The current production host supersedes the original eager Stage 5 bootstrap. +`RuntimeHost::open` now opens storage and verifies/stages driver packs without +starting Java. Core single-flights the first Java-backed request, keeps one +generation alive while any `EngineLease` exists, shuts it down after the +default three-minute idle window, and reloads the same verified packs into a +new generation on later use. Host health reports a dormant configured engine as +ready and available on demand rather than disabled or degraded. + Frontend checkpoints `928e62c` and `cf9ab8a` supersede the repository-owned Stage 5/7G replacement workbench. Current builds export the pinned Community Umi frontend without local page or style patches; the pinned source includes a @@ -75,9 +83,10 @@ read-only and accepts no JDBC bind parameters; it does not claim the built-in Agent's write tool. Stage 7A implements strict local JDBC driver-pack discovery, bounded artifact -hashing, startup preload, and immutable inventory through Core, Axum, Tauri, -and generated frontend contracts. Downloading, signing, installation, update, -rollback, and hot reload remain incomplete. +hashing, immutable inventory through Core, Axum, Tauri, and generated frontend +contracts, plus repeatable preload into each lazily started Java generation. +Host-owned staging remains valid across idle restarts. Downloading, signing, +installation, update, rollback, and hot reload remain incomplete. Stage 7B fixes the Community source at commit `37a34be858f2566b6b7fcf6c3f64183c1f560853`, builds its H2 compatibility From 81301c3ead14373fed1a9d84316b09ec669dbc68 Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 11:19:53 +0800 Subject: [PATCH 14/19] feat(mysql): add native mysql_async metadata backend --- Cargo.lock | 179 ++++++++++ Cargo.toml | 1 + crates/chat2db-core/Cargo.toml | 2 + crates/chat2db-core/src/community.rs | 16 + crates/chat2db-core/src/lib.rs | 23 ++ crates/chat2db-core/src/native_mysql.rs | 417 ++++++++++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 crates/chat2db-core/src/native_mysql.rs diff --git a/Cargo.lock b/Cargo.lock index db528dd..4bc4497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -413,6 +419,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -628,6 +643,7 @@ dependencies = [ "chat2db-java-bridge", "chat2db-storage", "futures-util", + "mysql_async", "rustix", "serde", "serde_json", @@ -636,6 +652,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "url", "uuid", ] @@ -967,6 +984,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1973,6 +1999,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2481,6 +2512,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap 2.14.0", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2577,6 +2617,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2589,6 +2638,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2731,6 +2803,81 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "mysql-common-derive" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" +dependencies = [ + "darling", + "heck 0.5.0", + "manyhow", + "num-bigint", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", + "thiserror 2.0.19", +] + +[[package]] +name = "mysql_async" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2" +dependencies = [ + "bytes", + "crossbeam-queue", + "crossbeam-utils", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lru", + "mysql_common", + "percent-encoding", + "rand 0.10.2", + "rustls", + "serde", + "socket2", + "thiserror 2.0.19", + "tokio", + "tokio-rustls", + "tokio-util", + "twox-hash", + "url", + "webpki-roots", +] + +[[package]] +name = "mysql_common" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.1", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "mysql-common-derive", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 2.0.19", + "uuid", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3581,6 +3728,17 @@ version = "0.5.20+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4189,6 +4347,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + [[package]] name = "schemars" version = "0.8.22" @@ -5139,6 +5303,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5555,6 +5728,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index d4ce6dd..6b1b24e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ fs2 = "0.4" futures-util = "0.3" http-body-util = "0.1" keyring = { version = "3.6.3", default-features = false } +mysql_async = { version = "=0.37.0", default-features = false, features = ["default-rustls-ring"] } prost = "0.14" rand = "0.9.2" reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] } diff --git a/crates/chat2db-core/Cargo.toml b/crates/chat2db-core/Cargo.toml index e89192c..3d4d301 100644 --- a/crates/chat2db-core/Cargo.toml +++ b/crates/chat2db-core/Cargo.toml @@ -16,6 +16,7 @@ chat2db-contract = { path = "../chat2db-contract" } chat2db-engine-protocol = { path = "../chat2db-engine-protocol" } chat2db-java-bridge = { path = "../chat2db-java-bridge" } chat2db-storage = { path = "../chat2db-storage" } +mysql_async.workspace = true serde.workspace = true serde_json.workspace = true rustix.workspace = true @@ -24,6 +25,7 @@ thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true +url.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index 7d0ddae..b2bb324 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -91,6 +91,7 @@ use crate::{ AppError, Application, datasource_session::{SessionReadOnly, open_datasource_session, resolve_datasource_connection}, engine_manager::EngineLease, + native_mysql, }; impl Application { @@ -119,6 +120,9 @@ impl Application { &self, request: ListCommunitySchemasRequest, ) -> Result { + if native_mysql::is_mysql_database_type(&request.database_type) { + return native_mysql::list_schemas(self, &request.datasource_id).await; + } let storage = self.require_storage()?; let engine = self.require_community_engine().await?; let ListCommunitySchemasRequest { @@ -154,6 +158,9 @@ impl Application { &self, request: ListCommunityDatabasesRequest, ) -> Result { + if native_mysql::is_mysql_database_type(&request.database_type) { + return native_mysql::list_databases(self, &request.datasource_id).await; + } let storage = self.require_storage()?; let engine = self.require_community_engine().await?; let ListCommunityDatabasesRequest { @@ -188,6 +195,15 @@ impl Application { &self, request: ListCommunityTablesRequest, ) -> Result { + if native_mysql::is_mysql_database_type(&request.database_type) { + return native_mysql::list_tables( + self, + &request.datasource_id, + &request.database_name, + &request.table_name_pattern, + ) + .await; + } let storage = self.require_storage()?; let engine = self.require_community_engine().await?; let ListCommunityTablesRequest { diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index cce2f6c..c2624fc 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -7,6 +7,7 @@ mod datasource_session; mod driver_pack; mod engine_manager; mod error; +mod native_mysql; mod operation; mod query; @@ -369,6 +370,9 @@ impl Application { )); } self.require_managed_driver(driver_id)?; + if self.is_native_mysql_driver(driver_id) { + return native_mysql::test_connection(&connection).await; + } let engine = self.require_engine().await?; let session = datasource_session::open_datasource_session( &engine, @@ -480,6 +484,25 @@ impl Application { } } + pub(crate) fn is_native_mysql_driver(&self, driver_id: &str) -> bool { + let identity = self + .inner + .drivers + .iter() + .find(|driver| driver.driver_id == driver_id) + .map_or_else( + || driver_id.to_ascii_lowercase(), + |driver| { + format!( + "{} {} {} {}", + driver.pack_id, driver.name, driver.driver_id, driver.driver_class + ) + .to_ascii_lowercase() + }, + ); + identity.contains("mysql") + } + async fn require_managed_driver_for_update( &self, storage: &Storage, diff --git a/crates/chat2db-core/src/native_mysql.rs b/crates/chat2db-core/src/native_mysql.rs new file mode 100644 index 0000000..96c9915 --- /dev/null +++ b/crates/chat2db-core/src/native_mysql.rs @@ -0,0 +1,417 @@ +use chat2db_contract::{ + ApiError, CommunityDatabase, CommunityDatabaseList, CommunitySchemaList, CommunityTable, + CommunityTableList, DatasourceConnection, +}; +use mysql_async::{Conn, Error as MysqlError, Opts, OptsBuilder, SslOpts, prelude::Queryable}; +use std::time::Duration; +use url::Url; + +use crate::{ + AppError, AppErrorKind, Application, + datasource_session::{ResolvedDatasourceConnection, resolve_datasource_connection}, +}; + +const MYSQL_SCHEME: &str = "mysql://"; +const JDBC_MYSQL_SCHEME: &str = "jdbc:mysql://"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +type TableRow = ( + String, + String, + String, + String, + String, + String, + Option, + Option, + Option, + Option, + Option, +); + +pub(crate) fn is_mysql_database_type(database_type: &str) -> bool { + database_type.trim().eq_ignore_ascii_case("mysql") +} + +pub(crate) async fn test_connection(connection: &DatasourceConnection) -> Result<(), AppError> { + let mut conn = open_connection(connection).await?; + let result = conn.ping().await.map_err(mysql_connection_error); + let close = conn.disconnect().await.map_err(mysql_connection_error); + result.and(close) +} + +pub(crate) async fn list_databases( + application: &Application, + datasource_id: &str, +) -> Result { + let resolved = resolve_native_connection(application, datasource_id).await?; + let mut conn = open_connection(&resolved.connection).await?; + let result = conn + .query::<(String, String, String), _>( + "SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME \ + FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME", + ) + .await + .map(|rows| CommunityDatabaseList { + items: rows + .into_iter() + .map(|(name, charset, collation)| CommunityDatabase { + system: is_system_database(&name), + name, + charset, + collation, + ..CommunityDatabase::default() + }) + .collect(), + }) + .map_err(mysql_query_error); + finish_connection(conn, result).await +} + +pub(crate) async fn list_schemas( + application: &Application, + datasource_id: &str, +) -> Result { + let resolved = resolve_native_connection(application, datasource_id).await?; + let conn = open_connection(&resolved.connection).await?; + finish_connection(conn, Ok(CommunitySchemaList::default())).await +} + +pub(crate) async fn list_tables( + application: &Application, + datasource_id: &str, + database_name: &str, + table_name_pattern: &str, +) -> Result { + if database_name.trim().is_empty() { + return Err(AppError::invalid( + "invalid_mysql_metadata_request", + "databaseName cannot be empty", + )); + } + let resolved = resolve_native_connection(application, datasource_id).await?; + let mut conn = open_connection(&resolved.connection).await?; + let query = "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, COALESCE(TABLE_COMMENT, ''), \ + COALESCE(ENGINE, ''), COALESCE(TABLE_COLLATION, ''), \ + CAST(AUTO_INCREMENT AS CHAR), CAST(TABLE_ROWS AS CHAR), \ + CAST(DATA_LENGTH AS CHAR), \ + DATE_FORMAT(CREATE_TIME, '%Y-%m-%dT%H:%i:%s'), \ + DATE_FORMAT(UPDATE_TIME, '%Y-%m-%dT%H:%i:%s') \ + FROM information_schema.TABLES \ + WHERE TABLE_SCHEMA = ? AND (? = '' OR TABLE_NAME LIKE ?) \ + ORDER BY TABLE_NAME"; + let pattern = table_name_pattern.trim().to_owned(); + let result = conn + .exec::(query, (database_name.to_owned(), pattern.clone(), pattern)) + .await + .map(|rows| CommunityTableList { + items: rows + .into_iter() + .map( + |( + database_name, + name, + table_type, + comment, + engine, + collation, + increment_value, + rows, + data_length, + create_time, + update_time, + )| CommunityTable { + database_name, + name, + table_type: normalize_table_type(&table_type).to_owned(), + comment, + database_type: "MYSQL".to_owned(), + engine, + charset: collation + .split_once('_') + .map_or_else(String::new, |(charset, _)| charset.to_owned()), + collation, + increment_value, + rows, + data_length, + create_time: create_time.unwrap_or_default(), + update_time: update_time.unwrap_or_default(), + ..CommunityTable::default() + }, + ) + .collect(), + }) + .map_err(mysql_query_error); + finish_connection(conn, result).await +} + +async fn resolve_native_connection( + application: &Application, + datasource_id: &str, +) -> Result { + let storage = application.require_storage()?; + let resolved = resolve_datasource_connection(&storage, datasource_id).await?; + if !application.is_native_mysql_driver(&resolved.driver_id) { + return Err(AppError::invalid( + "mysql_driver_mismatch", + "The datasource is not configured with a MySQL driver", + )); + } + Ok(resolved) +} + +async fn open_connection(connection: &DatasourceConnection) -> Result { + let opts = connection_opts(connection)?; + tokio::time::timeout(CONNECT_TIMEOUT, Conn::new(opts)) + .await + .map_err(|_| { + AppError::unavailable( + "mysql_connection_timeout", + "The MySQL connection attempt timed out", + ) + })? + .map_err(mysql_connection_error) +} + +async fn finish_connection(conn: Conn, result: Result) -> Result { + let close = conn.disconnect().await.map_err(mysql_connection_error); + match result { + Ok(value) => close.map(|()| value), + Err(error) => { + if let Err(close_error) = close { + tracing::warn!(error = %close_error, "native MySQL connection cleanup failed"); + } + Err(error) + } + } +} + +fn connection_opts(connection: &DatasourceConnection) -> Result { + let url = normalize_mysql_url(&connection.jdbc_url)?; + let mut parsed = Url::parse(&url).map_err(|_| invalid_connection_url())?; + if parsed.scheme() != "mysql" || parsed.host_str().is_none() { + return Err(invalid_connection_url()); + } + let query_properties = parsed + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + parsed.set_query(None); + parsed.set_fragment(None); + let base = Opts::from_url(parsed.as_str()).map_err(|_| invalid_connection_url())?; + let mut builder = OptsBuilder::from_opts(base).prefer_socket(Some(false)); + + let mut ssl = None; + for (key, value) in query_properties + .iter() + .map(|(key, value)| (key, value)) + .chain( + connection + .properties + .iter() + .map(|property| (&property.key, &property.value)), + ) + { + match key.trim().to_ascii_lowercase().as_str() { + "user" | "username" => builder = builder.user(Some(value.to_owned())), + "password" => builder = builder.pass(Some(value.to_owned())), + "database" | "databasename" => builder = builder.db_name(Some(value.to_owned())), + "usessl" | "requiressl" => { + ssl = parse_bool(value).then(SslOpts::default); + } + "sslmode" => { + ssl = match value.trim().to_ascii_lowercase().as_str() { + "disable" | "disabled" | "false" | "preferred" => None, + "require" | "required" | "true" => Some( + SslOpts::default() + .with_danger_accept_invalid_certs(true) + .with_danger_skip_domain_validation(true), + ), + "verify_ca" => { + Some(SslOpts::default().with_danger_skip_domain_validation(true)) + } + "verify_identity" => Some(SslOpts::default()), + _ => return Err(invalid_connection_property("sslMode")), + }; + } + "verifyservercertificate" if !parse_bool(value) && ssl.is_some() => { + ssl = ssl.map(|options| { + options + .with_danger_accept_invalid_certs(true) + .with_danger_skip_domain_validation(true) + }); + } + _ => {} + } + } + Ok(builder.ssl_opts(ssl).into()) +} + +fn normalize_mysql_url(value: &str) -> Result { + let value = value.trim(); + if value + .get(..JDBC_MYSQL_SCHEME.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(JDBC_MYSQL_SCHEME)) + { + return Ok(format!( + "{MYSQL_SCHEME}{}", + &value[JDBC_MYSQL_SCHEME.len()..] + )); + } + if value + .get(..MYSQL_SCHEME.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(MYSQL_SCHEME)) + { + return Ok(format!("{MYSQL_SCHEME}{}", &value[MYSQL_SCHEME.len()..])); + } + Err(invalid_connection_url()) +} + +fn parse_bool(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" | "required" + ) +} + +fn normalize_table_type(value: &str) -> &str { + if value.eq_ignore_ascii_case("VIEW") { + "VIEW" + } else { + "TABLE" + } +} + +fn is_system_database(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "information_schema" | "mysql" | "performance_schema" | "sys" + ) +} + +fn invalid_connection_url() -> AppError { + AppError::invalid( + "invalid_mysql_connection", + "A valid jdbc:mysql:// or mysql:// connection URL is required", + ) +} + +fn invalid_connection_property(property: &str) -> AppError { + AppError::invalid( + "invalid_mysql_connection", + format!("The MySQL connection property {property} is invalid"), + ) +} + +fn mysql_connection_error(error: MysqlError) -> AppError { + match error { + MysqlError::Server(server) => AppError::new( + AppErrorKind::InvalidRequest, + ApiError::new("mysql_connection_rejected", server.message), + ), + _ => AppError::unavailable( + "mysql_connection_failed", + "The MySQL server could not be reached", + ), + } +} + +fn mysql_query_error(error: MysqlError) -> AppError { + match error { + MysqlError::Server(server) => AppError::new( + AppErrorKind::InvalidRequest, + ApiError::new("mysql_query_failed", server.message), + ), + _ => AppError::unavailable( + "mysql_connection_failed", + "The MySQL connection ended before the operation completed", + ), + } +} + +#[cfg(test)] +mod tests { + use chat2db_contract::{DatasourceConnection, DatasourceConnectionProperty}; + + use super::{connection_opts, is_mysql_database_type, normalize_table_type}; + + #[test] + fn jdbc_url_and_properties_build_native_options_without_exposing_jdbc() { + let opts = connection_opts(&DatasourceConnection { + jdbc_url: "jdbc:mysql://db.example:3307/app?useSSL=false&serverTimezone=UTC".to_owned(), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: "chat2db".to_owned(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: "secret".to_owned(), + sensitive: true, + }, + ], + read_only: false, + }) + .expect("JDBC URL should convert"); + + assert_eq!(opts.ip_or_hostname(), "db.example"); + assert_eq!(opts.tcp_port(), 3307); + assert_eq!(opts.db_name(), Some("app")); + assert_eq!(opts.user(), Some("chat2db")); + assert_eq!(opts.pass(), Some("secret")); + assert!(opts.ssl_opts().is_none()); + assert!(!opts.prefer_socket()); + } + + #[test] + fn mysql_detection_and_table_types_are_closed() { + assert!(is_mysql_database_type(" mysql ")); + assert!(!is_mysql_database_type("mariadb")); + assert_eq!(normalize_table_type("VIEW"), "VIEW"); + assert_eq!(normalize_table_type("BASE TABLE"), "TABLE"); + } + + #[test] + fn explicit_properties_override_url_values_and_ssl_modes_are_mapped() { + let opts = connection_opts(&DatasourceConnection { + jdbc_url: "mysql://url-user:url-pass@localhost/url_db?sslMode=VERIFY_IDENTITY" + .to_owned(), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: "property-user".to_owned(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: "property-pass".to_owned(), + sensitive: true, + }, + ], + read_only: false, + }) + .expect("native URL should convert"); + + assert_eq!(opts.user(), Some("property-user")); + assert_eq!(opts.pass(), Some("property-pass")); + assert_eq!(opts.db_name(), Some("url_db")); + assert!(opts.ssl_opts().is_some()); + } + + #[test] + fn invalid_and_non_mysql_urls_are_rejected_without_panicking() { + for jdbc_url in [ + "", + "jdbc:postgresql://localhost/app", + "æ•°mysql://localhost/app", + ] { + let error = connection_opts(&DatasourceConnection { + jdbc_url: jdbc_url.to_owned(), + properties: Vec::new(), + read_only: false, + }) + .expect_err("non-MySQL URLs must fail"); + assert_eq!(error.api_error().code, "invalid_mysql_connection"); + } + } +} From 4199862a7cd60e000bbe2ad50c6aee3f7af9af02 Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 12:04:59 +0800 Subject: [PATCH 15/19] feat(mysql): route preview and console select natively --- Cargo.lock | 1 + crates/chat2db-core/Cargo.toml | 1 + crates/chat2db-core/src/community.rs | 3 + crates/chat2db-core/src/lib.rs | 28 +- crates/chat2db-core/src/native_mysql.rs | 1114 ++++++++++++++++++++++- crates/chat2db-core/src/query.rs | 83 +- 6 files changed, 1193 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4bc4497..34a1754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,7 @@ dependencies = [ "chat2db-storage", "futures-util", "mysql_async", + "prost", "rustix", "serde", "serde_json", diff --git a/crates/chat2db-core/Cargo.toml b/crates/chat2db-core/Cargo.toml index 3d4d301..9ff4ca0 100644 --- a/crates/chat2db-core/Cargo.toml +++ b/crates/chat2db-core/Cargo.toml @@ -17,6 +17,7 @@ chat2db-engine-protocol = { path = "../chat2db-engine-protocol" } chat2db-java-bridge = { path = "../chat2db-java-bridge" } chat2db-storage = { path = "../chat2db-storage" } mysql_async.workspace = true +prost.workspace = true serde.workspace = true serde_json.workspace = true rustix.workspace = true diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index b2bb324..8e24fee 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -905,6 +905,9 @@ impl Application { "datasourceId cannot be empty", )); } + if native_mysql::is_mysql_database_type(&request.database_type) { + return native_mysql::start_table_preview(self, request, row_limit).await; + } let engine = self.require_community_engine().await?; let client = engine.community_client().map_err(AppError::from)?; diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index c2624fc..57a62fe 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -478,6 +478,9 @@ impl Application { } fn require_managed_driver(&self, driver_id: &str) -> Result<(), AppError> { + if self.is_native_mysql_driver(driver_id) { + return Ok(()); + } match &self.inner.managed_driver_ids { Some(driver_ids) if !driver_ids.contains(driver_id) => Err(driver_not_installed()), _ => Ok(()), @@ -485,22 +488,21 @@ impl Application { } pub(crate) fn is_native_mysql_driver(&self, driver_id: &str) -> bool { - let identity = self - .inner + if driver_id.eq_ignore_ascii_case("mysql") { + return true; + } + self.inner .drivers .iter() .find(|driver| driver.driver_id == driver_id) - .map_or_else( - || driver_id.to_ascii_lowercase(), - |driver| { - format!( - "{} {} {} {}", - driver.pack_id, driver.name, driver.driver_id, driver.driver_class - ) - .to_ascii_lowercase() - }, - ); - identity.contains("mysql") + .is_some_and(|driver| { + format!( + "{} {} {} {}", + driver.pack_id, driver.name, driver.driver_id, driver.driver_class + ) + .to_ascii_lowercase() + .contains("mysql") + }) } async fn require_managed_driver_for_update( diff --git a/crates/chat2db-core/src/native_mysql.rs b/crates/chat2db-core/src/native_mysql.rs index 96c9915..47010f2 100644 --- a/crates/chat2db-core/src/native_mysql.rs +++ b/crates/chat2db-core/src/native_mysql.rs @@ -1,19 +1,43 @@ use chat2db_contract::{ ApiError, CommunityDatabase, CommunityDatabaseList, CommunitySchemaList, CommunityTable, - CommunityTableList, DatasourceConnection, + CommunityTableList, CommunityTablePreviewAccepted, DatasourceConnection, QueryLimits, + ResultMetadata, StartCommunityTablePreviewRequest, StartQueryRequest, }; -use mysql_async::{Conn, Error as MysqlError, Opts, OptsBuilder, SslOpts, prelude::Queryable}; +use chat2db_engine_protocol::wire; +use chat2db_java_bridge::QueryOptions; +use chat2db_storage::Storage; +use mysql_async::{ + Column, Conn, Error as MysqlError, Opts, OptsBuilder, Row, SslOpts, Value, + consts::{ColumnFlags, ColumnType}, + prelude::Queryable, +}; +use prost::Message; use std::time::Duration; +use tokio::sync::watch; use url::Url; use crate::{ AppError, AppErrorKind, Application, datasource_session::{ResolvedDatasourceConnection, resolve_datasource_connection}, + operation::CancellationRequest, + query::{PreparedQuery, QueryTaskError, RetainedWriter}, }; const MYSQL_SCHEME: &str = "mysql://"; const JDBC_MYSQL_SCHEME: &str = "jdbc:mysql://"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); +const DEFAULT_BATCH_ROWS: u32 = 256; +const DEFAULT_BATCH_BYTES: u32 = 256 * 1024; +const DEFAULT_RESULT_BYTES: u64 = wire::JdbcResultByteLimit::DefaultResultBytes as u64; +const MAX_RESULT_BYTES: u64 = wire::JdbcResultByteLimit::MaxResultBytes as u64; +const MAX_BATCH_ROWS: u32 = wire::JdbcProtocolLimit::MaxBatchRows as u32; +const MAX_BATCH_BYTES: u32 = wire::JdbcProtocolLimit::MaxBatchBytes as u32; +const MAX_COLUMNS: usize = wire::JdbcProtocolLimit::MaxColumns as usize; +const MAX_SQL_BYTES: usize = wire::JdbcProtocolLimit::MaxSqlBytes as usize; +const MAX_SCALAR_BYTES: usize = wire::JdbcProtocolLimit::MaxScalarBytes as usize; +const MAX_IDENTIFIER_BYTES: usize = 256; type TableRow = ( String, String, @@ -28,6 +52,12 @@ type TableRow = ( Option, ); +#[derive(Debug, PartialEq, Eq)] +enum SqlToken { + Word(String), + Semicolon, +} + pub(crate) fn is_mysql_database_type(database_type: &str) -> bool { database_type.trim().eq_ignore_ascii_case("mysql") } @@ -144,6 +174,1037 @@ pub(crate) async fn list_tables( finish_connection(conn, result).await } +pub(crate) fn is_native_read_candidate(sql: &str) -> Result { + Ok(matches!( + sql_tokens(sql)?.first(), + Some(SqlToken::Word(keyword)) if keyword == "SELECT" || keyword == "WITH" + )) +} + +pub(crate) fn validate_query(query: &PreparedQuery) -> Result<(), AppError> { + if query.sql.len() > MAX_SQL_BYTES { + return Err(AppError::invalid( + "invalid_query_request", + format!("SQL cannot exceed {MAX_SQL_BYTES} UTF-8 bytes"), + )); + } + if !query.parameters.is_empty() { + return Err(AppError::invalid( + "invalid_query_request", + "Native MySQL SELECT does not accept parameters yet", + )); + } + validate_read_sql(&query.sql)?; + validate_query_options(query.options) +} + +fn validate_read_sql(sql: &str) -> Result<(), AppError> { + let tokens = sql_tokens(sql)?; + if !matches!(tokens.first(), Some(SqlToken::Word(keyword)) if keyword == "SELECT") { + return Err(AppError::invalid( + "mysql_native_query_unsupported", + "Native MySQL currently supports single SELECT statements that begin with SELECT", + )); + } + if let Some(index) = tokens + .iter() + .position(|token| matches!(token, SqlToken::Semicolon)) + && (index + 1 != tokens.len() + || tokens[..index] + .iter() + .any(|token| matches!(token, SqlToken::Semicolon))) + { + return Err(AppError::invalid( + "mysql_native_query_unsupported", + "Native MySQL accepts exactly one SELECT statement", + )); + } + let words = tokens + .iter() + .filter_map(|token| match token { + SqlToken::Word(word) => Some(word.as_str()), + SqlToken::Semicolon => None, + }) + .collect::>(); + let forbidden = words + .windows(2) + .any(|window| matches!(window, ["INTO", "OUTFILE" | "DUMPFILE"] | ["FOR", "UPDATE"])) + || words + .windows(4) + .any(|window| matches!(window, ["LOCK", "IN", "SHARE", "MODE"])); + if forbidden { + return Err(AppError::invalid( + "mysql_native_query_unsupported", + "Native MySQL does not accept locking or server-file SELECT variants", + )); + } + Ok(()) +} + +fn sql_tokens(sql: &str) -> Result, AppError> { + let bytes = sql.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + byte if byte.is_ascii_whitespace() => index += 1, + b'#' => skip_line_comment(bytes, &mut index), + b'-' if bytes.get(index + 1) == Some(&b'-') + && bytes.get(index + 2).is_none_or(u8::is_ascii_whitespace) => + { + skip_line_comment(bytes, &mut index); + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + index += 2; + let mut terminated = false; + while index + 1 < bytes.len() { + if bytes[index] == b'*' && bytes[index + 1] == b'/' { + index += 2; + terminated = true; + break; + } + index += 1; + } + if !terminated { + return Err(invalid_sql_lexeme("unterminated block comment")); + } + } + quote @ (b'\'' | b'"' | b'`') => { + index += 1; + let mut terminated = false; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else if bytes[index] == quote { + if bytes.get(index + 1) == Some("e) { + index += 2; + } else { + index += 1; + terminated = true; + break; + } + } else { + index += 1; + } + } + if !terminated { + return Err(invalid_sql_lexeme("unterminated quoted value")); + } + } + b';' => { + tokens.push(SqlToken::Semicolon); + index += 1; + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = index; + index += 1; + while bytes + .get(index) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')) + { + index += 1; + } + tokens.push(SqlToken::Word(sql[start..index].to_ascii_uppercase())); + } + _ => index += 1, + } + } + Ok(tokens) +} + +fn skip_line_comment(bytes: &[u8], index: &mut usize) { + while *index < bytes.len() && !matches!(bytes[*index], b'\r' | b'\n') { + *index += 1; + } +} + +fn invalid_sql_lexeme(detail: &str) -> AppError { + AppError::invalid( + "invalid_query_request", + format!("MySQL SQL contains an {detail}"), + ) +} + +pub(crate) async fn start_table_preview( + application: &Application, + request: StartCommunityTablePreviewRequest, + row_limit: u32, +) -> Result { + let database_name = quote_identifier(&request.database_name, "databaseName")?; + let table_name = quote_identifier(&request.table_name, "tableName")?; + let sql = format!("SELECT * FROM {database_name}.{table_name} LIMIT {row_limit}"); + let accepted = application + .start_read_query(StartQueryRequest { + datasource_id: request.datasource_id, + sql: sql.clone(), + parameters: Vec::new(), + limits: QueryLimits { + max_rows: row_limit.to_string(), + max_result_bytes: (8 * 1024 * 1024_u64).to_string(), + batch_rows: row_limit.min(200), + batch_bytes: 1024 * 1024, + result_ttl_seconds: 60 * 60, + }, + }) + .await?; + Ok(CommunityTablePreviewAccepted { + operation_id: accepted.operation_id, + sql, + row_limit, + }) +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn execute_query_task( + application: &Application, + operation_id: &str, + mut cancellation: watch::Receiver, + query: PreparedQuery, + storage: Storage, + resolved: ResolvedDatasourceConnection, +) -> Result { + if let CancellationRequest::Requested { reason } = cancellation.borrow().clone() { + return Err(QueryTaskError::Cancelled(reason)); + } + + let options = connection_opts(&resolved.connection)?; + let mut conn = open_query_connection(options.clone(), &mut cancellation).await?; + let connection_id = conn.id(); + if let Err(error) = start_read_only_transaction(&mut conn).await { + disconnect_quietly(conn).await; + return Err(error.into()); + } + let query_result = { + let query_future = conn.exec_iter(query.sql, ()); + tokio::pin!(query_future); + let mut cancellation_open = true; + loop { + tokio::select! { + biased; + changed = cancellation.changed(), if cancellation_open => { + if changed.is_err() { + cancellation_open = false; + continue; + } + let request = { cancellation.borrow().clone() }; + if let CancellationRequest::Requested { reason } = request { + return match terminate_connection(options.clone(), connection_id).await { + Ok(()) => Err(QueryTaskError::Cancelled(reason)), + Err(error) => Err(QueryTaskError::Failed(error)), + }; + } + } + result = &mut query_future => break result.map_err(mysql_query_error)?, + } + } + }; + let columns = query_result.columns_ref().to_vec(); + let schema = (|| { + if columns.len() > MAX_COLUMNS { + return Err(resource_error( + "mysql_result_too_wide", + format!("MySQL returned more than {MAX_COLUMNS} columns"), + )); + } + Ok(wire::QueryStarted { + columns: columns + .iter() + .enumerate() + .map(|(index, column)| mysql_column(index, column)) + .collect::>()?, + }) + })(); + let schema = match schema { + Ok(schema) => schema, + Err(error) => { + drop(query_result); + terminate_connection_quietly(options, connection_id).await; + drop(conn); + return Err(error.into()); + } + }; + let mut writer = match RetainedWriter::begin(storage, schema, query.retention).await { + Ok(writer) => writer, + Err(error) => { + drop(query_result); + terminate_connection_quietly(options, connection_id).await; + drop(conn); + return Err(error.into()); + } + }; + if let Err(error) = application.inner.operations.started(operation_id).await { + drop(query_result); + abort_writer(&mut writer).await; + terminate_connection_quietly(options, connection_id).await; + drop(conn); + return Err(error.into()); + } + + let max_rows = query.options.max_rows; + let max_result_bytes = if query.options.max_result_bytes == 0 { + DEFAULT_RESULT_BYTES + } else { + query.options.max_result_bytes + }; + let batch_rows = if query.options.target_batch_rows == 0 { + DEFAULT_BATCH_ROWS + } else { + query.options.target_batch_rows + }; + let batch_bytes = if query.options.target_batch_bytes == 0 { + DEFAULT_BATCH_BYTES + } else { + query.options.target_batch_bytes + }; + let mut result = query_result; + let mut pending_rows = Vec::new(); + let mut pending_bytes = 0_u64; + let mut row_count = 0_u64; + let mut result_bytes = 0_u64; + let mut cancellation_open = true; + let consumption: Result<(wire::QueryCompleted, bool), QueryTaskError> = async { + loop { + let next = tokio::select! { + biased; + changed = cancellation.changed(), if cancellation_open => { + if changed.is_err() { + cancellation_open = false; + continue; + } + let request = { cancellation.borrow().clone() }; + if let CancellationRequest::Requested { reason } = request { + return Err(QueryTaskError::Cancelled(reason)); + } + continue; + } + row = result.next() => row, + }; + let Some(row) = next.map_err(mysql_query_error)? else { + return Ok(( + wire::QueryCompleted { + row_count, + truncated_by_max_rows: false, + truncated_by_max_result_bytes: false, + }, + false, + )); + }; + + if max_rows != 0 && row_count >= max_rows { + return Ok(( + wire::QueryCompleted { + row_count, + truncated_by_max_rows: true, + truncated_by_max_result_bytes: false, + }, + true, + )); + } + let row = mysql_row(row, &columns)?; + let row_bytes = u64::try_from(row.encoded_len()) + .map_err(|_| QueryTaskError::Failed(AppError::internal()))?; + if result_bytes.saturating_add(row_bytes) > max_result_bytes { + return Ok(( + wire::QueryCompleted { + row_count, + truncated_by_max_rows: false, + truncated_by_max_result_bytes: true, + }, + true, + )); + } + let entry_bytes = row_batch_entry_bytes(&row)?; + let candidate_bytes = pending_bytes + .saturating_add(if pending_rows.is_empty() { + row_batch_prefix_bytes(row_count) + } else { + 0 + }) + .saturating_add(entry_bytes); + if !pending_rows.is_empty() + && (pending_rows.len() + >= usize::try_from(batch_rows) + .map_err(|_| QueryTaskError::Failed(AppError::internal()))? + || candidate_bytes > u64::from(batch_bytes)) + { + flush_rows( + application, + operation_id, + &mut writer, + &mut pending_rows, + row_count, + ) + .await?; + pending_bytes = 0; + } + if pending_rows.is_empty() { + pending_bytes = row_batch_prefix_bytes(row_count); + } + pending_rows.push(row); + pending_bytes = pending_bytes.saturating_add(entry_bytes); + row_count = row_count + .checked_add(1) + .ok_or_else(|| QueryTaskError::Failed(AppError::internal()))?; + result_bytes = result_bytes + .checked_add(row_bytes) + .ok_or_else(|| QueryTaskError::Failed(AppError::internal()))?; + } + } + .await; + drop(result); + + let (completion, requires_termination) = match consumption { + Ok(completion) => completion, + Err(primary) => { + let termination = terminate_connection(options, connection_id).await; + abort_writer(&mut writer).await; + drop(conn); + if let Err(error) = termination { + if matches!(primary, QueryTaskError::Cancelled(_)) { + return Err(error.into()); + } + tracing::warn!(error = %error, "native MySQL connection termination failed"); + } + return Err(primary); + } + }; + if requires_termination && let Err(error) = terminate_connection(options, connection_id).await { + abort_writer(&mut writer).await; + drop(conn); + return Err(error.into()); + } + + let finalized = async { + flush_rows( + application, + operation_id, + &mut writer, + &mut pending_rows, + row_count, + ) + .await?; + writer + .finish(completion) + .await + .map_err(QueryTaskError::from) + } + .await; + let metadata = match finalized { + Ok(metadata) => metadata, + Err(error) => { + abort_writer(&mut writer).await; + if requires_termination { + drop(conn); + } else { + finish_read_only_connection_quietly(conn).await; + } + return Err(error); + } + }; + if requires_termination { + drop(conn); + } else { + finish_read_only_connection_quietly(conn).await; + } + Ok(metadata) +} + +fn row_batch_prefix_bytes(start_row_offset: u64) -> u64 { + if start_row_offset == 0 { + 0 + } else { + 1_u64.saturating_add( + u64::try_from(prost::encoding::encoded_len_varint(start_row_offset)) + .unwrap_or(u64::MAX), + ) + } +} + +fn row_batch_entry_bytes(row: &wire::JdbcRow) -> Result { + let row_bytes = row.encoded_len(); + let length_bytes = prost::encoding::length_delimiter_len(row_bytes); + u64::try_from( + 1_usize + .saturating_add(length_bytes) + .saturating_add(row_bytes), + ) + .map_err(|_| QueryTaskError::Failed(AppError::internal())) +} + +async fn flush_rows( + application: &Application, + operation_id: &str, + writer: &mut RetainedWriter, + rows: &mut Vec, + row_count: u64, +) -> Result<(), QueryTaskError> { + if rows.is_empty() { + return Ok(()); + } + let row_len = u64::try_from(rows.len()).map_err(|_| AppError::internal())?; + let start_row_offset = row_count + .checked_sub(row_len) + .ok_or_else(AppError::internal)?; + let batch = wire::RowBatch { + start_row_offset, + rows: std::mem::take(rows), + }; + if batch.encoded_len() > usize::try_from(MAX_BATCH_BYTES).unwrap_or(usize::MAX) { + return Err(resource_error( + "mysql_result_batch_too_large", + "One MySQL result row exceeds the retained-result batch limit", + ) + .into()); + } + let byte_count = writer.append(batch).await?; + application + .inner + .operations + .progress(operation_id, row_count, byte_count) + .await?; + Ok(()) +} + +async fn abort_writer(writer: &mut RetainedWriter) { + if let Err(error) = writer.abort().await { + tracing::warn!(error = %error, "native MySQL retained-result cleanup failed"); + } +} + +fn mysql_column(index: usize, column: &Column) -> Result { + let ordinal = u32::try_from(index) + .ok() + .and_then(|index| index.checked_add(1)) + .ok_or_else(AppError::internal)?; + let label = column.name_str().into_owned(); + let original_name = column.org_name_str().into_owned(); + let table_name = column.org_table_str().into_owned(); + let catalog_name = column.schema_str().into_owned(); + let column_type = column.column_type(); + let flags = column.flags(); + let value_type = mysql_value_type(column); + Ok(wire::JdbcColumn { + ordinal, + name: if original_name.is_empty() { + label.clone() + } else { + original_name + }, + label, + jdbc_type: mysql_jdbc_type(column), + jdbc_type_name: mysql_type_name(column_type).to_owned(), + value_type: value_type as i32, + nullability: if flags.contains(ColumnFlags::NOT_NULL_FLAG) { + wire::ColumnNullability::NoNulls as i32 + } else { + wire::ColumnNullability::Nullable as i32 + }, + precision: numeric_type(column_type).then_some(column.column_length()), + scale: numeric_type(column_type).then(|| i32::from(column.decimals())), + display_size: Some(column.column_length()), + signed: numeric_type(column_type).then_some(!flags.contains(ColumnFlags::UNSIGNED_FLAG)), + catalog_name: (!catalog_name.is_empty()).then_some(catalog_name), + schema_name: None, + table_name: (!table_name.is_empty()).then_some(table_name), + }) +} + +fn mysql_row(row: Row, columns: &[Column]) -> Result { + if row.len() != columns.len() { + return Err(AppError::internal()); + } + Ok(wire::JdbcRow { + values: row + .unwrap() + .into_iter() + .zip(columns) + .map(|(value, column)| mysql_value(value, column)) + .collect::>()?, + }) +} + +fn mysql_value(value: Value, column: &Column) -> Result { + use wire::jdbc_value::Value as WireValue; + if matches!(value, Value::NULL) { + return Ok(wire_value(WireValue::NullValue(wire::JdbcNull {}))); + } + let value_type = mysql_value_type(column); + let value = match value_type { + wire::JdbcValueType::Boolean => WireValue::BooleanValue(mysql_bool(value)?), + wire::JdbcValueType::SignedInteger => WireValue::SignedIntegerValue(mysql_i64(value)?), + wire::JdbcValueType::UnsignedInteger => WireValue::UnsignedIntegerValue(mysql_u64(value)?), + wire::JdbcValueType::Float32 => WireValue::Float32Value(mysql_f32(value)?), + wire::JdbcValueType::Float64 => WireValue::Float64Value(mysql_f64(value)?), + wire::JdbcValueType::Decimal => WireValue::DecimalValue(mysql_text(value)?), + wire::JdbcValueType::Text => WireValue::TextValue(mysql_text(value)?), + wire::JdbcValueType::Binary => WireValue::BinaryValue(mysql_binary(value)?), + wire::JdbcValueType::Date => match value { + Value::Date(0, 0, 0, _, _, _, _) => WireValue::NullValue(wire::JdbcNull {}), + Value::Date(year, month, day, _, _, _, _) => { + WireValue::DateValue(format!("{year:04}-{month:02}-{day:02}")) + } + other => WireValue::DateValue(mysql_text(other)?), + }, + wire::JdbcValueType::Time => WireValue::TimeValue(mysql_time(value)?), + wire::JdbcValueType::Timestamp => match value { + Value::Date(0, 0, 0, _, _, _, _) => WireValue::NullValue(wire::JdbcNull {}), + Value::Date(year, month, day, hour, minute, second, micros) => { + WireValue::TimestampValue(format_timestamp( + year, month, day, hour, minute, second, micros, + )) + } + other => WireValue::TimestampValue(mysql_text(other)?), + }, + wire::JdbcValueType::Json => WireValue::JsonValue(mysql_text(value)?), + wire::JdbcValueType::Opaque + | wire::JdbcValueType::TimestampWithTimeZone + | wire::JdbcValueType::Uuid + | wire::JdbcValueType::Unspecified => WireValue::OpaqueValue(wire::OpaqueValue { + type_name: mysql_type_name(column.column_type()).to_owned(), + display_value: mysql_display(value), + }), + }; + Ok(wire_value(value)) +} + +fn wire_value(value: wire::jdbc_value::Value) -> wire::JdbcValue { + wire::JdbcValue { value: Some(value) } +} + +fn mysql_value_type(column: &Column) -> wire::JdbcValueType { + use ColumnType as Type; + if is_binary_column(column) { + return wire::JdbcValueType::Binary; + } + match column.column_type() { + Type::MYSQL_TYPE_TINY + | Type::MYSQL_TYPE_SHORT + | Type::MYSQL_TYPE_LONG + | Type::MYSQL_TYPE_LONGLONG + | Type::MYSQL_TYPE_INT24 + | Type::MYSQL_TYPE_YEAR => { + if column.flags().contains(ColumnFlags::UNSIGNED_FLAG) { + wire::JdbcValueType::UnsignedInteger + } else { + wire::JdbcValueType::SignedInteger + } + } + Type::MYSQL_TYPE_FLOAT => wire::JdbcValueType::Float32, + Type::MYSQL_TYPE_DOUBLE => wire::JdbcValueType::Float64, + Type::MYSQL_TYPE_DECIMAL | Type::MYSQL_TYPE_NEWDECIMAL => wire::JdbcValueType::Decimal, + Type::MYSQL_TYPE_BIT if column.column_length() == 1 => wire::JdbcValueType::Boolean, + Type::MYSQL_TYPE_BIT | Type::MYSQL_TYPE_GEOMETRY | Type::MYSQL_TYPE_VECTOR => { + wire::JdbcValueType::Binary + } + Type::MYSQL_TYPE_DATE | Type::MYSQL_TYPE_NEWDATE => wire::JdbcValueType::Date, + Type::MYSQL_TYPE_TIME | Type::MYSQL_TYPE_TIME2 => wire::JdbcValueType::Time, + Type::MYSQL_TYPE_TIMESTAMP + | Type::MYSQL_TYPE_TIMESTAMP2 + | Type::MYSQL_TYPE_DATETIME + | Type::MYSQL_TYPE_DATETIME2 => wire::JdbcValueType::Timestamp, + Type::MYSQL_TYPE_JSON => wire::JdbcValueType::Json, + Type::MYSQL_TYPE_VARCHAR + | Type::MYSQL_TYPE_VAR_STRING + | Type::MYSQL_TYPE_STRING + | Type::MYSQL_TYPE_ENUM + | Type::MYSQL_TYPE_SET + | Type::MYSQL_TYPE_TINY_BLOB + | Type::MYSQL_TYPE_MEDIUM_BLOB + | Type::MYSQL_TYPE_LONG_BLOB + | Type::MYSQL_TYPE_BLOB => wire::JdbcValueType::Text, + Type::MYSQL_TYPE_NULL | Type::MYSQL_TYPE_TYPED_ARRAY | Type::MYSQL_TYPE_UNKNOWN => { + wire::JdbcValueType::Opaque + } + } +} + +fn mysql_jdbc_type(column: &Column) -> i32 { + use ColumnType as Type; + match column.column_type() { + Type::MYSQL_TYPE_BIT => -7, + Type::MYSQL_TYPE_TINY => -6, + Type::MYSQL_TYPE_SHORT | Type::MYSQL_TYPE_YEAR => 5, + Type::MYSQL_TYPE_LONG | Type::MYSQL_TYPE_INT24 => 4, + Type::MYSQL_TYPE_LONGLONG => -5, + Type::MYSQL_TYPE_FLOAT => 6, + Type::MYSQL_TYPE_DOUBLE => 8, + Type::MYSQL_TYPE_DECIMAL | Type::MYSQL_TYPE_NEWDECIMAL => 3, + Type::MYSQL_TYPE_DATE | Type::MYSQL_TYPE_NEWDATE => 91, + Type::MYSQL_TYPE_TIME | Type::MYSQL_TYPE_TIME2 => 92, + Type::MYSQL_TYPE_TIMESTAMP + | Type::MYSQL_TYPE_TIMESTAMP2 + | Type::MYSQL_TYPE_DATETIME + | Type::MYSQL_TYPE_DATETIME2 => 93, + Type::MYSQL_TYPE_VARCHAR | Type::MYSQL_TYPE_VAR_STRING => 12, + Type::MYSQL_TYPE_STRING | Type::MYSQL_TYPE_ENUM | Type::MYSQL_TYPE_SET => 1, + Type::MYSQL_TYPE_TINY_BLOB + | Type::MYSQL_TYPE_MEDIUM_BLOB + | Type::MYSQL_TYPE_LONG_BLOB + | Type::MYSQL_TYPE_BLOB => { + if mysql_value_type(column) == wire::JdbcValueType::Binary { + -4 + } else { + -1 + } + } + Type::MYSQL_TYPE_JSON => -1, + Type::MYSQL_TYPE_GEOMETRY | Type::MYSQL_TYPE_VECTOR => -4, + Type::MYSQL_TYPE_NULL => 0, + Type::MYSQL_TYPE_TYPED_ARRAY | Type::MYSQL_TYPE_UNKNOWN => 1111, + } +} + +fn mysql_type_name(column_type: ColumnType) -> &'static str { + use ColumnType as Type; + match column_type { + Type::MYSQL_TYPE_DECIMAL | Type::MYSQL_TYPE_NEWDECIMAL => "DECIMAL", + Type::MYSQL_TYPE_TINY => "TINYINT", + Type::MYSQL_TYPE_SHORT => "SMALLINT", + Type::MYSQL_TYPE_LONG => "INT", + Type::MYSQL_TYPE_FLOAT => "FLOAT", + Type::MYSQL_TYPE_DOUBLE => "DOUBLE", + Type::MYSQL_TYPE_NULL => "NULL", + Type::MYSQL_TYPE_TIMESTAMP | Type::MYSQL_TYPE_TIMESTAMP2 => "TIMESTAMP", + Type::MYSQL_TYPE_LONGLONG => "BIGINT", + Type::MYSQL_TYPE_INT24 => "MEDIUMINT", + Type::MYSQL_TYPE_DATE | Type::MYSQL_TYPE_NEWDATE => "DATE", + Type::MYSQL_TYPE_TIME | Type::MYSQL_TYPE_TIME2 => "TIME", + Type::MYSQL_TYPE_DATETIME | Type::MYSQL_TYPE_DATETIME2 => "DATETIME", + Type::MYSQL_TYPE_YEAR => "YEAR", + Type::MYSQL_TYPE_VARCHAR => "VARCHAR", + Type::MYSQL_TYPE_BIT => "BIT", + Type::MYSQL_TYPE_TYPED_ARRAY => "TYPED_ARRAY", + Type::MYSQL_TYPE_VECTOR => "VECTOR", + Type::MYSQL_TYPE_UNKNOWN => "UNKNOWN", + Type::MYSQL_TYPE_JSON => "JSON", + Type::MYSQL_TYPE_ENUM => "ENUM", + Type::MYSQL_TYPE_SET => "SET", + Type::MYSQL_TYPE_TINY_BLOB => "TINYBLOB", + Type::MYSQL_TYPE_MEDIUM_BLOB => "MEDIUMBLOB", + Type::MYSQL_TYPE_LONG_BLOB => "LONGBLOB", + Type::MYSQL_TYPE_BLOB => "BLOB", + Type::MYSQL_TYPE_VAR_STRING => "VAR_STRING", + Type::MYSQL_TYPE_STRING => "STRING", + Type::MYSQL_TYPE_GEOMETRY => "GEOMETRY", + } +} + +fn numeric_type(column_type: ColumnType) -> bool { + column_type.is_numeric_type() +} + +fn is_binary_column(column: &Column) -> bool { + matches!( + column.column_type(), + ColumnType::MYSQL_TYPE_GEOMETRY | ColumnType::MYSQL_TYPE_VECTOR + ) || (matches!( + column.column_type(), + ColumnType::MYSQL_TYPE_TINY_BLOB + | ColumnType::MYSQL_TYPE_MEDIUM_BLOB + | ColumnType::MYSQL_TYPE_LONG_BLOB + | ColumnType::MYSQL_TYPE_BLOB + | ColumnType::MYSQL_TYPE_VARCHAR + | ColumnType::MYSQL_TYPE_VAR_STRING + | ColumnType::MYSQL_TYPE_STRING + ) && (column.character_set() == 63 || column.flags().contains(ColumnFlags::BINARY_FLAG))) +} + +fn mysql_bool(value: Value) -> Result { + match value { + Value::Int(value) => Ok(value != 0), + Value::UInt(value) => Ok(value != 0), + Value::Bytes(value) => Ok(value.iter().any(|byte| *byte != 0)), + _ => Err(result_decode_error()), + } +} + +fn mysql_i64(value: Value) -> Result { + match value { + Value::Int(value) => Ok(value), + Value::UInt(value) => i64::try_from(value).map_err(|_| result_decode_error()), + Value::Bytes(value) => mysql_utf8(value)? + .parse() + .map_err(|_| result_decode_error()), + _ => Err(result_decode_error()), + } +} + +fn mysql_u64(value: Value) -> Result { + match value { + Value::UInt(value) => Ok(value), + Value::Int(value) => u64::try_from(value).map_err(|_| result_decode_error()), + Value::Bytes(value) => mysql_utf8(value)? + .parse() + .map_err(|_| result_decode_error()), + _ => Err(result_decode_error()), + } +} + +fn mysql_f32(value: Value) -> Result { + match value { + Value::Float(value) => Ok(value), + Value::Bytes(value) => mysql_utf8(value)? + .parse() + .map_err(|_| result_decode_error()), + _ => Err(result_decode_error()), + } +} + +fn mysql_f64(value: Value) -> Result { + match value { + Value::Double(value) => Ok(value), + Value::Float(value) => Ok(f64::from(value)), + Value::Bytes(value) => mysql_utf8(value)? + .parse() + .map_err(|_| result_decode_error()), + _ => Err(result_decode_error()), + } +} + +fn mysql_text(value: Value) -> Result { + match value { + Value::Bytes(value) => mysql_utf8(value), + Value::Int(value) => Ok(value.to_string()), + Value::UInt(value) => Ok(value.to_string()), + Value::Float(value) => Ok(value.to_string()), + Value::Double(value) => Ok(value.to_string()), + Value::Date(year, month, day, hour, minute, second, micros) => Ok(format_timestamp( + year, month, day, hour, minute, second, micros, + )), + Value::Time(negative, days, hours, minutes, seconds, micros) => { + Ok(format_time(negative, days, hours, minutes, seconds, micros)) + } + Value::NULL => Err(result_decode_error()), + } +} + +fn mysql_binary(value: Value) -> Result, AppError> { + match value { + Value::Bytes(value) if value.len() <= MAX_SCALAR_BYTES => Ok(value), + Value::Bytes(_) => Err(resource_error( + "mysql_scalar_too_large", + format!("A MySQL value exceeds {MAX_SCALAR_BYTES} bytes"), + )), + other => Ok(mysql_text(other)?.into_bytes()), + } +} + +fn mysql_utf8(value: Vec) -> Result { + if value.len() > MAX_SCALAR_BYTES { + return Err(resource_error( + "mysql_scalar_too_large", + format!("A MySQL value exceeds {MAX_SCALAR_BYTES} bytes"), + )); + } + String::from_utf8(value).map_err(|_| result_decode_error()) +} + +fn mysql_time(value: Value) -> Result { + match value { + Value::Time(negative, days, hours, minutes, seconds, micros) => { + Ok(format_time(negative, days, hours, minutes, seconds, micros)) + } + other => mysql_text(other), + } +} + +fn format_timestamp( + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + micros: u32, +) -> String { + let base = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}"); + if micros == 0 { + base + } else { + format!("{base}.{micros:06}") + } +} + +fn format_time( + negative: bool, + days: u32, + hours: u8, + minutes: u8, + seconds: u8, + micros: u32, +) -> String { + let sign = if negative { "-" } else { "" }; + let hours = u64::from(days) * 24 + u64::from(hours); + let base = format!("{sign}{hours:02}:{minutes:02}:{seconds:02}"); + if micros == 0 { + base + } else { + format!("{base}.{micros:06}") + } +} + +fn mysql_display(value: Value) -> String { + mysql_text(value).unwrap_or_else(|_| "[unavailable]".to_owned()) +} + +fn validate_query_options(options: QueryOptions) -> Result<(), AppError> { + if options.target_batch_rows > MAX_BATCH_ROWS { + return Err(AppError::invalid( + "invalid_query_limits", + format!("batchRows must be at most {MAX_BATCH_ROWS}"), + )); + } + if options.target_batch_bytes != 0 + && !(1024..=MAX_BATCH_BYTES).contains(&options.target_batch_bytes) + { + return Err(AppError::invalid( + "invalid_query_limits", + format!("batchBytes must be zero or between 1024 and {MAX_BATCH_BYTES}"), + )); + } + if options.max_result_bytes > MAX_RESULT_BYTES { + return Err(AppError::invalid( + "invalid_query_limits", + format!("maxResultBytes must be at most {MAX_RESULT_BYTES}"), + )); + } + Ok(()) +} + +fn quote_identifier(value: &str, field: &str) -> Result { + if value.trim().is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.contains('\0') { + return Err(AppError::invalid( + "invalid_community_table_preview_request", + format!("{field} is invalid"), + )); + } + Ok(format!("`{}`", value.replace('`', "``"))) +} + +async fn terminate_connection(options: Opts, connection_id: u32) -> Result<(), AppError> { + let mut control = open_connection_with_opts(options).await?; + let Ok(result) = tokio::time::timeout( + CONTROL_TIMEOUT, + control.query_drop(format!("KILL CONNECTION {connection_id}")), + ) + .await + else { + drop(control); + return Err(AppError::unavailable( + "mysql_termination_timeout", + "The MySQL query connection could not be terminated in time", + )); + }; + match result { + Ok(()) => { + disconnect_quietly(control).await; + Ok(()) + } + Err(MysqlError::Server(server)) if server.code == 1094 => { + disconnect_quietly(control).await; + Ok(()) + } + Err(error) => { + disconnect_quietly(control).await; + Err(mysql_query_error(error)) + } + } +} + +async fn terminate_connection_quietly(options: Opts, connection_id: u32) { + if let Err(error) = terminate_connection(options, connection_id).await { + tracing::warn!(error = %error, "native MySQL connection termination failed"); + } +} + +async fn open_query_connection( + options: Opts, + cancellation: &mut watch::Receiver, +) -> Result { + let open = open_connection_with_opts(options); + tokio::pin!(open); + let mut cancellation_open = true; + loop { + tokio::select! { + biased; + changed = cancellation.changed(), if cancellation_open => { + if changed.is_err() { + cancellation_open = false; + continue; + } + let request = { cancellation.borrow().clone() }; + if let CancellationRequest::Requested { reason } = request { + return Err(QueryTaskError::Cancelled(reason)); + } + } + result = &mut open => return result.map_err(QueryTaskError::from), + } + } +} + +async fn start_read_only_transaction(conn: &mut Conn) -> Result<(), AppError> { + tokio::time::timeout( + CONTROL_TIMEOUT, + conn.query_drop("START TRANSACTION READ ONLY"), + ) + .await + .map_err(|_| { + AppError::unavailable( + "mysql_transaction_timeout", + "The MySQL read-only transaction did not start in time", + ) + })? + .map_err(mysql_query_error) +} + +async fn finish_read_only_connection_quietly(mut conn: Conn) { + let rollback = tokio::time::timeout(CONTROL_TIMEOUT, conn.query_drop("ROLLBACK")).await; + match rollback { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let error = mysql_query_error(error); + tracing::warn!(error = %error, "native MySQL rollback failed"); + } + Err(_) => tracing::warn!("native MySQL rollback timed out"), + } + disconnect_quietly(conn).await; +} + +async fn disconnect_connection(conn: Conn) -> Result<(), AppError> { + tokio::time::timeout(DISCONNECT_TIMEOUT, conn.disconnect()) + .await + .map_err(|_| { + AppError::unavailable( + "mysql_disconnect_timeout", + "The MySQL connection did not close in time", + ) + })? + .map_err(mysql_connection_error) +} + +async fn disconnect_quietly(conn: Conn) { + if let Err(error) = disconnect_connection(conn).await { + tracing::warn!(error = %error, "native MySQL connection cleanup failed"); + } +} + +fn resource_error(code: impl Into, message: impl Into) -> AppError { + AppError::new( + AppErrorKind::ResourceExhausted, + ApiError::new(code, message), + ) +} + +fn result_decode_error() -> AppError { + AppError::new( + AppErrorKind::Unavailable, + ApiError::new( + "mysql_result_decode_failed", + "A MySQL result value could not be decoded safely", + ), + ) +} + async fn resolve_native_connection( application: &Application, datasource_id: &str, @@ -161,6 +1222,10 @@ async fn resolve_native_connection( async fn open_connection(connection: &DatasourceConnection) -> Result { let opts = connection_opts(connection)?; + open_connection_with_opts(opts).await +} + +async fn open_connection_with_opts(opts: Opts) -> Result { tokio::time::timeout(CONNECT_TIMEOUT, Conn::new(opts)) .await .map_err(|_| { @@ -332,7 +1397,10 @@ fn mysql_query_error(error: MysqlError) -> AppError { mod tests { use chat2db_contract::{DatasourceConnection, DatasourceConnectionProperty}; - use super::{connection_opts, is_mysql_database_type, normalize_table_type}; + use super::{ + connection_opts, is_mysql_database_type, is_native_read_candidate, normalize_table_type, + quote_identifier, validate_read_sql, + }; #[test] fn jdbc_url_and_properties_build_native_options_without_exposing_jdbc() { @@ -414,4 +1482,44 @@ mod tests { assert_eq!(error.api_error().code, "invalid_mysql_connection"); } } + + #[test] + fn native_query_selection_is_read_only_and_conservative() { + for sql in [ + "SELECT 1", + " select * from items;", + "/* leading comment */ SELECT ' FOR UPDATE'", + ] { + assert!(is_native_read_candidate(sql).expect("SQL should tokenize")); + validate_read_sql(sql).expect("plain SELECT should be native"); + } + assert!( + !is_native_read_candidate("UPDATE items SET label = 'changed'") + .expect("SQL should tokenize") + ); + assert!( + is_native_read_candidate("WITH cte AS (SELECT 1) SELECT * FROM cte") + .expect("SQL should tokenize") + ); + + for sql in [ + "WITH cte AS (SELECT 1) SELECT * FROM cte", + "SELECT * FROM items FOR\nUPDATE", + "SELECT * FROM items FOR/**/UPDATE", + "SELECT 1 INTO/**/OUTFILE '/tmp/result'", + "SELECT 1; UPDATE items SET label = 'changed'", + ] { + assert!(validate_read_sql(sql).is_err(), "{sql} must be rejected"); + } + } + + #[test] + fn preview_identifiers_are_quoted_as_one_mysql_identifier() { + assert_eq!( + quote_identifier("odd`name", "tableName").expect("identifier should quote"), + "`odd``name`" + ); + assert!(quote_identifier("", "tableName").is_err()); + assert!(quote_identifier("bad\0name", "tableName").is_err()); + } } diff --git a/crates/chat2db-core/src/query.rs b/crates/chat2db-core/src/query.rs index 3ebbccb..c0fb797 100644 --- a/crates/chat2db-core/src/query.rs +++ b/crates/chat2db-core/src/query.rs @@ -20,13 +20,23 @@ use crate::{ operation::CancellationRequest, }; -struct PreparedQuery { - datasource_id: String, - sql: String, - parameters: Vec, - options: QueryOptions, - retention: Duration, - force_read_only: bool, +pub(crate) struct PreparedQuery { + pub(crate) datasource_id: String, + pub(crate) sql: String, + pub(crate) parameters: Vec, + pub(crate) options: QueryOptions, + pub(crate) retention: Duration, + pub(crate) force_read_only: bool, +} + +enum QueryBackend { + Java { + engine: EngineLease, + resolved: ResolvedDatasourceConnection, + }, + NativeMysql { + resolved: ResolvedDatasourceConnection, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,7 +51,7 @@ pub(crate) struct DatabaseWriteError { pub(crate) outcome: DatabaseWriteOutcome, } -enum QueryTaskError { +pub(crate) enum QueryTaskError { Failed(AppError), Cancelled(Option), } @@ -52,7 +62,7 @@ impl From for QueryTaskError { } } -struct RetainedWriter { +pub(crate) struct RetainedWriter { inner: Option, } @@ -102,7 +112,18 @@ impl Application { prepared: PreparedQuery, ) -> Result { let storage = self.require_storage()?; - let engine = self.require_engine().await?; + let resolved = resolve_datasource_connection(&storage, &prepared.datasource_id).await?; + let backend = if self.is_native_mysql_driver(&resolved.driver_id) + && crate::native_mysql::is_native_read_candidate(&prepared.sql)? + { + crate::native_mysql::validate_query(&prepared)?; + QueryBackend::NativeMysql { resolved } + } else { + QueryBackend::Java { + engine: self.require_engine().await?, + resolved, + } + }; let accepting_work = self.inner.accepting_work.lock().await; if !*accepting_work { return Err(AppError::unavailable( @@ -126,7 +147,7 @@ impl Application { operation.cancellation, prepared, storage, - engine, + backend, ) .await; application @@ -163,11 +184,32 @@ impl Application { cancellation: watch::Receiver, query: PreparedQuery, storage: Storage, - engine: EngineLease, + backend: QueryBackend, ) { - let outcome = self - .execute_query_task(&operation_id, cancellation, query, storage, engine) - .await; + let outcome = match backend { + QueryBackend::Java { engine, resolved } => { + self.execute_query_task( + &operation_id, + cancellation, + query, + storage, + engine, + resolved, + ) + .await + } + QueryBackend::NativeMysql { resolved } => { + crate::native_mysql::execute_query_task( + self, + &operation_id, + cancellation, + query, + storage, + resolved, + ) + .await + } + }; match outcome { Ok(result) => { let _ = self.inner.operations.completed(&operation_id, result).await; @@ -192,9 +234,8 @@ impl Application { query: PreparedQuery, storage: Storage, engine: EngineLease, + resolved: ResolvedDatasourceConnection, ) -> Result { - let resolved = resolve_datasource_connection(&storage, &query.datasource_id).await?; - if let CancellationRequest::Requested { reason } = cancellation.borrow().clone() { return Err(QueryTaskError::Cancelled(reason)); } @@ -515,7 +556,7 @@ impl TryFrom for PreparedQuery { } impl RetainedWriter { - async fn begin( + pub(crate) async fn begin( storage: Storage, schema: wire::QueryStarted, retention: Duration, @@ -526,7 +567,7 @@ impl RetainedWriter { }) } - async fn append(&mut self, batch: wire::RowBatch) -> Result { + pub(crate) async fn append(&mut self, batch: wire::RowBatch) -> Result { let mut writer = self.inner.take().ok_or_else(AppError::internal)?; let (returned, result) = tokio::task::spawn_blocking(move || { let result = writer.append_batch(&batch); @@ -543,7 +584,7 @@ impl RetainedWriter { .persisted_bytes()) } - async fn finish( + pub(crate) async fn finish( &mut self, completed: wire::QueryCompleted, ) -> Result { @@ -552,7 +593,7 @@ impl RetainedWriter { Ok(convert::result_metadata(metadata)) } - async fn abort(&mut self) -> Result<(), AppError> { + pub(crate) async fn abort(&mut self) -> Result<(), AppError> { if let Some(writer) = self.inner.take() { tokio::task::spawn_blocking(move || writer.abort()) .await From 6c7442113158559b462438af789a61141bf0586b Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 12:05:38 +0800 Subject: [PATCH 16/19] test(mysql): prove native product vertical without Java --- .github/workflows/ci.yml | 8 + Makefile | 12 +- .../tests/native_mysql_product.rs | 540 ++++++++++++++++++ 3 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 crates/chat2db-core/tests/native_mysql_product.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afba49e..437d7be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,6 +186,14 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.8.1 with: shared-key: mysql-integration + - name: Verify native MySQL product vertical without Java + env: + MYSQL_TEST_HOST: 127.0.0.1 + MYSQL_TEST_PORT: "3306" + MYSQL_TEST_USER: root + MYSQL_TEST_PASSWORD: chat2db-ci-root + MYSQL_TEST_REQUIRED: "1" + run: cargo test -p chat2db-core --test native_mysql_product --locked - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.7.1 with: distribution: temurin diff --git a/Makefile b/Makefile index c9e7660..81ca509 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ community-h2-classpath community-h2-reproducibility community-java-h2-integration \ community-h2-integration \ community-product-h2-integration product-h2-integration mysql-driver-pack \ - community-product-mysql-integration \ + native-mysql-integration community-product-mysql-integration \ frontend-deps frontend-source frontend desktop generate-contracts check-contracts \ macos-runtime macos-package-java macos-package macos-package-verify @@ -61,6 +61,16 @@ product-h2-integration: java mysql-driver-pack: ./scripts/prepare-mysql-driver-pack.sh "$(MYSQL_DRIVER_PACK_DIR)" +native-mysql-integration: + @test -n "$(MYSQL_TEST_USER)" || (echo "MYSQL_TEST_USER is required" >&2; exit 1) + @test -n "$(MYSQL_TEST_PASSWORD)" || (echo "MYSQL_TEST_PASSWORD is required" >&2; exit 1) + @MYSQL_TEST_HOST="$(MYSQL_TEST_HOST)" \ + MYSQL_TEST_PORT="$(MYSQL_TEST_PORT)" \ + MYSQL_TEST_USER="$(MYSQL_TEST_USER)" \ + MYSQL_TEST_PASSWORD="$(MYSQL_TEST_PASSWORD)" \ + MYSQL_TEST_REQUIRED="1" \ + cargo test -p chat2db-core --test native_mysql_product --locked + community-product-mysql-integration: java community-h2-classpath mysql-driver-pack @test -n "$(MYSQL_TEST_USER)" || (echo "MYSQL_TEST_USER is required" >&2; exit 1) @test -n "$(MYSQL_TEST_PASSWORD)" || (echo "MYSQL_TEST_PASSWORD is required" >&2; exit 1) diff --git a/crates/chat2db-core/tests/native_mysql_product.rs b/crates/chat2db-core/tests/native_mysql_product.rs new file mode 100644 index 0000000..51c690b --- /dev/null +++ b/crates/chat2db-core/tests/native_mysql_product.rs @@ -0,0 +1,540 @@ +use std::{panic::AssertUnwindSafe, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chat2db_contract::{ + CancelDisposition, ComponentState, CreateDatasourceRequest, DatasourceConnection, + DatasourceConnectionProperty, JdbcValue, ListCommunityDatabasesRequest, + ListCommunitySchemasRequest, ListCommunityTablesRequest, OperationEvent, OperationStatus, + QueryLimits, QueryParameter, ResultMetadata, ResultPageRequest, + StartCommunityTablePreviewRequest, StartQueryRequest, +}; +use chat2db_core::{Application, RuntimeConfig, RuntimeHost}; +use chat2db_java_bridge::{EngineCommand, EngineConfig}; +use futures_util::FutureExt as _; +use mysql_async::{Conn, Opts, OptsBuilder, prelude::Queryable}; +use tempfile::TempDir; +use uuid::Uuid; + +const MYSQL_DATABASE_TYPE: &str = "MYSQL"; +const EVENT_TIMEOUT: Duration = Duration::from_secs(15); +const REQUIRED_MYSQL_ENV: [&str; 4] = [ + "MYSQL_TEST_HOST", + "MYSQL_TEST_PORT", + "MYSQL_TEST_USER", + "MYSQL_TEST_PASSWORD", +]; + +struct MysqlTestConfig { + host: String, + port: u16, + user: String, + password: String, +} + +impl MysqlTestConfig { + fn from_environment() -> Option { + let required = mysql_test_required(); + let configured = REQUIRED_MYSQL_ENV + .iter() + .filter(|name| std::env::var_os(name).is_some()) + .count(); + if configured == 0 { + assert!( + !required, + "MYSQL_TEST_REQUIRED is enabled but the MySQL endpoint is absent" + ); + eprintln!("skipping native MySQL product test; MYSQL_TEST_* variables are absent"); + return None; + } + assert_eq!( + configured, + REQUIRED_MYSQL_ENV.len(), + "native MySQL integration is partially configured" + ); + let host = required_env("MYSQL_TEST_HOST"); + assert!( + !host.trim().is_empty() + && !host.chars().any(char::is_control) + && !host.contains(['/', '?', '#']), + "MYSQL_TEST_HOST is invalid" + ); + let port = required_env("MYSQL_TEST_PORT") + .parse::() + .expect("MYSQL_TEST_PORT must be a TCP port"); + assert_ne!(port, 0, "MYSQL_TEST_PORT cannot be zero"); + let user = required_env("MYSQL_TEST_USER"); + assert!(!user.is_empty(), "MYSQL_TEST_USER cannot be empty"); + Some(Self { + host, + port, + user, + password: required_env("MYSQL_TEST_PASSWORD"), + }) + } + + fn native_options(&self) -> Opts { + OptsBuilder::default() + .ip_or_hostname(self.host.clone()) + .tcp_port(self.port) + .user(Some(self.user.clone())) + .pass(Some(self.password.clone())) + .prefer_socket(Some(false)) + .into() + } + + fn connection(&self, database_name: Option<&str>) -> DatasourceConnection { + let host = if self.host.contains(':') + && !(self.host.starts_with('[') && self.host.ends_with(']')) + { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + DatasourceConnection { + jdbc_url: format!( + "jdbc:mysql://{host}:{}/{database}?useSSL=false&serverTimezone=UTC", + self.port, + database = database_name.unwrap_or_default() + ), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: self.user.clone(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: self.password.clone(), + sensitive: true, + }, + ], + read_only: false, + } + } +} + +#[tokio::test] +async fn native_mysql_product_paths_keep_java_dormant() { + let Some(config) = MysqlTestConfig::from_environment() else { + return; + }; + let database_name = format!("chat2db_native_it_{}", Uuid::new_v4().simple()); + provision_database(&config, &database_name).await; + + let verification = AssertUnwindSafe(verify_native_product(&config, &database_name)) + .catch_unwind() + .await; + let cleanup = cleanup_database(&config, &database_name).await; + if let Err(payload) = verification { + if let Err(error) = cleanup { + eprintln!("native MySQL cleanup also failed: {error}"); + } + std::panic::resume_unwind(payload); + } + cleanup.expect("native MySQL fixture must be removed"); +} + +async fn verify_native_product(config: &MysqlTestConfig, database_name: &str) { + let directory = TempDir::new().expect("temporary native MySQL runtime"); + let missing_java = directory.path().join("missing-java"); + let runtime = RuntimeConfig::new(EngineConfig::new(EngineCommand::new(missing_java))) + .with_data_dir(directory.path().join("data")) + .with_vault_master_key_base64(STANDARD.encode([0x6d; 32])); + let mut host = RuntimeHost::open(runtime) + .await + .expect("native MySQL runtime must open without Java"); + let application = host.application(); + assert_java_dormant(&application); + assert!(application.list_drivers().items.is_empty()); + + let unknown_driver = application + .test_datasource_connection("notmysql", config.connection(None)) + .await + .expect_err("unknown driver ids must not enter the native MySQL path"); + assert_eq!(unknown_driver.api_error().code, "driver_not_installed"); + assert_java_dormant(&application); + + application + .test_datasource_connection("mysql", config.connection(None)) + .await + .expect("native MySQL connection test must succeed without a JDBC pack"); + assert_java_dormant(&application); + + let datasource = application + .create_datasource(CreateDatasourceRequest { + name: "Native MySQL".to_owned(), + driver_id: "mysql".to_owned(), + connection: Some(config.connection(Some(database_name))), + }) + .await + .expect("native MySQL datasource must persist without a JDBC pack"); + + verify_native_metadata(&application, &datasource.id, database_name).await; + verify_native_preview(&application, &datasource.id, database_name).await; + verify_native_console(&application, &datasource.id).await; + verify_rejected_native_selects(&application, &datasource.id).await; + verify_native_truncation(&application, &datasource.id).await; + verify_native_cancellation(&application, &datasource.id, config, database_name).await; + + host.shutdown() + .await + .expect("native-only runtime must shut down cleanly"); +} + +async fn verify_native_metadata( + application: &Application, + datasource_id: &str, + database_name: &str, +) { + let databases = application + .list_community_databases(ListCommunityDatabasesRequest { + datasource_id: datasource_id.to_owned(), + database_type: MYSQL_DATABASE_TYPE.to_owned(), + }) + .await + .expect("native MySQL databases must list"); + assert!( + databases + .items + .iter() + .any(|database| database.name == database_name) + ); + assert!( + databases + .items + .iter() + .find(|database| database.name == "information_schema") + .is_some_and(|database| database.system) + ); + assert_java_dormant(application); + + let schemas = application + .list_community_schemas(ListCommunitySchemasRequest { + datasource_id: datasource_id.to_owned(), + database_type: MYSQL_DATABASE_TYPE.to_owned(), + database_name: database_name.to_owned(), + }) + .await + .expect("MySQL schema route must stay native"); + assert!(schemas.items.is_empty()); + + let tables = application + .list_community_tables(ListCommunityTablesRequest { + datasource_id: datasource_id.to_owned(), + database_type: MYSQL_DATABASE_TYPE.to_owned(), + database_name: database_name.to_owned(), + schema_name: String::new(), + table_name_pattern: String::new(), + }) + .await + .expect("native MySQL tables must list"); + let table = tables + .items + .iter() + .find(|table| table.name == "items") + .expect("fixture table must be visible"); + assert_eq!(table.database_name, database_name); + assert_eq!(table.table_type, "TABLE"); + assert_eq!(table.engine, "InnoDB"); + assert_java_dormant(application); +} + +async fn verify_native_preview( + application: &Application, + datasource_id: &str, + database_name: &str, +) { + let preview = application + .start_community_table_preview(StartCommunityTablePreviewRequest { + datasource_id: datasource_id.to_owned(), + database_type: MYSQL_DATABASE_TYPE.to_owned(), + database_name: database_name.to_owned(), + schema_name: String::new(), + table_name: "items".to_owned(), + row_limit: Some(2), + }) + .await + .expect("native MySQL preview must be accepted"); + assert_eq!( + preview.sql, + format!("SELECT * FROM `{database_name}`.`items` LIMIT 2") + ); + let preview_result = wait_for_result(application, &preview.operation_id).await; + assert_eq!(preview_result.row_count, "2"); + assert!(!preview_result.truncated_by_max_rows); + let preview_page = result_page(application, &preview_result).await; + assert_eq!(preview_page.rows.len(), 2); + assert_java_dormant(application); +} + +async fn verify_native_console(application: &Application, datasource_id: &str) { + let query = application + .start_query(StartQueryRequest { + datasource_id: datasource_id.to_owned(), + sql: "SELECT id, label, amount, active, created_at FROM items ORDER BY id".to_owned(), + parameters: Vec::new(), + limits: query_limits("10"), + }) + .await + .expect("native MySQL Console SELECT must be accepted"); + let query_result = wait_for_result(application, &query.operation_id).await; + assert_eq!(query_result.row_count, "3"); + let page = result_page(application, &query_result).await; + assert_eq!(page.rows.len(), 3); + assert!(matches!( + page.rows[0].values.as_slice(), + [ + JdbcValue::SignedInteger { value: id }, + JdbcValue::Text { value: label }, + JdbcValue::Decimal { value: amount }, + JdbcValue::SignedInteger { value: active }, + JdbcValue::Timestamp { value: created_at }, + ] if id == "1" + && label == "mysql-ready" + && amount == "99.99" + && active == "1" + && created_at == "2026-07-27T12:34:56" + )); + assert_java_dormant(application); +} + +async fn verify_rejected_native_selects(application: &Application, datasource_id: &str) { + let parameterized = application + .start_query(StartQueryRequest { + datasource_id: datasource_id.to_owned(), + sql: "SELECT ?".to_owned(), + parameters: vec![QueryParameter { + position: 1, + value: JdbcValue::SignedInteger { + value: "1".to_owned(), + }, + }], + limits: query_limits("10"), + }) + .await + .expect_err("parameterized native SELECT must fail without starting Java"); + assert_eq!(parameterized.api_error().code, "invalid_query_request"); + + let cte = application + .start_query(StartQueryRequest { + datasource_id: datasource_id.to_owned(), + sql: "WITH selected AS (SELECT 1) SELECT * FROM selected".to_owned(), + parameters: Vec::new(), + limits: query_limits("10"), + }) + .await + .expect_err("unsupported native SELECT must fail without starting Java"); + assert_eq!(cte.api_error().code, "mysql_native_query_unsupported"); + assert_java_dormant(application); +} + +async fn verify_native_truncation(application: &Application, datasource_id: &str) { + let query = application + .start_query(StartQueryRequest { + datasource_id: datasource_id.to_owned(), + sql: "SELECT id FROM items ORDER BY id".to_owned(), + parameters: Vec::new(), + limits: query_limits("1"), + }) + .await + .expect("bounded native SELECT must be accepted"); + let result = wait_for_result(application, &query.operation_id).await; + assert_eq!(result.row_count, "1"); + assert!(result.truncated_by_max_rows); + assert_eq!(result_page(application, &result).await.rows.len(), 1); + assert_java_dormant(application); +} + +async fn verify_native_cancellation( + application: &Application, + datasource_id: &str, + config: &MysqlTestConfig, + database_name: &str, +) { + let query = application + .start_query(StartQueryRequest { + datasource_id: datasource_id.to_owned(), + sql: "SELECT SLEEP(30)".to_owned(), + parameters: Vec::new(), + limits: query_limits("1"), + }) + .await + .expect("cancellable native SELECT must be accepted"); + let mut subscription = application + .subscribe_operation(&query.operation_id, Some(0)) + .await + .expect("native cancellation operation must be subscribable"); + wait_for_active_sleep(config, database_name).await; + + let cancellation = application.cancel_operation(&query.operation_id).await; + assert_eq!(cancellation.disposition, CancelDisposition::Accepted); + tokio::time::timeout(EVENT_TIMEOUT, async { + loop { + let envelope = subscription + .next_event() + .await + .expect("operation event must decode") + .expect("operation must emit a cancelled event"); + if matches!(envelope.event, OperationEvent::Cancelled { .. }) { + break; + } + } + }) + .await + .expect("native MySQL query must cancel before timeout"); + let snapshot = application + .operation_snapshot(&query.operation_id) + .await + .expect("cancelled native operation must remain inspectable"); + assert_eq!(snapshot.status, OperationStatus::Cancelled); + assert!(snapshot.result.is_none()); + assert_java_dormant(application); +} + +async fn wait_for_active_sleep(config: &MysqlTestConfig, database_name: &str) { + let mut conn = Conn::new(config.native_options()) + .await + .expect("process-list probe must connect"); + tokio::time::timeout(EVENT_TIMEOUT, async { + loop { + let active = conn + .exec_first::( + "SELECT COUNT(*) FROM information_schema.PROCESSLIST \ + WHERE DB = ? AND INFO LIKE 'SELECT SLEEP(30)%'", + (database_name,), + ) + .await + .expect("process-list probe must succeed") + .unwrap_or_default(); + if active > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("sleep query must become active before cancellation"); + conn.disconnect() + .await + .expect("process-list probe must disconnect"); +} + +fn query_limits(max_rows: &str) -> QueryLimits { + QueryLimits { + max_rows: max_rows.to_owned(), + max_result_bytes: (8_u64 * 1024 * 1024).to_string(), + batch_rows: 2, + batch_bytes: 1024 * 1024, + result_ttl_seconds: 60, + } +} + +async fn wait_for_result(application: &Application, operation_id: &str) -> ResultMetadata { + let mut subscription = application + .subscribe_operation(operation_id, None) + .await + .expect("query operation must be subscribable"); + tokio::time::timeout(EVENT_TIMEOUT, async { + while let Some(envelope) = subscription + .next_event() + .await + .expect("operation event must decode") + { + match envelope.event { + OperationEvent::Completed { result } => return result, + OperationEvent::Failed { error } => panic!("native MySQL query failed: {error:?}"), + OperationEvent::Cancelled { reason } => { + panic!("native MySQL query was cancelled: {reason:?}") + } + OperationEvent::Started | OperationEvent::Progress { .. } => {} + } + } + panic!("native MySQL operation ended without a terminal event") + }) + .await + .expect("native MySQL query must finish before timeout") +} + +async fn result_page( + application: &Application, + result: &ResultMetadata, +) -> chat2db_contract::ResultPage { + application + .result_page( + &result.id, + ResultPageRequest { + offset: "0".to_owned(), + max_rows: "20".to_owned(), + max_bytes: (8_u64 * 1024 * 1024).to_string(), + }, + ) + .await + .expect("native MySQL result page must be retained") +} + +async fn provision_database(config: &MysqlTestConfig, database_name: &str) { + let mut conn = Conn::new(config.native_options()) + .await + .expect("native MySQL fixture connection must open"); + conn.query_drop(format!( + "CREATE DATABASE `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" + )) + .await + .expect("native MySQL fixture database must create"); + conn.query_drop(format!( + "CREATE TABLE `{database_name}`.`items` (\ + `id` BIGINT NOT NULL, `label` VARCHAR(128) NOT NULL, \ + `amount` DECIMAL(12,2) NOT NULL, `active` BOOLEAN NOT NULL, \ + `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`)\ + ) ENGINE=InnoDB" + )) + .await + .expect("native MySQL fixture table must create"); + conn.query_drop(format!( + "INSERT INTO `{database_name}`.`items` VALUES \ + (1, 'mysql-ready', 99.99, TRUE, '2026-07-27 12:34:56'), \ + (2, 'second', 2.50, FALSE, '2026-07-28 01:02:03'), \ + (3, 'third', 3.75, TRUE, '2026-07-29 04:05:06')" + )) + .await + .expect("native MySQL fixture rows must insert"); + conn.disconnect() + .await + .expect("native MySQL fixture connection must close"); +} + +async fn cleanup_database(config: &MysqlTestConfig, database_name: &str) -> Result<(), String> { + let mut conn = Conn::new(config.native_options()) + .await + .map_err(|error| error.to_string())?; + conn.query_drop(format!("DROP DATABASE IF EXISTS `{database_name}`")) + .await + .map_err(|error| error.to_string())?; + conn.disconnect().await.map_err(|error| error.to_string()) +} + +fn assert_java_dormant(application: &Application) { + let engine = application + .health() + .components + .into_iter() + .find(|component| component.id == "database-engine") + .expect("database engine health must be present"); + assert_eq!(engine.state, ComponentState::Ready); + assert_eq!(engine.detail, "Available on demand; Java is not running"); +} + +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} must be configured")) +} + +fn mysql_test_required() -> bool { + match std::env::var("MYSQL_TEST_REQUIRED") { + Err(std::env::VarError::NotPresent) => false, + Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") => true, + Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, + Ok(_) | Err(std::env::VarError::NotUnicode(_)) => { + panic!("MYSQL_TEST_REQUIRED must be 1, 0, true, or false") + } + } +} From 900f44a73cc5f1503077355389affb7e65d6311b Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 12:16:23 +0800 Subject: [PATCH 17/19] docs(mysql): document native mysql fast path --- README.md | 35 ++++++++++++++------ docs/architecture.md | 76 ++++++++++++++++++++++++++++++-------------- docs/stages.md | 26 ++++++++++++--- 3 files changed, 100 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7163b95..0c49aa2 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ Source-available implementation of the Chat2DB Community hybrid runtime. -Chat2DB Rust owns the product runtime in Rust while retaining the public -Chat2DB Community database compatibility layer behind a supervised Java -process. The repository is under active development and is not yet a stable -end-user release. +Chat2DB Rust owns the product runtime in Rust, uses a native Rust path for the +current MySQL browser and SELECT slice, and retains the broader public Chat2DB +Community database compatibility layer behind a supervised Java process. The +repository is under active development and is not yet a stable end-user +release. ## Clone @@ -90,15 +91,16 @@ Console compatibility slice: - product-owned Community DTOs and Core services exposed consistently through Axum and Tauri, with exact locked-classpath startup and forced-read-only metadata sessions; and -- an original-frontend compatibility layer for MySQL connection testing, +- an original-frontend compatibility layer for native MySQL connection testing, datasource CRUD/tree, database/schema/table discovery, and synchronous table preview over the historical `{success,data,errorCode,errorMessage}` envelope; and - durable SQLite-backed Community Console create/get/list/update/delete, including SQL text, datasource/database/schema binding, saved status, and open-tab state across process restarts; and -- Community Console SELECT execution through the existing bounded Core query - and retained-result path: Web uses the historical synchronous result shape, +- Community Console SELECT execution through upstream `mysql_async 0.37.0`, a + MySQL read-only transaction, and the existing bounded Core retained-result + path without starting Java: Web uses the historical synchronous result shape, while desktop maps operation lifecycle, rows, failure, and cancellation to the original JCEF event bus; - a shared Web/Tauri legacy dispatcher: Axum maps the original `/api` routes, @@ -118,7 +120,11 @@ and three-row table preview. On 2026-07-28 commits `36ecac6`, `78e92d6`, and frontend tests, and the Community production build. A live MySQL 8.4 run then created a Console, returned real table rows, returned a renderable SQL error, saved edited SQL, closed it, restarted the Rust host, reopened it, and executed -the restored SQL successfully. +the restored SQL successfully. On 2026-07-29 commits `81301c3`, `4199862`, and +`6c74421` passed 144 Core unit tests, strict Core Clippy, and a real MySQL 8.4 +vertical covering native connection, database/schema/table discovery, preview, +typed Console SELECT, row truncation, active-query cancellation, retained +paging, and proof after every operation that Java remained dormant. Stage 6 is complete. Web and desktop own the product runtime and publish its owner-only local endpoint; CLI and MCP attach to that host and never contact @@ -156,7 +162,10 @@ build a row-limited SELECT without opening JDBC, then Rust validates that SQL an executes it through the existing forced-read-only query and retained-result path. The fixed 149-JAR classpath keeps H2 and MySQL; PostgreSQL and other dialects do not block the MySQL preview. MySQL writes, Agent, CLI, and MCP -conformance remain outside this small read-only milestone. +conformance remain outside this small read-only milestone. The current MySQL +connection, database/schema/table, preview, and supported Console SELECT routes +dispatch to `mysql_async` before Java lease acquisition; Community parser, +formatter, completion, builders, and advanced metadata remain Java-backed. The first Console compatibility slice adds SQLite migration 3 for saved Consoles and the historical `/api/operation/saved/*` plus @@ -244,6 +253,14 @@ MYSQL_TEST_PASSWORD='' \ make community-product-mysql-integration ``` +Run the native MySQL product path alone, without building Java or Connector/J: + +```bash +MYSQL_TEST_USER=root \ +MYSQL_TEST_PASSWORD='' \ +make native-mysql-integration +``` + `MYSQL_TEST_HOST` and `MYSQL_TEST_PORT` default to `127.0.0.1:3306`. The test creates a uniquely named database, verifies driver loading, datasource CRUD, metadata, namespace DDL, typed DML, parsing, validation, formatting, completion, diff --git a/docs/architecture.md b/docs/architecture.md index bbe4b58..71c3973 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,9 +16,11 @@ without embedding H2 in the compatibility-engine JAR. The Web and Tauri hosts open the production vault, SQLite storage, and verified driver catalog before exposing a shared `Application`; they do not start Java -during host bootstrap. The Core `EngineManager` starts one Java generation on -the first database, parser, formatter, or completion request, shares that -single-flight startup across concurrent callers, and issues generation-scoped +during host bootstrap. Native MySQL connection, database/schema/table, preview, +and supported Console SELECT operations do not acquire a Java lease. The Core +`EngineManager` starts one Java generation on the first JDBC-only database, +parser, formatter, completion, builder, or advanced metadata request. It shares +that single-flight startup across concurrent callers and issues generation-scoped leases that remain live through stream and session cleanup. After the final lease is released, the default three-minute idle deadline shuts down and fully reaps Java. A later request starts a new generation and reloads every staged @@ -42,9 +44,9 @@ workbench. The build exports the unmodified Community frontend tree pinned by `scripts/community-frontend.lock.json`. Web maps its historical `/api` contract through Axum; desktop maps the existing `window.javaQuery` contract through one Tauri `legacy_request` command. Both paths call the same Rust -legacy dispatcher. The implemented product slice covers MySQL connection -testing, datasource CRUD/tree, database/schema/table discovery, and -forced-read-only table preview. Signing, distribution, the remaining dialect +legacy dispatcher. The implemented product slice covers native MySQL connection +testing, datasource CRUD/tree, database/schema/table discovery, read-only table +preview, and typed Console SELECT. Signing, distribution, the remaining dialect estate, broader historical API coverage, and packaging remain target components. CLI and MCP attach to a running host rather than composing a second product runtime. @@ -52,7 +54,9 @@ second product runtime. Runtime-tested: yes for the Stage 7M MySQL product vertical. On 2026-07-27 the complete stored-datasource path passed against MySQL 8.4, from real Community identifier/DQL/page-limit generation through forced-read-only JDBC execution -and retained-result paging. +and retained-result paging. On 2026-07-29 the native MySQL path passed against +MySQL 8.4 with a deliberately missing Java executable, including active-query +cancellation and dormant-Java assertions after every product operation. ## Ownership @@ -65,7 +69,8 @@ and retained-result paging. | Durable state | Rust | SQLite, retained-result files, and a mandatory injected secret-vault contract | | AI agent | Rust | Provider adapters, tool loop, limits, compaction, and cancellation | | MCP and CLI | Rust | Adapters around the same product services and policy | -| Database compatibility | Java 17 | Existing SPI/plugins, JDBC, metadata, builders, and execution | +| Native MySQL product slice | Rust / `mysql_async` | Connection, database/schema/table discovery, preview, supported SELECT, typed streaming, limits, cancellation, and retained results | +| Compatibility databases and advanced MySQL operations | Java 17 | Existing SPI/plugins, JDBC, advanced metadata, builders, parsing, formatting, completion, writes, and transactions | | SQL parsing, formatting, and completion | Java 17 | Existing Java ANTLR grammars, parser behavior, formatter behavior, and completion | | Rust-to-Java IPC | Shared Protobuf contract | Length-prefixed frames over private stdin/stdout | @@ -82,6 +87,7 @@ React in system WebView React in browser <- owner-only local attachment <- rmcp stdio server <- MCP client -> SQLite and result store -> AI agent runtime + -> native MySQL connection / metadata / SELECT -> Java process supervisor -> Protobuf stdin/stdout -> Java database compatibility engine @@ -119,9 +125,24 @@ cross-language acceptance gates pass. ## Database boundary -Java remains the primary database implementation at the first complete release. -Rust does not reimplement vendor wire protocols and does not introduce parallel -native-driver behavior before the Java-backed conformance baseline passes. +Java/JDBC remains the compatibility implementation for other databases and for +unmigrated advanced MySQL operations. The first closed native route uses +upstream `mysql_async 0.37.0` for MySQL connection testing, +database/schema/table discovery, preview, and supported Console SELECT. Core +selects this backend before requesting an `EngineLease`; unrecognized drivers +cannot enter it. Parameterized, CTE-first, locking, server-file, and +multi-statement SELECT is rejected rather than silently starting Java. + +The native MySQL baseline implements: + +- JDBC-URL and connection-property translation into `mysql_async::Opts`, with + explicit Rustls policy, TCP preference, and a 15-second connect deadline; +- database/schema/table metadata and safely quoted bounded table preview; +- one read-only prepared SELECT with ordered typed columns and values emitted + through the existing retained-result wire contract; +- row, result-byte, batch, column, SQL, identifier, and scalar limits; and +- active-query cancellation and truncation through a second bounded connection + issuing `KILL CONNECTION`, followed by deterministic result cleanup. The JDBC baseline implements: @@ -331,14 +352,16 @@ qualified identifier. This generation step accepts no raw SQL, opens no JDBC session, and never executes the returned statement. Core applies the product default of 200 rows and rejects limits outside -`1..=1000`. It parses the generated SQL through the retained Community parser -and requires `is_select`, at most one projected SELECT statement, a SELECT -prefix, and no semicolon before passing the SQL to `start_read_query`. That -existing path resolves the stored datasource, forces the JDBC session read-only, -caps the result at the same row limit and 8 MiB, writes batches of at most 1 MiB, -and retains the result for one hour. The accepted response carries the operation -id, exact SQL, and effective row limit; normal operation events, cancellation, -and retained-result paging remain unchanged. +`1..=1000`. The current MySQL route safely quotes the database and table as two +identifier segments and builds the bounded SELECT directly in Rust. Other +drivers retain the Community builder and parser checks for `is_select`, one +projected statement, a SELECT prefix, and no semicolon. Both routes pass the SQL +to `start_read_query`, which dispatches MySQL to a native read-only transaction +and other drivers to a forced-read-only JDBC session. It caps the result at the +same row limit and 8 MiB, writes batches of at most 1 MiB, and retains the result +for one hour. The accepted response carries the operation id, exact SQL, and +effective row limit; normal operation events, cancellation, and retained-result +paging remain unchanged. Axum exposes `POST /api/v1/community/table-preview`; Tauri exposes `start_community_table_preview`; generated OpenAPI/TypeScript and both frontend @@ -347,11 +370,13 @@ selected plugin advertises DQL support. Starting a preview inserts the generated SQL into the editor and observes the accepted operation in the existing result surface. A table/scope change aborts the pending request, and a late accepted operation is cancelled instead of replacing newer state. The Core path is -runtime-tested against MySQL 8.4 with a pinned Connector/J pack. Product writes +runtime-tested against MySQL 8.4 through both the historical Connector/J gate +and the native `mysql_async` gate. Product writes and Agent, CLI, and MCP MySQL conformance remain outside Stage 7M; PostgreSQL and long-tail plugin conformance do not block this MySQL milestone. -Remaining builder operations, general type conversion, non-relational +Remaining builder operations, complete MySQL type conformance, native bind +parameters and CTE-first SELECT, non-relational operations, script execution, import/export, and per-dialect conformance are not implemented yet. @@ -380,7 +405,9 @@ fresh nonce, AES-256-GCM, and reference-bound additional authenticated data. Headless hosts can inject an explicit standard-base64 32-byte master key. Both paths fail closed before storage opens when their key source is unavailable. -Java streams typed row batches to Rust under bounded row and byte budgets. Rust +Java/JDBC streams typed row batches to Rust under bounded row and byte budgets; +the native MySQL producer emits the same wire column, row-batch, and completion +messages directly in Core. Rust persists the schema and batches as four-byte big-endian length-prefixed Protobuf frames and indexes full-frame SHA-256 hashes, offsets, ordinals, row ranges, completion state, and expiry in SQLite. File data is synced before its index @@ -457,8 +484,9 @@ The current operation journal retains at most 256 events per operation. Subscriptions atomically capture replay plus live delivery, reject cursors ahead of the operation or behind the retained window, and stop after one terminal event. Query batches are durably appended before progress is emitted -or the next Java credit is granted. Failures and cancellations abort incomplete -writers and close the Java session. +or the next Java credit/native row is consumed. Failures and cancellations abort +incomplete writers and close the Java session or terminate the native MySQL +connection through a separate bounded control connection. The first Stage 7 compatibility slice discovers strict local driver-pack manifests, verifies bounded artifacts in Rust, preloads them sequentially into diff --git a/docs/stages.md b/docs/stages.md index c6e726f..063bf38 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -12,7 +12,7 @@ in runtime health until then. | 4 | Complete | Product and result storage foundation | SQLite migration/integrity gates, mandatory vault boundary, revisioned datasource records, durable result frames, bounded paging/quota, expiry, writer cleanup and recovery tests | | 5 | Complete | Product transports | Generated OpenAPI/TypeScript contract, Axum JSON/SSE, Tauri 2 commands/channels, shared SQL workbench, product H2 tests | | 6 | Complete | Agent, MCP, and CLI | Direct providers, durable bounded tool loop, SQL tools/permissions, compaction, Web/Tauri run transports, owner-only local attachment, read-query CLI, and bounded `rmcp` stdio tools | -| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; the host now starts Java on first use, leases active generations, reaps them after three idle minutes, and reloads packs after restart; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, and bounded SELECT execution | +| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; native `mysql_async` now owns the MySQL browser and supported SELECT path while Java starts on demand for unmigrated compatibility work; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, and bounded SELECT execution | | 8 | Planned | Packaging and release | License authorization, NOTICE/SBOM, jlink runtime, Tauri installers, signed product/engine/driver manifests, atomic update and rollback, size measurement | Stage 3 completion means the versioned Rust-Java bridge can load an external @@ -318,9 +318,27 @@ This slice is query-only. Arbitrary DDL, DML, and multi-statement Console execution are not implemented and must not be inferred from the historical endpoint name. -Stage 7 remains incomplete. General type conversion, script execution, data -import/export, non-relational behavior, remaining builder operations and plugin -inventory, driver distribution, and per-dialect conformance are not +The native MySQL follow-up pins upstream `mysql_async 0.37.0` with Rustls and +routes MySQL connection testing, database/schema/table discovery, table preview, +and supported Console SELECT before Java lease acquisition. SELECT executes in +a MySQL read-only transaction and emits the existing typed retained-result wire +messages directly from Core. Parameters, CTE-first SELECT, locking/server-file +variants, and multi-statements fail before Java startup. Row and byte truncation +plus cancellation terminate the active MySQL connection through a separate +bounded control connection. The explicit `native-mysql-integration` target and +MySQL CI job use a deliberately missing Java executable and verify connection, +metadata, two-row preview, typed three-row Console output, one-row truncation, +active `SELECT SLEEP(30)` cancellation, retained paging, and dormant Java health. + +Runtime-tested: yes. On 2026-07-29 commits `81301c3`, `4199862`, and `6c74421` +passed 144 Core unit tests, strict Core all-target Clippy, formatting, Actionlint, +and a real MySQL 8.4 native product vertical. The broad Community compatibility +operations and other database types remain on the lazy Java/JDBC path. + +Stage 7 remains incomplete. Complete MySQL type conformance, native bind +parameters and CTE-first SELECT, script execution, data import/export, +non-relational behavior, remaining builder operations and plugin inventory, +driver distribution, and per-dialect conformance are not implemented. Before Stage 8 may produce any Object-form distribution containing Community From a3fc9b5e9e0fbc51812a1c42599854153bc3f079 Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 12:19:50 +0800 Subject: [PATCH 18/19] fix(mysql): preserve disabled-engine error precedence --- crates/chat2db-core/src/query.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/chat2db-core/src/query.rs b/crates/chat2db-core/src/query.rs index c0fb797..0c3b07f 100644 --- a/crates/chat2db-core/src/query.rs +++ b/crates/chat2db-core/src/query.rs @@ -112,6 +112,9 @@ impl Application { prepared: PreparedQuery, ) -> Result { let storage = self.require_storage()?; + if !self.inner.engine.is_configured() { + let _engine = self.require_engine().await?; + } let resolved = resolve_datasource_connection(&storage, &prepared.datasource_id).await?; let backend = if self.is_native_mysql_driver(&resolved.driver_id) && crate::native_mysql::is_native_read_candidate(&prepared.sql)? From 4d60778fa72d173e6556dd8cd7b0c0362fa8782c Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 12:26:45 +0800 Subject: [PATCH 19/19] docs(mysql): record full native verification --- README.md | 2 ++ docs/architecture.md | 4 +++- docs/stages.md | 6 ++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0c49aa2..499d361 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ the restored SQL successfully. On 2026-07-29 commits `81301c3`, `4199862`, and vertical covering native connection, database/schema/table discovery, preview, typed Console SELECT, row truncation, active-query cancellation, retained paging, and proof after every operation that Java remained dormant. +The complete repository `make verify` gate and the explicit real-MySQL +`native-mysql-integration` target also passed after the final compatibility fix. Stage 6 is complete. Web and desktop own the product runtime and publish its owner-only local endpoint; CLI and MCP attach to that host and never contact diff --git a/docs/architecture.md b/docs/architecture.md index 71c3973..3d8a1d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,9 @@ complete stored-datasource path passed against MySQL 8.4, from real Community identifier/DQL/page-limit generation through forced-read-only JDBC execution and retained-result paging. On 2026-07-29 the native MySQL path passed against MySQL 8.4 with a deliberately missing Java executable, including active-query -cancellation and dormant-Java assertions after every product operation. +cancellation and dormant-Java assertions after every product operation. The +complete repository `make verify` gate and an explicit real-MySQL rerun passed +after preserving the disabled-engine error contract. ## Ownership diff --git a/docs/stages.md b/docs/stages.md index 063bf38..ac134c0 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -332,8 +332,10 @@ active `SELECT SLEEP(30)` cancellation, retained paging, and dormant Java health Runtime-tested: yes. On 2026-07-29 commits `81301c3`, `4199862`, and `6c74421` passed 144 Core unit tests, strict Core all-target Clippy, formatting, Actionlint, -and a real MySQL 8.4 native product vertical. The broad Community compatibility -operations and other database types remain on the lazy Java/JDBC path. +the complete repository `make verify` gate, and a real MySQL 8.4 native product +vertical rerun after the final compatibility fix. The broad Community +compatibility operations and other database types remain on the lazy Java/JDBC +path. Stage 7 remains incomplete. Complete MySQL type conformance, native bind parameters and CTE-first SELECT, script execution, data import/export,