diff --git a/GEMINI.md b/.github/GEMINI.md similarity index 100% rename from GEMINI.md rename to .github/GEMINI.md diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index ac95286..c0c73f1 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -24,11 +24,6 @@ jobs: - name: Install Linux build and test dependencies run: sudo apt-get update && sudo apt-get install --yes clang cmake ninja-build pkg-config libgtk-3-dev libreoffice-calc xvfb - - name: Set up Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: stable - - name: Resolve locked DecentDB version id: decentdb-version shell: bash @@ -56,28 +51,19 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Check out DecentDB ${{ steps.decentdb-version.outputs.tag }} - run: git clone --depth 1 --branch "${{ steps.decentdb-version.outputs.tag }}" https://github.com/sphildreth/decentdb.git ../decentdb - - - name: Build libpg_query (Linux) - working-directory: ../decentdb + - name: Install pinned DecentDB native library shell: bash run: | - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - make -C /tmp/libpg_query - fi - mkdir -p build/lib - cp /tmp/libpg_query/libpg_query.a build/lib/ - - - name: Install DecentDB Nim dependencies - working-directory: ../decentdb - run: nimble setup -y - - - name: Build DecentDB native library - working-directory: ../decentdb - shell: bash - run: nimble build_lib + set -euo pipefail + tag="${{ steps.decentdb-version.outputs.tag }}" + asset="decentdb-dart-native-${tag}-Linux-x64.tar.gz" + url="https://github.com/sphildreth/decentdb/releases/download/${tag}/${asset}" + work_dir="$RUNNER_TEMP/decentdb-native" + archive_path="$RUNNER_TEMP/$asset" + mkdir -p "$work_dir" + curl -fsSL "$url" -o "$archive_path" + tar -xzf "$archive_path" -C "$work_dir" + sudo install -m 0644 "$work_dir/libdecentdb.so" /usr/local/lib/libdecentdb.so - name: Flutter pub get working-directory: apps/decent-bench @@ -93,7 +79,36 @@ jobs: - name: Flutter integration test working-directory: apps/decent-bench - run: xvfb-run -a flutter test integration_test + shell: bash + run: | + set -euo pipefail + display_file="$(mktemp)" + Xvfb -displayfd 3 -screen 0 1600x1000x24 -ac 3>"$display_file" >"$RUNNER_TEMP/xvfb.log" 2>&1 & + xvfb_pid=$! + cleanup() { + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + rm -f "$display_file" + } + trap cleanup EXIT + for _ in $(seq 1 100); do + if [ -s "$display_file" ]; then + break + fi + sleep 0.1 + done + export DISPLAY=":$(cat "$display_file")" + # Run integration tests; retry once on non-zero exit to handle the + # known Linux desktop shutdown crash where all test assertions pass + # but the binary exits non-zero during cleanup. + set +e + flutter test integration_test + it_exit=$? + set -e + if [ "$it_exit" -ne 0 ]; then + echo "::warning::Integration test exited with $it_exit on first attempt; retrying..." + flutter test integration_test + fi desktop-package-verify: runs-on: ${{ matrix.os }} @@ -104,15 +119,20 @@ jobs: - os: ubuntu-latest flutter_target: linux bundle_path: build/linux/x64/release/bundle - native_lib: ../../../decentdb/build/libdecentdb.so + decentdb_release_suffix: Linux-x64 + native_archive_extension: tar.gz + native_lib_name: libdecentdb.so - os: macos-latest flutter_target: macos bundle_path: build/macos/Build/Products/Release/decent_bench.app - native_lib: ../../../decentdb/build/libdecentdb.dylib + native_archive_extension: tar.gz + native_lib_name: libdecentdb.dylib - os: windows-latest flutter_target: windows bundle_path: build/windows/x64/runner/Release - native_lib: ../../../decentdb/build/c_api.dll + decentdb_release_suffix: Windows-x64 + native_archive_extension: zip + native_lib_name: decentdb.dll steps: - name: Check out Decent Bench @@ -128,11 +148,6 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install --yes clang cmake ninja-build pkg-config libgtk-3-dev - - name: Set up Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: stable - - name: Resolve locked DecentDB version id: decentdb-version shell: bash @@ -160,56 +175,42 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Check out DecentDB ${{ steps.decentdb-version.outputs.tag }} - run: git clone --depth 1 --branch "${{ steps.decentdb-version.outputs.tag }}" https://github.com/sphildreth/decentdb.git ../decentdb - - - name: Build libpg_query (Linux) - if: runner.os == 'Linux' - working-directory: ../decentdb - shell: bash - run: | - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - make -C /tmp/libpg_query - fi - mkdir -p build/lib - cp /tmp/libpg_query/libpg_query.a build/lib/ - - - name: Build libpg_query (macOS) - if: runner.os == 'macOS' - working-directory: ../decentdb + - name: Download DecentDB Dart-native asset (Linux/macOS) + if: runner.os != 'Windows' shell: bash run: | - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - make -C /tmp/libpg_query + set -euo pipefail + tag="${{ steps.decentdb-version.outputs.tag }}" + # Use the matrix-provided suffix when available; for macOS the + # suffix is derived at runtime from RUNNER_ARCH so the workflow + # stays correct if GitHub changes the runner architecture. + release_suffix="${{ matrix.decentdb_release_suffix }}" + if [[ -z "$release_suffix" ]]; then + arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')" + release_suffix="${RUNNER_OS}-${arch}" fi - mkdir -p build/lib - cp /tmp/libpg_query/libpg_query.a build/lib/ - - - name: Build libpg_query (Windows) + asset="decentdb-dart-native-${tag}-${release_suffix}.${{ matrix.native_archive_extension }}" + url="https://github.com/sphildreth/decentdb/releases/download/${tag}/${asset}" + work_dir="$RUNNER_TEMP/decentdb-native" + archive_path="$RUNNER_TEMP/$asset" + mkdir -p "$work_dir" + curl -fsSL "$url" -o "$archive_path" + tar -xzf "$archive_path" -C "$work_dir" + echo "DECENTDB_NATIVE_PATH=$work_dir/${{ matrix.native_lib_name }}" >> "$GITHUB_ENV" + + - name: Download DecentDB Dart-native asset (Windows) if: runner.os == 'Windows' - working-directory: ../decentdb - shell: bash + shell: pwsh run: | - repo_root="$PWD" - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - cd /tmp/libpg_query - make - fi - cd /tmp/libpg_query - mkdir -p "$repo_root/build/lib" - cp libpg_query.a "$repo_root/build/lib/" - - - name: Install DecentDB Nim dependencies - working-directory: ../decentdb - run: nimble setup -y - - - name: Build DecentDB native library - working-directory: ../decentdb - shell: bash - run: nimble build_lib + $tag = "${{ steps.decentdb-version.outputs.tag }}" + $asset = "decentdb-dart-native-$tag-${{ matrix.decentdb_release_suffix }}.${{ matrix.native_archive_extension }}" + $url = "https://github.com/sphildreth/decentdb/releases/download/$tag/$asset" + $workDir = Join-Path $env:RUNNER_TEMP "decentdb-native" + $archivePath = Join-Path $env:RUNNER_TEMP $asset + New-Item -ItemType Directory -Force $workDir | Out-Null + Invoke-WebRequest -Uri $url -OutFile $archivePath + Expand-Archive -Path $archivePath -DestinationPath $workDir -Force + Add-Content -Path $env:GITHUB_ENV -Value "DECENTDB_NATIVE_PATH=$workDir\\${{ matrix.native_lib_name }}" - name: Flutter pub get working-directory: apps/decent-bench @@ -249,7 +250,7 @@ jobs: - name: Stage DecentDB native library into bundle working-directory: apps/decent-bench - run: dart run tool/stage_decentdb_native.dart --bundle "${{ matrix.bundle_path }}" --source "${{ matrix.native_lib }}" + run: dart run tool/stage_decentdb_native.dart --bundle "${{ matrix.bundle_path }}" --source "${{ env.DECENTDB_NATIVE_PATH }}" - name: Verify bundled native library working-directory: apps/decent-bench diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad81d9c..3e673fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,20 @@ jobs: - os: ubuntu-latest flutter_target: linux bundle_path: build/linux/x64/release/bundle - native_lib: ../../../decentdb/build/libdecentdb.so + native_archive_extension: tar.gz + native_lib_name: libdecentdb.so archive_extension: tar.gz - os: macos-latest flutter_target: macos bundle_path: build/macos/Build/Products/Release/decent_bench.app - native_lib: ../../../decentdb/build/libdecentdb.dylib + native_archive_extension: tar.gz + native_lib_name: libdecentdb.dylib archive_extension: tar.gz - os: windows-latest flutter_target: windows bundle_path: build/windows/x64/runner/Release - native_lib: ../../../decentdb/build/decentdb.dll + native_archive_extension: zip + native_lib_name: decentdb.dll archive_extension: zip steps: @@ -45,15 +48,38 @@ jobs: channel: stable cache: true + - name: Resolve release platform metadata + id: release-meta + shell: bash + run: | + set -euo pipefail + + case "$RUNNER_ARCH" in + X64) arch_suffix=x64 ;; + ARM64) arch_suffix=arm64 ;; + *) + echo "Unsupported runner architecture: $RUNNER_ARCH" >&2 + exit 1 + ;; + esac + + case "$RUNNER_OS" in + Linux) decentdb_release_suffix="Linux-$arch_suffix" ;; + macOS) decentdb_release_suffix="macOS-$arch_suffix" ;; + Windows) decentdb_release_suffix="Windows-$arch_suffix" ;; + *) + echo "Unsupported runner OS: $RUNNER_OS" >&2 + exit 1 + ;; + esac + + echo "app_arch_suffix=$arch_suffix" >> "$GITHUB_OUTPUT" + echo "decentdb_release_suffix=$decentdb_release_suffix" >> "$GITHUB_OUTPUT" + - name: Install Linux desktop build dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install --yes clang cmake ninja-build pkg-config libgtk-3-dev - - name: Set up Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: stable - - name: Resolve locked DecentDB version id: decentdb-version shell: bash @@ -81,56 +107,34 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Check out DecentDB ${{ steps.decentdb-version.outputs.tag }} - run: git clone --depth 1 --branch "${{ steps.decentdb-version.outputs.tag }}" https://github.com/sphildreth/decentdb.git ../decentdb - - - name: Build libpg_query (Linux) - if: runner.os == 'Linux' - working-directory: ../decentdb - shell: bash - run: | - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - make -C /tmp/libpg_query - fi - mkdir -p build/lib - cp /tmp/libpg_query/libpg_query.a build/lib/ - - - name: Build libpg_query (macOS) - if: runner.os == 'macOS' - working-directory: ../decentdb + - name: Download DecentDB Dart-native asset (Linux/macOS) + if: runner.os != 'Windows' shell: bash run: | - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - make -C /tmp/libpg_query - fi - mkdir -p build/lib - cp /tmp/libpg_query/libpg_query.a build/lib/ - - - name: Build libpg_query (Windows) + set -euo pipefail + tag="${{ steps.decentdb-version.outputs.tag }}" + asset="decentdb-dart-native-${tag}-${{ steps.release-meta.outputs.decentdb_release_suffix }}.${{ matrix.native_archive_extension }}" + url="https://github.com/sphildreth/decentdb/releases/download/${tag}/${asset}" + work_dir="$RUNNER_TEMP/decentdb-native" + archive_path="$RUNNER_TEMP/$asset" + mkdir -p "$work_dir" + curl -fsSL "$url" -o "$archive_path" + tar -xzf "$archive_path" -C "$work_dir" + echo "DECENTDB_NATIVE_PATH=$work_dir/${{ matrix.native_lib_name }}" >> "$GITHUB_ENV" + + - name: Download DecentDB Dart-native asset (Windows) if: runner.os == 'Windows' - working-directory: ../decentdb - shell: bash + shell: pwsh run: | - repo_root="$PWD" - if [ ! -d "/tmp/libpg_query" ]; then - git clone --depth 1 https://github.com/pganalyze/libpg_query.git /tmp/libpg_query - cd /tmp/libpg_query - make - fi - cd /tmp/libpg_query - mkdir -p "$repo_root/build/lib" - cp libpg_query.a "$repo_root/build/lib/" - - - name: Install DecentDB Nim dependencies - working-directory: ../decentdb - run: nimble setup -y - - - name: Build DecentDB native library - working-directory: ../decentdb - shell: bash - run: nimble build_lib + $tag = "${{ steps.decentdb-version.outputs.tag }}" + $asset = "decentdb-dart-native-$tag-${{ steps.release-meta.outputs.decentdb_release_suffix }}.${{ matrix.native_archive_extension }}" + $url = "https://github.com/sphildreth/decentdb/releases/download/$tag/$asset" + $workDir = Join-Path $env:RUNNER_TEMP "decentdb-native" + $archivePath = Join-Path $env:RUNNER_TEMP $asset + New-Item -ItemType Directory -Force $workDir | Out-Null + Invoke-WebRequest -Uri $url -OutFile $archivePath + Expand-Archive -Path $archivePath -DestinationPath $workDir -Force + Add-Content -Path $env:GITHUB_ENV -Value "DECENTDB_NATIVE_PATH=$workDir\\${{ matrix.native_lib_name }}" - name: Flutter pub get working-directory: ${{ env.APP_DIR }} @@ -199,11 +203,11 @@ jobs: shell: bash run: | set -euo pipefail - echo "name=decent-bench-${GITHUB_REF_NAME}-${RUNNER_OS}-x64.${{ matrix.archive_extension }}" >> "$GITHUB_OUTPUT" + echo "name=decent-bench-${GITHUB_REF_NAME}-${RUNNER_OS}-${{ steps.release-meta.outputs.app_arch_suffix }}.${{ matrix.archive_extension }}" >> "$GITHUB_OUTPUT" - name: Stage DecentDB native library into bundle working-directory: ${{ env.APP_DIR }} - run: dart run tool/stage_decentdb_native.dart --bundle "${{ steps.bundle.outputs.path }}" --source "${{ matrix.native_lib }}" + run: dart run tool/stage_decentdb_native.dart --bundle "${{ steps.bundle.outputs.path }}" --source "${{ env.DECENTDB_NATIVE_PATH }}" - name: Verify bundled native library working-directory: ${{ env.APP_DIR }} @@ -216,7 +220,7 @@ jobs: run: | set -euo pipefail stage_root="$RUNNER_TEMP/release-stage" - package_root="decent-bench-${GITHUB_REF_NAME}-${RUNNER_OS}-x64" + package_root="decent-bench-${GITHUB_REF_NAME}-${RUNNER_OS}-${{ steps.release-meta.outputs.app_arch_suffix }}" stage_dir="$stage_root/$package_root" bundle_path="${{ steps.bundle.outputs.path }}" @@ -237,7 +241,7 @@ jobs: shell: pwsh run: | $stageRoot = Join-Path $env:RUNNER_TEMP "release-stage" - $packageRoot = "decent-bench-$env:GITHUB_REF_NAME-$env:RUNNER_OS-x64" + $packageRoot = "decent-bench-$env:GITHUB_REF_NAME-$env:RUNNER_OS-${{ steps.release-meta.outputs.app_arch_suffix }}" $stageDir = Join-Path $stageRoot $packageRoot $bundlePath = "${{ steps.bundle.outputs.path }}" @@ -253,6 +257,7 @@ jobs: with: name: release-${{ matrix.os }} path: ${{ env.APP_DIR }}/${{ steps.artifact.outputs.name }} + if-no-files-found: error release: name: Create GitHub Release @@ -272,7 +277,8 @@ jobs: tag_name: ${{ github.ref_name }} prerelease: ${{ contains(github.ref_name, '-') }} generate_release_notes: true + fail_on_unmatched_files: true files: | - artifacts/**/decent-bench-${{ github.ref_name }}-Linux-x64.tar.gz - artifacts/**/decent-bench-${{ github.ref_name }}-macOS-x64.tar.gz - artifacts/**/decent-bench-${{ github.ref_name }}-Windows-x64.zip + artifacts/**/decent-bench-${{ github.ref_name }}-Linux-*.tar.gz + artifacts/**/decent-bench-${{ github.ref_name }}-macOS-*.tar.gz + artifacts/**/decent-bench-${{ github.ref_name }}-Windows-*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 94beb32..f61bbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,38 @@ This file records notable project changes. It follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.0] - 2026-04-21 + +### Added + +- Added DecentDB native asset staging and improved native-library resolution so + desktop builds and packaged runners can acquire and load the engine more + reliably across environments. +- Added headless CLI import mode plus broader import detection for wrapped + inputs, including additional archive handling and initial MS SQL Server + backup (`.bak`) workflow scaffolding. +- Added `Tools -> View Log` to open the DecentDB-backed application log + database from inside the workspace. + +### Changed + +- Expanded import workflow coverage in the app, docs, ADRs, and tests to cover + the broader supported import scope and native/runtime packaging decisions. +- Improved SQLite import summaries to show richer validation detail, including + imported object counts, per-table row totals, target file size, and WAL + status after import finalization. + +### Fixed + +- Fixed SQLite import handling for high-precision timestamp text and mixed + date-like columns so large real-world databases such as Navidrome import + correctly. +- Fixed import finalization so successful imports checkpoint and flush their + WAL state before completion, avoiding misleading tiny database files paired + with large sidecar WAL files. +- Fixed import failure UX so failed imports stop the workflow, surface a clear + blocking error dialog with summary and details, and no longer look like a + successful completion. ## [1.0.0] - 2026-03-14 @@ -29,5 +60,6 @@ This file records notable project changes. It follows the metadata, bundled theme compatibility ranges, and project documentation with that release line. -[unreleased]: https://github.com/sphildreth/decent-bench/compare/v1.0.0...HEAD +[unreleased]: https://github.com/sphildreth/decent-bench/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/sphildreth/decent-bench/releases/tag/v1.1.0 [1.0.0]: https://github.com/sphildreth/decent-bench/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 2f6b74e..8b9ff67 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

Features • - Status • + StatusGetting StartedDeveloper OnboardingRoadmap • @@ -44,18 +44,17 @@ ## ✨ Features - 🚀 **DecentDB-First:** A fully local-first workflow. Fast open/create, recent files, and intuitive drag-and-drop support. -- 📥 **Smart Import Wizards:** Seamlessly import CSV, JSON, XML, HTML, SQLite, Excel, and SQL dumps (including `.zip`/`.gz` archives). Includes previews, rename/type-override transforms, progress reporting, and summary actions. +- 📥 **Smart Import Wizards:** Import delimited text, JSON/NDJSON, XML, HTML tables, Excel, SQLite, SQL dumps, and wrapped archives (`.zip`, `.gz`, `.bz2`). Includes previews, rename/type-override transforms, progress reporting, and post-import summaries. - 🛠️ **Modern SQL Workbench:** Iterate in a multi-tab editor with isolated per-tab results, schema-aware autocomplete, editable snippets, and deterministic formatting. - ⚡ **Performance-Focused:** Background imports, paginated/streamed results grids, and best-effort query cancellation ensure the UI never freezes. - 🧭 **Rich Engine Metadata:** Schema browsing is powered by DecentDB's rich upstream schema snapshot (tables/views/indexes/triggers, checks, foreign keys, generated columns, temp-object metadata, and canonical DDL). -- 🎨 **Workspace Persistence:** Config and app state are safely stored as TOML, providing reliable per-database workspace restoration. +- 🎨 **Workspace Persistence:** Application preferences are stored as TOML, and per-database workspace state is stored separately for reliable tab and query restoration. +- 🪵 **Operational Visibility:** Open the DecentDB-backed application log database directly from `Tools -> View Log`. +- 🧪 **Import Validation:** Blocking failure dialogs and richer import summaries make unsuccessful imports obvious and successful imports easier to verify. - 📦 **Desktop Native:** Packaged for Linux, macOS, and Windows with a repeatable native-library staging helper. -## 🚀 Status: v1.0.0 - -**Decent Bench has reached 1.0.0!** The core MVP loop is fully implemented and stable: import or open a database, inspect schema, run SQL, stream/page through results, and export to CSV. ### Supported File Types @@ -63,25 +62,44 @@ | --- | --- | --- | | `.ddb` | **Open directly** | Main DecentDB workspace format. | | `.db`, `.sqlite`, `.sqlite3` | **Import Wizard** | Background import with schema preview and table selection. | -| `.csv`, `.tsv`, `.txt`, `.dat`, `.psv` | **Import Wizard** | Delimited text import with header, delimiter, quoting, preview, and type overrides. | +| `.csv`, `.tsv` | **Import Wizard** | CSV/TSV import through the generic delimited-text pipeline. | +| `.txt`, `.dat`, `.log`, `.psv` | **Import Wizard** | Generic delimited-text import with header, delimiter, quoting, malformed-row, preview, and type-override controls. | | `.json`, `.ndjson`, `.jsonl` | **Import Wizard** | Structured and line-oriented JSON import with relational previews. | | `.xml` | **Import Wizard** | XML import with flatten or parent-child normalization strategies. | | `.html`, `.htm` | **Import Wizard** | HTML table extraction with table selection and header inference. | | `.xlsx` | **Import Wizard** | Select worksheets and map DecentDB types automatically. | -| `.sql` | **Import Wizard** | Supports common MariaDB/MySQL-style dumps (CREATE TABLE + INSERT). | -| `.zip`, `.gz` | **Unwrap & Import** | Archive wrappers that automatically unwrap supported files and route them to the import flow. | -| `.xls` | *Partial / Hint* | Legacy format. Handled with warnings or prompts to convert to `.xlsx` first. | +| `.xls` | *Partial / Warning Path* | Routed through the legacy Excel path and may require conversion/normalization warnings. | +| `.sql` | **Import Wizard** | Supports the current MariaDB/MySQL-style MVP-lite dump scope (`CREATE TABLE` plus common `INSERT ... VALUES`). | +| `.zip` | **Unwrap & Import** | Archive wrapper that discovers supported inner files and routes them to the normal import flow. | +| `.gz`, `.tar.gz`, `.tgz` | **Unwrap & Import** | Supports single-file gzip unwrap and tar+gzip archive inspection/extraction. | +| `.bz2`, `.tar.bz2`, `.tbz2` | **Unwrap & Import** | Supports single-file bzip2 unwrap and tar+bzip2 archive inspection/extraction. | +| `.bak` | **Environment-Gated / Scaffold** | Detected and routed to the MS SQL backup flow, which currently checks Docker availability and surfaces the planned container-assisted import path. | + +### Recognized But Not Yet Implemented + +The current build recognizes, but does not yet import, several formats and +wrappers including `.ods`, `.yaml`, `.yml`, `.toml`, `.md`, `.duckdb`, +`.mdb`, `.accdb`, `.dbf`, `.parquet`, `.pdf`, and `.xz`. ## 🚀 Getting Started (End Users) -*Binary releases for Linux, macOS, and Windows will be available on the [Releases](https://github.com/sphildreth/decent-bench/releases) page.* +*Binary releases for Linux, macOS, and Windows are listed on the [Releases](https://github.com/sphildreth/decent-bench/releases) page.* ### Command-line Launch -Packaged desktop builds expose a narrow CLI entry for import flows: +Packaged desktop builds expose a small CLI surface for direct-open and import +flows: ```bash +dbench /path/to/workspace.ddb dbench --import /path/to/source.xlsx +dbench --in /path/to/source.sqlite --out /tmp/import.ddb ``` -This reuses the drag-and-drop detection rules and instantly opens the right import wizard. + +- Passing a `.ddb` path opens that workspace directly. +- `--import` reuses drag-and-drop detection rules and opens the matching wizard. +- `--in` / `--out` run the shipped headless import path. +- `--silent` suppresses headless progress output. +- `--plan` is parsed but intentionally rejected for now; it is reserved for a + future plan-file execution flow. ## 💻 Developer Onboarding @@ -92,6 +110,14 @@ Want to build from source or contribute? Welcome! - **Git** - **Flutter** (stable, desktop tooling enabled) - OS-specific native toolchain (C++ compiler, etc.) +- `tar` on systems where you want tar+gzip or tar+bzip2 archive detection and extraction +- A matching **DecentDB native library** for the pinned app version + +Decent Bench pins the upstream Dart package by Git tag and expects the matching +DecentDB desktop native library alongside it. CI and release packaging resolve +that version from `apps/decent-bench/pubspec.lock` and download the matching +`decentdb-dart-native--...` asset from +[`sphildreth/decentdb` Releases](https://github.com/sphildreth/decentdb/releases). ### 1. Bootstrap the Flutter App ```bash @@ -99,33 +125,56 @@ cd apps/decent-bench flutter pub get ``` -### 2. Run Locally +### 2. Install the Matching DecentDB Native Library +The app and test tooling resolve the pinned `decentdb` tag from +`apps/decent-bench/pubspec.lock` and download the matching +`decentdb-dart-native--...` release asset from DecentDB Releases into a +local cache when needed. The app still prefers a bundled native library first, +then the cached pinned asset, then common system locations. + +### 3. Run Locally ```bash flutter run -d linux ``` -### 3. Testing & Validation +### 4. Testing & Validation ```bash flutter analyze flutter test flutter test integration_test ``` -### 4. Packaging Desktop Builds -Build the bundle, then use the staging helper to inject the DecentDB native library: +These commands will fetch the matching pinned DecentDB native library on first +use if it is not already cached. + +### 5. Packaging Desktop Builds +Build the bundle, then use the staging helper to inject the DecentDB native +library. The `--source` path can point at either an extracted +`decentdb-dart-native--...` release asset or a local DecentDB build: ```bash flutter build linux dart run tool/stage_decentdb_native.dart --bundle build/linux/x64/release/bundle ``` -*(For macOS use `build/macos/Build/Products/Release/decent_bench.app` and Windows `build/windows/x64/runner/Release`)*. +If `--source` is omitted, the helper resolves and downloads the pinned matching +DecentDB native release asset automatically. + +*(For macOS use `build/macos/Build/Products/Release/decent_bench.app` and +Windows `build/windows/x64/runner/Release`.)* ## 🏗️ Architecture & Configuration -The application stores global configuration and workspace state locally: +The application stores its local state under the platform app-support +directory: - **Linux:** `~/.config/decent-bench/` - **macOS:** `~/Library/Application Support/Decent Bench/` - **Windows:** `%APPDATA%\Decent Bench\` +Typical files under that root include: +- `config.toml` for application preferences +- per-workspace `.json` state files for saved tabs/history/restoration +- `decent-bench-log.ddb` for the application log database opened by + `Tools -> View Log` + **Project Source of Truth:** - 📐 [`design/PRD.md`](design/PRD.md) — Product goals and user journeys - 📝 [`design/SPEC.md`](design/SPEC.md) — Implementation scope (Authoritative) @@ -134,20 +183,24 @@ The application stores global configuration and workspace state locally: ## 🗺️ Roadmap -**Completed for 1.0.0:** +**Shipped through 1.1.0:** - ✅ Drag-and-drop open/import flows - ✅ Expansive import support: CSV, JSON, XML, HTML, SQLite, Excel, and SQL dumps -- ✅ ZIP & GZip archive wrappers for imports +- ✅ ZIP, GZip, and BZip2 wrapper routing for imports +- ✅ Headless CLI import mode +- ✅ DecentDB native asset staging and pinned runtime resolution +- ✅ In-app application log viewing +- ✅ Clear blocking import-failure dialogs and richer import summaries - ✅ Schema browsing and multi-tab SQL editing - ✅ Autocomplete, snippets, and deterministic formatter - ✅ Paged results, query cancellation, and CSV export -- ✅ TOML config and persistent per-database workspaces +- ✅ Local app config plus persistent per-database workspaces -**Coming Next (Post-1.0):** +**Coming Next (Post-1.1):** - 🔜 **Expanded Exports:** JSON, Parquet, and Excel formats, plus schema exports and reusable export recipes. -- 🔜 **New Database & Analytical Imports:** DuckDB, Parquet, PostgreSQL dumps, and legacy DBs (Access, DBF). +- 🔜 **New Database & Analytical Imports:** DuckDB, Parquet, broader PostgreSQL dump handling, and legacy DBs (Access, DBF). - 🔜 **New Document & Log Imports:** OpenDocument (`.ods`), YAML, Markdown/PDF tables, and continuous log streams. -- 🔜 **Advanced Import Capabilities:** Computed-column transforms and native legacy binary `.xls` parsing. +- 🔜 **Advanced Import Capabilities:** Computed-column transforms, native legacy binary `.xls` parsing, and full MS SQL `.bak` restore/extract execution. ## 🤝 Contributing diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9c8d321..3b0e92b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,8 @@ Apache 2.0 distribution. This file tracks attributions and license details. ## Dependencies - `decentdb` - - Version/source: local path dependency at `../decentdb/bindings/dart/dart` + - Version/source: Git dependency from `https://github.com/sphildreth/decentdb`, + path `bindings/dart/dart`, ref `v2.3.0` - License: Apache License 2.0 - Upstream project: `https://github.com/sphildreth/decentdb` @@ -44,3 +45,20 @@ Apache 2.0 distribution. This file tracks attributions and license details. - License: MIT - Copyright: Lukas Renggli - Source: `https://pub.dev/packages/xml` + +- `path` `^1.9.0` + - License: BSD-style license (Dart project) + - Copyright: Dart project authors + - Source: `https://pub.dev/packages/path` + - Note: `path` is a Dart SDK team package; attribution included for + completeness. + +## Transitive dependency notes + +The following transitive dependencies are brought in by direct dependencies. +Their licenses are compatible with Apache 2.0 distribution: + +- `archive` brings `crypto` (MIT, Dart project authors) and `convert` + (BSD-style, Dart project authors) +- `sqlite3` brings `collection` and `meta` (BSD-style, Dart project authors) +- `excel` brings `equatable` (MIT), `ffi` (BSD-style), and `recase` (MIT) diff --git a/apps/decent-bench/README.md b/apps/decent-bench/README.md index 346262b..1946c01 100644 --- a/apps/decent-bench/README.md +++ b/apps/decent-bench/README.md @@ -1,7 +1,7 @@ # Decent Bench App This directory contains the shipped Flutter desktop app source for Decent -Bench `1.0.0`, which is the project's MVP release. +Bench `1.1.0`, which builds on the project's shipped `1.0.0` MVP release. ## Current state @@ -18,8 +18,9 @@ Bench `1.0.0`, which is the project's MVP release. - CSV, TSV, generic delimited text, JSON, NDJSON/JSONL, XML, HTML tables, and ZIP/GZip wrapper routing now use the generic import preview/execution path - desktop runner folders (`linux/`, `macos/`, `windows/`) are checked in -- the DecentDB Dart package is consumed from GitHub releases - (`https://github.com/sphildreth/decentdb`) +- the DecentDB Dart package is pinned from the upstream Git tag + (`https://github.com/sphildreth/decentdb`), and desktop packaging stages the + matching `decentdb-dart-native--...` release asset - Excel import currently supports `.xlsx`; legacy `.xls` files route through the existing conversion/normalization path and remain explicitly partial - SQL dump import currently targets the MVP-lite parser scope documented in @@ -56,6 +57,11 @@ The app expects a compatible DecentDB v2.x native library to be available via: 1. System library paths (`/usr/local/lib/`, `~/.local/lib/`) 2. Bundled with the app +CI, local tests, and Linux desktop builds resolve the pinned `decentdb` tag from +`pubspec.lock` and use the matching `decentdb-dart-native--...` asset from +DecentDB Releases. You can still override the source library explicitly with +`tool/stage_decentdb_native.dart --source ` when needed. + Workspace tab drafts are stored separately from `config.toml` under the platform-specific `workspaces/` directory documented in the root [README.md](/home/steven/source/decent-bench/README.md). diff --git a/apps/decent-bench/integration_test/workspace_shell_test.dart b/apps/decent-bench/integration_test/workspace_shell_test.dart index 3aeaabd..bcb0749 100644 --- a/apps/decent-bench/integration_test/workspace_shell_test.dart +++ b/apps/decent-bench/integration_test/workspace_shell_test.dart @@ -6,6 +6,7 @@ import 'package:decent_bench/app/startup_launch_options.dart'; import 'package:archive/archive.dart'; import 'package:decent_bench/features/workspace/application/workspace_controller.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; @@ -139,10 +140,10 @@ void main() { final tempDir = await Directory.systemTemp.createTemp('decent-bench-it-'); final zipPath = p.join(tempDir.path, 'bundle.zip'); final archive = Archive() - ..addFile( - ArchiveFile('customers.csv', 14, 'id,name\n1,Ada\n'.codeUnits), - ); - await File(zipPath).writeAsBytes(ZipEncoder().encode(archive)!, flush: true); + ..addFile(ArchiveFile('customers.csv', 14, 'id,name\n1,Ada\n'.codeUnits)); + await File( + zipPath, + ).writeAsBytes(ZipEncoder().encode(archive)!, flush: true); tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1600, 1000); @@ -169,4 +170,213 @@ void main() { expect(find.text('ZIP Wrapper Contents'), findsOneWidget); expect(find.textContaining('customers.csv'), findsOneWidget); }); + + testWidgets('creates and switches between multiple editor tabs', ( + tester, + ) async { + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + controller.dispose(); + }); + + await controller.initialize(); + controller.createTab(sql: 'SELECT 1'); + controller.createTab(sql: 'SELECT 2'); + + await tester.pumpWidget( + DecentBenchApp( + controller: controller, + autoInitialize: false, + logger: const NoOpAppLogger(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Query'), findsAtLeastNWidgets(3)); + }); + + testWidgets('opens preferences dialog from Options menu', (tester) async { + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + final messenger = + TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger; + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + messenger.setMockMethodCallHandler(SystemChannels.menu, null); + controller.dispose(); + }); + + messenger.setMockMethodCallHandler(SystemChannels.menu, (call) async { + if (call.method == 'Menu.isPluginAvailable') { + return false; + } + return null; + }); + + await controller.initialize(); + await tester.pumpWidget( + DecentBenchApp( + controller: controller, + autoInitialize: false, + logger: const NoOpAppLogger(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Tools'), findsOneWidget); + + await tester.tap(find.text('Tools')); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.widgetWithText(MenuItemButton, 'Options / Preferences'), + findsOneWidget, + ); + + await tester.tap(find.widgetWithText(MenuItemButton, 'Options / Preferences')); + await tester.pumpAndSettle(); + + expect(find.text('Options / Preferences'), findsOneWidget); + expect(find.textContaining('Theme'), findsWidgets); + expect(find.textContaining('Editor'), findsWidgets); + }); + + testWidgets('launches JSON import wizard from startup options', ( + tester, + ) async { + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + final tempDir = await Directory.systemTemp.createTemp('decent-bench-it-'); + final jsonPath = p.join(tempDir.path, 'data.json'); + await File( + jsonPath, + ).writeAsString('[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Lin"}]'); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() async { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + controller.dispose(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + await controller.initialize(); + await tester.pumpWidget( + DecentBenchApp( + controller: controller, + autoInitialize: false, + logger: const NoOpAppLogger(), + startupLaunchOptions: StartupLaunchOptions(importSourcePath: jsonPath), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Import Wizard'), findsOneWidget); + expect(find.textContaining('Tables: 1'), findsOneWidget); + }); + + testWidgets('launches XML import wizard from startup options', ( + tester, + ) async { + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + final tempDir = await Directory.systemTemp.createTemp('decent-bench-it-'); + final xmlPath = p.join(tempDir.path, 'catalog.xml'); + await File(xmlPath).writeAsString( + '' + '1Widget' + '2Gadget', + ); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() async { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + controller.dispose(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + await controller.initialize(); + await tester.pumpWidget( + DecentBenchApp( + controller: controller, + autoInitialize: false, + logger: const NoOpAppLogger(), + startupLaunchOptions: StartupLaunchOptions(importSourcePath: xmlPath), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Import Wizard'), findsOneWidget); + }); + + testWidgets('launches HTML table import wizard from startup options', ( + tester, + ) async { + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + final tempDir = await Directory.systemTemp.createTemp('decent-bench-it-'); + final htmlPath = p.join(tempDir.path, 'report.html'); + await File(htmlPath).writeAsString( + '' + '
NameAge
Alice30
', + ); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() async { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + controller.dispose(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + await controller.initialize(); + await tester.pumpWidget( + DecentBenchApp( + controller: controller, + autoInitialize: false, + logger: const NoOpAppLogger(), + startupLaunchOptions: StartupLaunchOptions(importSourcePath: htmlPath), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Import Wizard'), findsOneWidget); + }); } diff --git a/apps/decent-bench/lib/app/app_metadata.dart b/apps/decent-bench/lib/app/app_metadata.dart index c2f7666..ac94c80 100644 --- a/apps/decent-bench/lib/app/app_metadata.dart +++ b/apps/decent-bench/lib/app/app_metadata.dart @@ -1,2 +1,2 @@ -const String kDecentBenchVersion = '1.0.0'; +const String kDecentBenchVersion = '1.1.0'; const String kDecentBenchDisplayName = 'Decent Bench'; diff --git a/apps/decent-bench/lib/app/logging/import_log_details.dart b/apps/decent-bench/lib/app/logging/import_log_details.dart index a2f08fa..f0475ed 100644 --- a/apps/decent-bench/lib/app/logging/import_log_details.dart +++ b/apps/decent-bench/lib/app/logging/import_log_details.dart @@ -139,6 +139,12 @@ Map buildSqliteImportSummaryLogDetails( extra: { 'status_message': summary.statusMessage, 'index_count': summary.indexesCreated.length, + 'target_table_count': summary.targetTableCount, + 'target_index_count': summary.targetIndexCount, + 'target_view_count': summary.targetViewCount, + 'target_trigger_count': summary.targetTriggerCount, + 'database_file_bytes': summary.databaseFileBytes, + 'wal_file_bytes': summary.walFileBytes, if (summary.indexesCreated.isNotEmpty) 'indexes_created': summary.indexesCreated, 'skipped_item_count': summary.skippedItems.length, diff --git a/apps/decent-bench/lib/features/import/domain/import_models.dart b/apps/decent-bench/lib/features/import/domain/import_models.dart index f83173a..92db377 100644 --- a/apps/decent-bench/lib/features/import/domain/import_models.dart +++ b/apps/decent-bench/lib/features/import/domain/import_models.dart @@ -56,6 +56,7 @@ enum ImportFormatKey { duckdb, access, dbf, + msSqlBak, sqlDump, postgresPlainDump, parquet, diff --git a/apps/decent-bench/lib/features/import/infrastructure/import_detection_service.dart b/apps/decent-bench/lib/features/import/infrastructure/import_detection_service.dart index 5546a44..b667376 100644 --- a/apps/decent-bench/lib/features/import/infrastructure/import_detection_service.dart +++ b/apps/decent-bench/lib/features/import/infrastructure/import_detection_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; @@ -31,6 +32,17 @@ class ImportDetectionService { ); } if (format.key == ImportFormatKey.gzipArchive) { + final ext = p.extension(sourcePath).toLowerCase(); + final innerName = p.basenameWithoutExtension(sourcePath); + if (ext == '.tgz' || _looksLikeTar(innerName)) { + return _detectTarCandidates( + sourcePath: sourcePath, + format: format, + innerName: innerName, + listArgs: ['-tzf', sourcePath], + extractFlag: '-xzf', + ); + } final candidate = await _detectGzipCandidate(sourcePath); return ImportDetectionResult( sourcePath: sourcePath, @@ -45,6 +57,33 @@ class ImportDetectionService { : [candidate], ); } + if (format.key == ImportFormatKey.bzip2Archive) { + final ext = p.extension(sourcePath).toLowerCase(); + final innerName = p.basenameWithoutExtension(sourcePath); + if (ext == '.tbz2' || _looksLikeTar(innerName)) { + return _detectTarCandidates( + sourcePath: sourcePath, + format: format, + innerName: innerName, + listArgs: ['-tjf', sourcePath], + extractFlag: '-xjf', + ); + } + final candidate = await _detectBzip2SingleFileCandidate(sourcePath); + return ImportDetectionResult( + sourcePath: sourcePath, + format: format, + warnings: candidate == null + ? [ + 'The BZip2 filename does not indicate a supported inner ' + 'source.', + ] + : warnings, + archiveCandidates: candidate == null + ? const [] + : [candidate], + ); + } if (format.key == ImportFormatKey.sqlite && file.existsSync()) { final header = await file .openRead(0, 16) @@ -55,17 +94,109 @@ class ImportDetectionService { final signature = String.fromCharCodes(header); if (!signature.startsWith('SQLite format 3')) { warnings.add( - 'The file uses a SQLite-like extension, but the header does not match the SQLite signature.', + 'The file uses a SQLite-like extension, but the header does not ' + 'match the SQLite signature.', + ); + } + } + return ImportDetectionResult( + sourcePath: sourcePath, + format: format, + warnings: warnings, + ); + } + + bool _looksLikeTar(String innerName) { + return innerName.toLowerCase().endsWith('.tar'); + } + + Future _detectTarCandidates({ + required String sourcePath, + required ImportFormatDefinition format, + required String innerName, + required List listArgs, + required String extractFlag, + }) async { + final warnings = []; + final candidates = []; + + try { + final result = await Process.run( + 'tar', + listArgs, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + warnings.add( + 'Failed to list tar archive contents: ' + '${(result.stderr as String).trim()}', + ); + return ImportDetectionResult( + sourcePath: sourcePath, + format: format, + warnings: warnings, + archiveCandidates: candidates, ); } + final lines = LineSplitter.split( + result.stdout as String, + ).map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); + for (final entryPath in lines) { + if (entryPath.endsWith('/')) { + continue; + } + final innerFormat = _registry.detectByPath(entryPath); + if (innerFormat.key != ImportFormatKey.unknown) { + candidates.add( + ImportArchiveCandidate( + entryPath: entryPath, + displayName: entryPath, + innerFormatKey: innerFormat.key, + innerFormatLabel: innerFormat.label, + supportState: innerFormat.supportState, + ), + ); + continue; + } + final inferred = _inferFormatForExtensionlessTarEntry(entryPath); + candidates.add( + ImportArchiveCandidate( + entryPath: entryPath, + displayName: entryPath, + innerFormatKey: inferred.key, + innerFormatLabel: inferred.label, + supportState: inferred.supportState, + ), + ); + } + } on ProcessException catch (error) { + warnings.add( + 'tar command unavailable for archive listing: ${error.message}', + ); + } + + if (candidates.isEmpty) { + warnings.add('No importable files were found inside the tar archive.'); } return ImportDetectionResult( sourcePath: sourcePath, format: format, warnings: warnings, + archiveCandidates: candidates, ); } + ImportFormatDefinition _inferFormatForExtensionlessTarEntry( + String entryPath, + ) { + final baseName = p.basename(entryPath); + if (baseName.contains('.')) { + return _registry.detectByPath(baseName); + } + return _registry.forKey(ImportFormatKey.tsv); + } + Future> _detectZipCandidates( String sourcePath, ) async { @@ -113,6 +244,23 @@ class ImportDetectionService { ); } + Future _detectBzip2SingleFileCandidate( + String sourcePath, + ) async { + final innerName = p.basenameWithoutExtension(sourcePath); + final innerFormat = _registry.detectByPath(innerName); + if (innerFormat.key == ImportFormatKey.unknown) { + return null; + } + return ImportArchiveCandidate( + entryPath: innerName, + displayName: innerName, + innerFormatKey: innerFormat.key, + innerFormatLabel: innerFormat.label, + supportState: innerFormat.supportState, + ); + } + Future extractArchiveCandidate({ required String archivePath, required ImportFormatKey wrapperKey, @@ -121,8 +269,10 @@ class ImportDetectionService { final tempDir = await Directory.systemTemp.createTemp( 'decent-bench-import-', ); - final outputPath = p.join(tempDir.path, p.basename(candidate.entryPath)); + final entryBaseName = p.basename(candidate.entryPath); + if (wrapperKey == ImportFormatKey.zipArchive) { + final outputPath = p.join(tempDir.path, entryBaseName); final archive = ZipDecoder().decodeBytes( await File(archivePath).readAsBytes(), ); @@ -136,10 +286,23 @@ class ImportDetectionService { } } throw StateError( - 'Archive entry `${candidate.entryPath}` was not found in $archivePath.', + 'Archive entry `${candidate.entryPath}` was not found in ' + '$archivePath.', ); } + if (wrapperKey == ImportFormatKey.gzipArchive) { + final ext = p.extension(archivePath).toLowerCase(); + final innerName = p.basenameWithoutExtension(archivePath); + if (ext == '.tgz' || _looksLikeTar(innerName)) { + return _extractTarEntry( + archivePath: archivePath, + tempDir: tempDir, + entryPath: candidate.entryPath, + extractFlag: '-xzf', + ); + } + final outputPath = p.join(tempDir.path, entryBaseName); final decoded = GZipDecoder().decodeBytes( await File(archivePath).readAsBytes(), ); @@ -148,6 +311,72 @@ class ImportDetectionService { output.writeAsBytesSync(decoded, flush: true); return output.path; } + + if (wrapperKey == ImportFormatKey.bzip2Archive) { + final ext = p.extension(archivePath).toLowerCase(); + final innerName = p.basenameWithoutExtension(archivePath); + if (ext == '.tbz2' || _looksLikeTar(innerName)) { + return _extractTarEntry( + archivePath: archivePath, + tempDir: tempDir, + entryPath: candidate.entryPath, + extractFlag: '-xjf', + ); + } + final outputPath = p.join(tempDir.path, entryBaseName); + final decoded = BZip2Decoder().decodeBytes( + await File(archivePath).readAsBytes(), + ); + final output = File(outputPath); + output.parent.createSync(recursive: true); + output.writeAsBytesSync(decoded, flush: true); + return output.path; + } + throw StateError('Unsupported wrapper extraction for ${wrapperKey.name}.'); } + + String _extractTarEntry({ + required String archivePath, + required Directory tempDir, + required String entryPath, + required String extractFlag, + }) { + final baseName = p.basename(entryPath); + final inferredFormat = _inferFormatForExtensionlessTarEntry(entryPath); + final extension = inferredFormat.extensions.isNotEmpty + ? inferredFormat.extensions.first + : ''; + + // Extract by the exact archive-relative path to avoid GNU tar-specific + // --no-anchored semantics and basename collisions across subdirectories. + final result = Process.runSync( + 'tar', + [extractFlag, archivePath, '-C', tempDir.path, entryPath], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + throw StateError( + 'Failed to extract `$entryPath` from tar archive: ' + '${(result.stderr as String).trim()}', + ); + } + + final extractedPath = p.join(tempDir.path, entryPath); + final extractedFile = File(extractedPath); + if (!extractedFile.existsSync()) { + throw StateError( + 'Extracted entry not found at expected path `$extractedPath`.', + ); + } + + if (extension.isNotEmpty && + !baseName.toLowerCase().endsWith(extension)) { + final renamed = '$extractedPath$extension'; + extractedFile.renameSync(renamed); + return renamed; + } + return extractedPath; + } } diff --git a/apps/decent-bench/lib/features/import/infrastructure/import_execution_service.dart b/apps/decent-bench/lib/features/import/infrastructure/import_execution_service.dart index 8e201ec..ee34192 100644 --- a/apps/decent-bench/lib/features/import/infrastructure/import_execution_service.dart +++ b/apps/decent-bench/lib/features/import/infrastructure/import_execution_service.dart @@ -375,6 +375,7 @@ Future _runGenericImport({ database.commit(); transactionOpen = false; + database.checkpoint(); return GenericImportSummary( jobId: request.jobId, sourcePath: request.sourcePath, diff --git a/apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart b/apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart index 3891890..a9a02f5 100644 --- a/apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart +++ b/apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart @@ -190,6 +190,15 @@ class ImportFormatRegistry { implementationKind: ImportImplementationKind.recognizedUnsupported, description: 'Legacy DBF database import.', ), + ImportFormatDefinition( + key: ImportFormatKey.msSqlBak, + label: 'MS SQL Server Backup', + family: ImportFamily.databaseDump, + supportState: ImportSupportState.investigate, + extensions: ['.bak'], + implementationKind: ImportImplementationKind.recognizedUnsupported, + description: 'Container-assisted MS SQL backup import (not yet implemented).', + ), ImportFormatDefinition( key: ImportFormatKey.sqlDump, label: 'SQL Dump', @@ -251,19 +260,22 @@ class ImportFormatRegistry { label: 'GZip Wrapper', family: ImportFamily.compressedArchive, supportState: ImportSupportState.complete, - extensions: ['.gz'], + extensions: ['.gz', '.tgz'], implementationKind: ImportImplementationKind.wrapper, description: 'Single-file wrapper that unwraps supported CSV/JSON/NDJSON/XML/HTML/SQL/Excel/SQLite files.', ), ImportFormatDefinition( key: ImportFormatKey.bzip2Archive, - label: 'BZip2 Wrapper', + label: 'BZip2 / Tar+BZip2 Wrapper', family: ImportFamily.compressedArchive, - supportState: ImportSupportState.investigate, - extensions: ['.bz2'], - implementationKind: ImportImplementationKind.recognizedUnsupported, - description: 'BZip2 compressed wrapper support.', + supportState: ImportSupportState.complete, + extensions: ['.bz2', '.tbz2'], + implementationKind: ImportImplementationKind.wrapper, + description: + 'BZip2 wrapper for single-file decompression and tar+bzip2 ' + 'archive extraction. Uses the system tar command for large ' + 'tar archives.', ), ImportFormatDefinition( key: ImportFormatKey.xzArchive, diff --git a/apps/decent-bench/lib/features/import/presentation/generic_import_dialog.dart b/apps/decent-bench/lib/features/import/presentation/generic_import_dialog.dart index 476bf1b..4bce85f 100644 --- a/apps/decent-bench/lib/features/import/presentation/generic_import_dialog.dart +++ b/apps/decent-bench/lib/features/import/presentation/generic_import_dialog.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:decent_bench/shared/widgets/import_failure_dialog.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; @@ -79,6 +80,7 @@ class _GenericImportDialogState extends State { bool _replaceExistingTarget = true; String? _focusedTableId; String? _lastSuggestedTargetPath; + String? _shownFailureMessage; int _durationToNanos(Duration duration) => duration.inMicroseconds * 1000; @@ -165,6 +167,7 @@ class _GenericImportDialogState extends State { @override Widget build(BuildContext context) { + _maybeShowFailureDialog(); return AlertDialog( title: Text('${widget.initialFormat.label} Import Wizard'), content: SizedBox( @@ -200,6 +203,34 @@ class _GenericImportDialogState extends State { ); } + void _maybeShowFailureDialog() { + if (_phase != GenericImportJobPhase.failed || + _step != GenericImportWizardStep.summary || + _error == null) { + _shownFailureMessage = null; + return; + } + if (_shownFailureMessage == _error) { + return; + } + _shownFailureMessage = _error; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _error == null) { + return; + } + showImportFailureDialog( + context: context, + title: '${widget.initialFormat.label} import failed', + message: _error!, + onAcknowledged: () { + if (mounted) { + Navigator.of(context).pop(); + } + }, + ); + }); + } + Widget _buildStepHeader() { return Wrap( spacing: 8, @@ -807,9 +838,17 @@ class _GenericImportDialogState extends State { Widget _buildSummaryStep() { if (_summary == null) { - return const _EmptyState( - title: 'No summary yet', - message: 'Run the import to populate the summary view.', + return _EmptyState( + title: _phase == GenericImportJobPhase.failed + ? 'Import failed' + : _phase == GenericImportJobPhase.cancelled + ? 'Import cancelled' + : 'No summary yet', + message: + _error ?? + (_phase == GenericImportJobPhase.cancelled + ? 'The import was cancelled before a summary was produced.' + : 'Run the import to populate the summary view.'), ); } return ListView( @@ -907,7 +946,7 @@ class _GenericImportDialogState extends State { GenericImportWizardStep.preview => 'Next', GenericImportWizardStep.transforms => 'Next', GenericImportWizardStep.execute => 'Run Import', - GenericImportWizardStep.summary => 'Done', + GenericImportWizardStep.summary => _summary == null ? 'Close' : 'Done', }; } @@ -955,7 +994,11 @@ class _GenericImportDialogState extends State { await _runImport(); break; case GenericImportWizardStep.summary: - if (!mounted || _summary == null) { + if (!mounted) { + return; + } + if (_summary == null) { + Navigator.of(context).pop(); return; } Navigator.of(context).pop( @@ -1199,6 +1242,7 @@ class _GenericImportDialogState extends State { _step = GenericImportWizardStep.summary; }); case GenericImportUpdateKind.failed: + final message = update.message ?? 'The import failed.'; _logError( 'run_generic_import', 'Generic import failed.', @@ -1210,7 +1254,7 @@ class _GenericImportDialogState extends State { 'format_key': request.formatKey.name, 'format_label': widget.initialFormat.label, 'selected_table_count': request.selectedTables.length, - 'message': update.message, + 'message': message, }, ); if (!mounted) { @@ -1218,7 +1262,8 @@ class _GenericImportDialogState extends State { } setState(() { _phase = GenericImportJobPhase.failed; - _error = update.message ?? 'The import failed.'; + _step = GenericImportWizardStep.summary; + _error = message; }); } }); diff --git a/apps/decent-bench/lib/features/import/utils/docker_cli.dart b/apps/decent-bench/lib/features/import/utils/docker_cli.dart new file mode 100644 index 0000000..11b84a9 --- /dev/null +++ b/apps/decent-bench/lib/features/import/utils/docker_cli.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +class DockerCli { + static Future isDockerAvailable() async { + try { + final result = await Process.run('docker', ['info']); + return result.exitCode == 0; + } catch (_) { + return false; + } + } +} diff --git a/apps/decent-bench/lib/features/workspace/application/workspace_controller.dart b/apps/decent-bench/lib/features/workspace/application/workspace_controller.dart index 450cdbd..02866b3 100644 --- a/apps/decent-bench/lib/features/workspace/application/workspace_controller.dart +++ b/apps/decent-bench/lib/features/workspace/application/workspace_controller.dart @@ -382,6 +382,11 @@ class WorkspaceController extends ChangeNotifier { } } + Future openLogDatabase() async { + await _logger.initialize(minimumLevel: config.logging.verbosity); + await openDatabase(_logger.logDatabasePath, createIfMissing: false); + } + Future refreshSchema({bool showLoadingState = true}) async { if (!hasOpenDatabase) { return; @@ -1842,11 +1847,14 @@ class WorkspaceController extends ChangeNotifier { ); break; case ExcelImportUpdateKind.failed: + final message = update.message ?? 'Excel import failed.'; excelImportSession = current.copyWith( step: ExcelImportWizardStep.summary, phase: ExcelImportJobPhase.failed, - error: update.message ?? 'Excel import failed.', + error: message, ); + workspaceError = message; + workspaceMessage = null; _logError( 'run_excel_import', 'Excel import failed.', @@ -1857,7 +1865,7 @@ class WorkspaceController extends ChangeNotifier { 'source_path': current.sourcePath, 'target_path': current.targetPath, 'selected_sheet_count': current.selectedSheets.length, - 'message': update.message, + 'message': message, }, ); break; @@ -2328,11 +2336,14 @@ class WorkspaceController extends ChangeNotifier { ); break; case SqlDumpImportUpdateKind.failed: + final message = update.message ?? 'SQL dump import failed.'; sqlDumpImportSession = current.copyWith( step: SqlDumpImportWizardStep.summary, phase: SqlDumpImportJobPhase.failed, - error: update.message ?? 'SQL dump import failed.', + error: message, ); + workspaceError = message; + workspaceMessage = null; _logError( 'run_sql_dump_import', 'SQL dump import failed.', @@ -2343,7 +2354,7 @@ class WorkspaceController extends ChangeNotifier { 'source_path': current.sourcePath, 'target_path': current.targetPath, 'selected_table_count': current.selectedTables.length, - 'message': update.message, + 'message': message, }, ); break; @@ -2844,11 +2855,14 @@ class WorkspaceController extends ChangeNotifier { ); break; case SqliteImportUpdateKind.failed: + final message = update.message ?? 'SQLite import failed.'; sqliteImportSession = current.copyWith( step: SqliteImportWizardStep.summary, phase: SqliteImportJobPhase.failed, - error: update.message ?? 'SQLite import failed.', + error: message, ); + workspaceError = message; + workspaceMessage = null; _logError( 'run_sqlite_import', 'SQLite import failed.', @@ -2859,7 +2873,7 @@ class WorkspaceController extends ChangeNotifier { 'source_path': current.sourcePath, 'target_path': current.targetPath, 'selected_table_count': current.selectedTables.length, - 'message': update.message, + 'message': message, }, ); break; @@ -3362,6 +3376,10 @@ class WorkspaceController extends ChangeNotifier { error: message, phase: phase ?? session.phase, ); + if ((phase ?? session.phase) == SqlDumpImportJobPhase.failed) { + workspaceError = message; + workspaceMessage = null; + } _safeNotify(); _logError( 'sql_dump_import_error', @@ -3387,6 +3405,10 @@ class WorkspaceController extends ChangeNotifier { error: message, phase: phase ?? session.phase, ); + if ((phase ?? session.phase) == ExcelImportJobPhase.failed) { + workspaceError = message; + workspaceMessage = null; + } _safeNotify(); _logError( 'excel_import_error', @@ -3412,6 +3434,10 @@ class WorkspaceController extends ChangeNotifier { error: message, phase: phase ?? session.phase, ); + if ((phase ?? session.phase) == SqliteImportJobPhase.failed) { + workspaceError = message; + workspaceMessage = null; + } _safeNotify(); _logError( 'sqlite_import_error', diff --git a/apps/decent-bench/lib/features/workspace/domain/sqlite_import_models.dart b/apps/decent-bench/lib/features/workspace/domain/sqlite_import_models.dart index 59d68aa..2f3e126 100644 --- a/apps/decent-bench/lib/features/workspace/domain/sqlite_import_models.dart +++ b/apps/decent-bench/lib/features/workspace/domain/sqlite_import_models.dart @@ -579,6 +579,12 @@ class SqliteImportSummary { required this.importedTables, required this.rowsCopiedByTable, required this.indexesCreated, + required this.targetTableCount, + required this.targetIndexCount, + required this.targetViewCount, + required this.targetTriggerCount, + required this.databaseFileBytes, + required this.walFileBytes, required this.skippedItems, required this.warnings, required this.statusMessage, @@ -591,6 +597,12 @@ class SqliteImportSummary { final List importedTables; final Map rowsCopiedByTable; final List indexesCreated; + final int targetTableCount; + final int targetIndexCount; + final int targetViewCount; + final int targetTriggerCount; + final int databaseFileBytes; + final int walFileBytes; final List skippedItems; final List warnings; final String statusMessage; @@ -610,6 +622,12 @@ class SqliteImportSummary { 'importedTables': importedTables, 'rowsCopiedByTable': rowsCopiedByTable, 'indexesCreated': indexesCreated, + 'targetTableCount': targetTableCount, + 'targetIndexCount': targetIndexCount, + 'targetViewCount': targetViewCount, + 'targetTriggerCount': targetTriggerCount, + 'databaseFileBytes': databaseFileBytes, + 'walFileBytes': walFileBytes, 'skippedItems': >[ for (final item in skippedItems) item.toMap(), ], @@ -631,6 +649,12 @@ class SqliteImportSummary { .map((key, value) => MapEntry(key as String, value as int)), indexesCreated: ((map['indexesCreated'] as List?) ?? const []) .cast(), + targetTableCount: map['targetTableCount']! as int, + targetIndexCount: map['targetIndexCount']! as int, + targetViewCount: map['targetViewCount']! as int, + targetTriggerCount: map['targetTriggerCount']! as int, + databaseFileBytes: map['databaseFileBytes']! as int, + walFileBytes: map['walFileBytes']! as int, skippedItems: ((map['skippedItems'] as List?) ?? const []) .cast>() .map( diff --git a/apps/decent-bench/lib/features/workspace/infrastructure/decentdb_native_release_asset.dart b/apps/decent-bench/lib/features/workspace/infrastructure/decentdb_native_release_asset.dart new file mode 100644 index 0000000..cff4662 --- /dev/null +++ b/apps/decent-bench/lib/features/workspace/infrastructure/decentdb_native_release_asset.dart @@ -0,0 +1,439 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +enum DecentDbNativeAssetPlatform { linux, macos, windows } + +class DecentDbNativeReleaseAsset { + const DecentDbNativeReleaseAsset._({ + required this.projectDirectoryPath, + required this.tag, + required this.platform, + required this.releaseSuffix, + required this.archiveExtension, + required this.libraryFileName, + }); + + final String projectDirectoryPath; + final String tag; + final DecentDbNativeAssetPlatform platform; + final String releaseSuffix; + final String archiveExtension; + final String libraryFileName; + + String get cacheDirectoryPath => p.join( + projectDirectoryPath, + '.dart_tool', + 'decentdb', + 'native', + tag, + releaseSuffix, + ); + + String get libraryPath => p.join(cacheDirectoryPath, libraryFileName); + + static Future ensureAvailableForCurrentProject({ + String? startPath, + }) async { + final asset = locate(startPath: startPath); + return asset.ensureAvailable(); + } + + static DecentDbNativeReleaseAsset locate({ + String? startPath, + DecentDbNativeAssetPlatform? platform, + }) { + final projectDirectoryPath = _findProjectDirectory( + startPath ?? Directory.current.path, + ); + if (projectDirectoryPath == null) { + throw StateError( + 'Unable to locate pubspec.lock while resolving the pinned DecentDB release asset.', + ); + } + final lockFile = File(p.join(projectDirectoryPath, 'pubspec.lock')); + final tag = parsePinnedTagFromPubspecLock(lockFile.readAsStringSync()); + if (tag == null) { + throw StateError( + 'Unable to determine the pinned DecentDB tag from ${lockFile.path}.', + ); + } + return _fromResolvedTag( + projectDirectoryPath: projectDirectoryPath, + tag: tag, + platform: platform ?? _detectCurrentPlatform(), + ); + } + + static String? parsePinnedTagFromPubspecLock(String contents) { + var insideDecentDb = false; + String? version; + for (final line in const LineSplitter().convert(contents)) { + if (line.startsWith(' decentdb:')) { + insideDecentDb = true; + continue; + } + if (insideDecentDb && line.startsWith(' ') && !line.startsWith(' ')) { + break; + } + if (!insideDecentDb) { + continue; + } + final trimmed = line.trimLeft(); + if (trimmed.startsWith('ref: ')) { + final ref = _unquote(trimmed.substring(5).trim()); + if (ref.isNotEmpty) { + return ref; + } + } + if (trimmed.startsWith('version: ')) { + version = _unquote(trimmed.substring(9).trim()); + } + } + if (version == null || version.isEmpty) { + return null; + } + return version.startsWith('v') ? version : 'v$version'; + } + + static Iterable cachedLibraryCandidates({ + required Iterable searchRoots, + required DecentDbNativeAssetPlatform platform, + }) sync* { + final seen = {}; + for (final root in searchRoots) { + final projectDirectoryPath = _findProjectDirectory(root); + if (projectDirectoryPath == null) { + continue; + } + final lockFile = File(p.join(projectDirectoryPath, 'pubspec.lock')); + if (!lockFile.existsSync()) { + continue; + } + final tag = parsePinnedTagFromPubspecLock(lockFile.readAsStringSync()); + if (tag == null) { + continue; + } + final candidate = _fromResolvedTag( + projectDirectoryPath: projectDirectoryPath, + tag: tag, + platform: platform, + ).libraryPath; + if (seen.add(candidate)) { + yield candidate; + } + } + } + + Future ensureAvailable() async { + final libraryFile = File(libraryPath); + if (libraryFile.existsSync()) { + return libraryFile.path; + } + + await Directory(cacheDirectoryPath).create(recursive: true); + final lockFile = File('$libraryPath.lock'); + final lockHandle = await _waitForLock(lockFile, libraryFile); + if (lockHandle == null) { + return libraryFile.path; + } + try { + if (libraryFile.existsSync()) { + return libraryFile.path; + } + + final download = await _resolveDownload(); + final archiveBytes = await _downloadArchive(download.downloadUri); + final libraryBytes = _extractLibraryBytes(archiveBytes); + final tempPath = '$libraryPath.download'; + final tempFile = File(tempPath); + await tempFile.writeAsBytes(libraryBytes, flush: true); + if (libraryFile.existsSync()) { + await libraryFile.delete(); + } + await tempFile.rename(libraryFile.path); + return libraryFile.path; + } finally { + await lockHandle.unlock(); + await lockHandle.close(); + } + } + + static Future _waitForLock( + File lockFile, + File libraryFile, + ) async { + for (var attempt = 0; attempt < 50; attempt++) { + RandomAccessFile? handle; + try { + handle = await lockFile.open(mode: FileMode.write); + await handle.lock(); + return handle; + } on FileSystemException { + await handle?.close(); + if (libraryFile.existsSync()) { + return null; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } + throw StateError( + 'Timed out while waiting for the DecentDB native asset lock at ${lockFile.path}.', + ); + } + + Uint8List _extractLibraryBytes(Uint8List archiveBytes) { + final archive = switch (archiveExtension) { + 'tar.gz' => TarDecoder().decodeBytes( + GZipDecoder().decodeBytes(archiveBytes), + ), + 'zip' => ZipDecoder().decodeBytes(archiveBytes), + _ => throw UnsupportedError( + 'Unsupported archive extension: $archiveExtension', + ), + }; + + for (final file in archive.files) { + if (!file.isFile || p.basename(file.name) != libraryFileName) { + continue; + } + final content = file.content; + if (content is Uint8List) { + return content; + } + if (content is List) { + return Uint8List.fromList(content); + } + throw StateError( + 'Unexpected archive content type for ${file.name}: ${content.runtimeType}', + ); + } + + throw StateError( + 'Downloaded a DecentDB release asset for $tag but did not find $libraryFileName in the archive.', + ); + } + + Future _resolveDownload() async { + final metadata = await _fetchReleaseMetadata(tag); + return selectDownload( + metadata: metadata, + tag: tag, + releaseSuffix: releaseSuffix, + archiveExtension: archiveExtension, + ); + } + + static DecentDbNativeReleaseDownload selectDownload({ + required Map metadata, + required String tag, + required String releaseSuffix, + required String archiveExtension, + }) { + final rawAssets = metadata['assets']; + if (rawAssets is! List) { + throw StateError( + 'Release metadata for $tag did not include an asset list.', + ); + } + final suffix = '-$releaseSuffix.$archiveExtension'; + final matches = + rawAssets + .whereType>() + .map(DecentDbNativeReleaseDownload.fromJson) + .where( + (asset) => + asset.name.endsWith(suffix) && + asset.name.startsWith('decentdb-') && + !asset.name.startsWith('decentdb-jdbc-') && + !asset.name.startsWith('decentdb-dbeaver-'), + ) + .toList() + ..sort((left, right) { + final leftPriority = left.name.contains('dart-native') ? 0 : 1; + final rightPriority = right.name.contains('dart-native') ? 0 : 1; + final priorityCompare = leftPriority.compareTo(rightPriority); + if (priorityCompare != 0) { + return priorityCompare; + } + return left.name.compareTo(right.name); + }); + + if (matches.isEmpty) { + final assetNames = + rawAssets + .whereType>() + .map((asset) => asset['name']) + .whereType() + .toList() + ..sort(); + throw StateError( + 'No DecentDB release asset matched $tag / $releaseSuffix.$archiveExtension. ' + 'Available assets: ${assetNames.join(', ')}', + ); + } + + return matches.first; + } + + static Future> _fetchReleaseMetadata(String tag) async { + final responseBytes = await _downloadArchive( + Uri.parse( + 'https://api.github.com/repos/sphildreth/decentdb/releases/tags/$tag', + ), + requestJson: true, + ); + final decoded = jsonDecode(utf8.decode(responseBytes)); + if (decoded is! Map) { + throw StateError('Unexpected GitHub release metadata payload for $tag.'); + } + return decoded; + } + + static Future _downloadArchive( + Uri uri, { + bool requestJson = false, + }) async { + final client = HttpClient(); + try { + final request = await client.getUrl(uri); + if (requestJson) { + request.headers.set( + HttpHeaders.acceptHeader, + 'application/vnd.github+json', + ); + final token = Platform.environment['GITHUB_TOKEN']; + if (token != null && token.isNotEmpty) { + request.headers.set( + HttpHeaders.authorizationHeader, + 'Bearer $token', + ); + } + } + request.headers.set( + HttpHeaders.userAgentHeader, + 'decent-bench-native-asset', + ); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException( + 'Failed to download $uri (HTTP ${response.statusCode})', + uri: uri, + ); + } + final bytes = BytesBuilder(copy: false); + await for (final chunk in response) { + bytes.add(chunk); + } + return bytes.takeBytes(); + } finally { + client.close(force: true); + } + } + + static DecentDbNativeReleaseAsset _fromResolvedTag({ + required String projectDirectoryPath, + required String tag, + required DecentDbNativeAssetPlatform platform, + }) { + final abi = Abi.current(); + switch (platform) { + case DecentDbNativeAssetPlatform.linux: + return DecentDbNativeReleaseAsset._( + projectDirectoryPath: projectDirectoryPath, + tag: tag, + platform: platform, + releaseSuffix: switch (abi) { + Abi.linuxArm64 => 'Linux-arm64', + _ => 'Linux-x64', + }, + archiveExtension: 'tar.gz', + libraryFileName: 'libdecentdb.so', + ); + case DecentDbNativeAssetPlatform.macos: + return DecentDbNativeReleaseAsset._( + projectDirectoryPath: projectDirectoryPath, + tag: tag, + platform: platform, + releaseSuffix: switch (abi) { + Abi.macosX64 => 'macOS-x64', + _ => 'macOS-arm64', + }, + archiveExtension: 'tar.gz', + libraryFileName: 'libdecentdb.dylib', + ); + case DecentDbNativeAssetPlatform.windows: + return DecentDbNativeReleaseAsset._( + projectDirectoryPath: projectDirectoryPath, + tag: tag, + platform: platform, + releaseSuffix: switch (abi) { + Abi.windowsArm64 => 'Windows-arm64', + _ => 'Windows-x64', + }, + archiveExtension: 'zip', + libraryFileName: 'decentdb.dll', + ); + } + } + + static DecentDbNativeAssetPlatform _detectCurrentPlatform() { + final abi = Abi.current(); + return switch (abi) { + Abi.linuxX64 || Abi.linuxArm64 => DecentDbNativeAssetPlatform.linux, + Abi.macosX64 || Abi.macosArm64 => DecentDbNativeAssetPlatform.macos, + Abi.windowsX64 || Abi.windowsArm64 => DecentDbNativeAssetPlatform.windows, + _ => throw UnsupportedError('Unsupported ABI for DecentDB assets: $abi'), + }; + } + + static String? _findProjectDirectory(String startPath) { + var current = Directory(startPath).absolute; + while (true) { + final lockFile = File(p.join(current.path, 'pubspec.lock')); + if (lockFile.existsSync()) { + return current.path; + } + final parent = current.parent; + if (parent.path == current.path) { + return null; + } + current = parent; + } + } + + static String _unquote(String value) { + if (value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")))) { + return value.substring(1, value.length - 1); + } + return value; + } +} + +class DecentDbNativeReleaseDownload { + const DecentDbNativeReleaseDownload({ + required this.name, + required this.downloadUri, + }); + + final String name; + final Uri downloadUri; + + factory DecentDbNativeReleaseDownload.fromJson(Map json) { + final name = json['name']; + final downloadUrl = json['browser_download_url']; + if (name is! String || downloadUrl is! String) { + throw StateError('Release asset metadata is missing required fields.'); + } + return DecentDbNativeReleaseDownload( + name: name, + downloadUri: Uri.parse(downloadUrl), + ); + } +} diff --git a/apps/decent-bench/lib/features/workspace/infrastructure/excel_import_support.dart b/apps/decent-bench/lib/features/workspace/infrastructure/excel_import_support.dart index 298e1fb..bb4a027 100644 --- a/apps/decent-bench/lib/features/workspace/infrastructure/excel_import_support.dart +++ b/apps/decent-bench/lib/features/workspace/infrastructure/excel_import_support.dart @@ -424,6 +424,7 @@ Future _runExcelImport({ target.commit(); transactionOpen = false; + target.checkpoint(); return ExcelImportSummary( jobId: request.jobId, diff --git a/apps/decent-bench/lib/features/workspace/infrastructure/native_library_resolver.dart b/apps/decent-bench/lib/features/workspace/infrastructure/native_library_resolver.dart index 650784b..d3ddc0a 100644 --- a/apps/decent-bench/lib/features/workspace/infrastructure/native_library_resolver.dart +++ b/apps/decent-bench/lib/features/workspace/infrastructure/native_library_resolver.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'decentdb_native_release_asset.dart'; + enum NativeLibraryPlatform { linux, macos, windows } enum NativeLibraryResolutionMode { runtime, packagingSource } @@ -138,6 +140,7 @@ class NativeLibraryResolver { if (mode == NativeLibraryResolutionMode.runtime) { candidates.addAll(_bundleCandidates()); } + candidates.addAll(_cachedAssetCandidates()); candidates.addAll(_searchFrom(_currentDirectoryPath)); candidates.addAll(_searchFrom(_scriptDirectoryPath)); return _dedupePaths(candidates); @@ -155,6 +158,17 @@ class NativeLibraryResolver { } } + Iterable _cachedAssetCandidates() sync* { + yield* DecentDbNativeReleaseAsset.cachedLibraryCandidates( + searchRoots: [_currentDirectoryPath, _scriptDirectoryPath], + platform: switch (_platform) { + NativeLibraryPlatform.linux => DecentDbNativeAssetPlatform.linux, + NativeLibraryPlatform.macos => DecentDbNativeAssetPlatform.macos, + NativeLibraryPlatform.windows => DecentDbNativeAssetPlatform.windows, + }, + ); + } + Iterable _searchFrom(String startPath) sync* { var current = Directory(startPath).absolute; for (var i = 0; i < 8; i++) { diff --git a/apps/decent-bench/lib/features/workspace/infrastructure/sql_dump_import_support.dart b/apps/decent-bench/lib/features/workspace/infrastructure/sql_dump_import_support.dart index e67df79..851172c 100644 --- a/apps/decent-bench/lib/features/workspace/infrastructure/sql_dump_import_support.dart +++ b/apps/decent-bench/lib/features/workspace/infrastructure/sql_dump_import_support.dart @@ -421,6 +421,7 @@ Future _runSqlDumpImport({ target.commit(); transactionOpen = false; + target.checkpoint(); return SqlDumpImportSummary( jobId: request.jobId, diff --git a/apps/decent-bench/lib/features/workspace/infrastructure/sqlite_import_support.dart b/apps/decent-bench/lib/features/workspace/infrastructure/sqlite_import_support.dart index 1f1f885..31c0980 100644 --- a/apps/decent-bench/lib/features/workspace/infrastructure/sqlite_import_support.dart +++ b/apps/decent-bench/lib/features/workspace/infrastructure/sqlite_import_support.dart @@ -205,7 +205,10 @@ Future _runSqliteImport({ request.sourcePath, mode: sqlite.OpenMode.readOnly, ); - final target = Database.open(request.targetPath, libraryPath: libraryPath); + Database? target = Database.open( + request.targetPath, + libraryPath: libraryPath, + ); var transactionOpen = false; final rowsCopied = {}; final indexesCreated = []; @@ -322,6 +325,25 @@ Future _runSqliteImport({ target.commit(); transactionOpen = false; + target.checkpoint(); + final schema = target.schema; + final storage = target.inspectStorageState(); + final targetTableCount = schema.listTables().length; + final targetIndexCount = schema.listIndexes().length; + final targetViewCount = schema.listViews().length; + final targetTriggerCount = schema.listTriggers().length; + final walFile = File(storage.walPath); + target.close(); + target = null; + if (!request.importIntoExistingTarget && + storage.activeReaders == 0 && + walFile.existsSync()) { + walFile.deleteSync(); + } + final databaseFileBytes = targetFile.existsSync() + ? targetFile.lengthSync() + : 0; + final walFileBytes = walFile.existsSync() ? walFile.lengthSync() : 0; return SqliteImportSummary( jobId: request.jobId, @@ -330,6 +352,12 @@ Future _runSqliteImport({ importedTables: orderedTables.map((table) => table.targetName).toList(), rowsCopiedByTable: rowsCopied, indexesCreated: indexesCreated, + targetTableCount: targetTableCount, + targetIndexCount: targetIndexCount, + targetViewCount: targetViewCount, + targetTriggerCount: targetTriggerCount, + databaseFileBytes: databaseFileBytes, + walFileBytes: walFileBytes, skippedItems: skippedItems, warnings: warnings, statusMessage: @@ -339,7 +367,7 @@ Future _runSqliteImport({ } on _SqliteImportCancelledSignal { if (transactionOpen) { try { - target.rollback(); + target?.rollback(); } catch (_) { // Best-effort rollback for cancellation. } @@ -351,6 +379,12 @@ Future _runSqliteImport({ importedTables: rowsCopied.keys.toList(), rowsCopiedByTable: rowsCopied, indexesCreated: indexesCreated, + targetTableCount: 0, + targetIndexCount: 0, + targetViewCount: 0, + targetTriggerCount: 0, + databaseFileBytes: 0, + walFileBytes: 0, skippedItems: skippedItems, warnings: warnings, statusMessage: 'SQLite import cancelled and rolled back.', @@ -360,14 +394,14 @@ Future _runSqliteImport({ } catch (_) { if (transactionOpen) { try { - target.rollback(); + target?.rollback(); } catch (_) { // Best-effort rollback on failure. } } rethrow; } finally { - target.close(); + target?.close(); source.close(); } } @@ -1778,7 +1812,7 @@ Object? _adaptImportValue(Object? value, String targetType) { return Uint8List.fromList(value.codeUnits); } if (targetType == 'TIMESTAMP') { - return _tryParseTimestampValue(value) ?? value; + return tryParseSqliteTimestampValue(value) ?? value; } if (_isDecimalType(targetType) && value is num) { return value.toString(); @@ -1824,6 +1858,10 @@ Map _inferColumnTypesFromSamples( for (final row in rows) row[column.sourceName], ]; final inferredType = _inferColumnTypeFromSamples(column, sampledValues); + if (inferredType == 'TIMESTAMP' && + !_columnContainsOnlyTimestampLikeValues(database, tableName, column)) { + continue; + } if (inferredType != null && inferredType != column.inferredTargetType) { inferredColumns[column.sourceName] = inferredType; } @@ -1831,6 +1869,35 @@ Map _inferColumnTypesFromSamples( return inferredColumns; } +bool _columnContainsOnlyTimestampLikeValues( + sqlite.Database database, + String tableName, + SqliteImportColumnDraft column, { + int maxRows = 1000, +}) { + final quotedTable = _quoteSqliteIdent(tableName); + final quotedColumn = _quoteSqliteIdent(column.sourceName); + final statement = database.prepare( + 'SELECT $quotedColumn AS value FROM $quotedTable' + ' WHERE $quotedColumn IS NOT NULL LIMIT $maxRows', + ); + try { + final cursor = statement.selectCursor(); + while (cursor.moveNext()) { + if (tryParseSqliteTimestampValue( + cursor.current['value'], + columnName: column.sourceName, + ) == + null) { + return false; + } + } + return true; + } finally { + statement.close(); + } +} + bool _canInferColumnTypeFromSamples(SqliteImportColumnDraft column) { return column.inferredTargetType == 'TEXT' || column.inferredTargetType == 'INTEGER'; @@ -1958,11 +2025,12 @@ bool _allSampledValuesAreTimestamps( return false; } return nonNullValues.every( - (value) => _tryParseTimestampValue(value, columnName: columnName) != null, + (value) => + tryParseSqliteTimestampValue(value, columnName: columnName) != null, ); } -DateTime? _tryParseTimestampValue(Object? value, {String? columnName}) { +DateTime? tryParseSqliteTimestampValue(Object? value, {String? columnName}) { if (value is DateTime) { return value.toUtc(); } @@ -1981,9 +2049,9 @@ DateTime? _tryParseTimestampValue(Object? value, {String? columnName}) { return null; } - final parsed = DateTime.tryParse(trimmed); + final parsed = _tryParseIsoTimestampValue(trimmed); if (parsed != null) { - return parsed.toUtc(); + return parsed; } final slashParsed = _tryParseSlashDateTime(trimmed); @@ -2008,6 +2076,52 @@ DateTime? _tryParseTimestampValue(Object? value, {String? columnName}) { return _tryParseEpochTimestamp(asInteger, columnName: columnName); } +DateTime? _tryParseIsoTimestampValue(String value) { + final parsed = DateTime.tryParse(value); + if (parsed != null) { + return parsed.toUtc(); + } + + final normalized = _normalizeIsoTimestampValue(value); + if (normalized == null) { + return null; + } + + final normalizedParsed = DateTime.tryParse(normalized); + return normalizedParsed?.toUtc(); +} + +String? _normalizeIsoTimestampValue(String value) { + final match = RegExp( + r'^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.(\d{1,}))?((?:[zZ]|[+-]\d{2}:?\d{2})?)$', + ).firstMatch(value); + if (match == null) { + return null; + } + + final rawFraction = match.group(4); + if (rawFraction == null || rawFraction.length <= 6) { + return null; + } + + final normalizedOffset = _normalizeIsoTimestampOffset(match.group(5) ?? ''); + return '${match.group(1)}T${match.group(2)}.${rawFraction.substring(0, 6)}$normalizedOffset'; +} + +String _normalizeIsoTimestampOffset(String offset) { + if (offset.isEmpty) { + return ''; + } + if (_equalsIgnoreCase(offset, 'z')) { + return 'Z'; + } + if (offset.length == 5 && + (offset.startsWith('+') || offset.startsWith('-'))) { + return '${offset.substring(0, 3)}:${offset.substring(3)}'; + } + return offset; +} + DateTime? _tryParseTimeOnlyDateTime(String value) { final match = RegExp( r'^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?$', diff --git a/apps/decent-bench/lib/features/workspace/presentation/excel_import_dialog.dart b/apps/decent-bench/lib/features/workspace/presentation/excel_import_dialog.dart index 0ac6848..7d3e71b 100644 --- a/apps/decent-bench/lib/features/workspace/presentation/excel_import_dialog.dart +++ b/apps/decent-bench/lib/features/workspace/presentation/excel_import_dialog.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; +import 'package:decent_bench/shared/widgets/import_failure_dialog.dart'; import '../application/workspace_controller.dart'; import '../domain/excel_import_models.dart'; @@ -23,6 +24,7 @@ class _ExcelImportDialogState extends State { TextEditingController(); late final TextEditingController _targetPathController = TextEditingController(); + String? _shownFailureMessage; @override void dispose() { @@ -41,6 +43,7 @@ class _ExcelImportDialogState extends State { return const SizedBox.shrink(); } _syncControllers(session); + _maybeShowFailureDialog(session); return AlertDialog( title: const Text('Excel Import Wizard'), @@ -79,6 +82,35 @@ class _ExcelImportDialogState extends State { ); } + void _maybeShowFailureDialog(ExcelImportSession session) { + if (session.phase != ExcelImportJobPhase.failed || + session.step != ExcelImportWizardStep.summary || + session.error == null) { + _shownFailureMessage = null; + return; + } + if (_shownFailureMessage == session.error) { + return; + } + _shownFailureMessage = session.error; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + showImportFailureDialog( + context: context, + title: 'Excel import failed', + message: session.error!, + onAcknowledged: () { + widget.controller.closeExcelImportSession(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + ); + }); + } + Widget _buildStepHeader(ExcelImportSession session) { return Wrap( spacing: 8, @@ -392,9 +424,17 @@ class _ExcelImportDialogState extends State { Widget _buildSummaryStep(ExcelImportSession session) { final summary = session.summary; if (summary == null) { - return const _DialogEmptyState( - title: 'No summary available', - message: 'Run an Excel import to populate the summary view.', + return _DialogEmptyState( + title: session.phase == ExcelImportJobPhase.failed + ? 'Import failed' + : session.phase == ExcelImportJobPhase.cancelled + ? 'Import cancelled' + : 'Import summary unavailable', + message: + session.error ?? + (session.phase == ExcelImportJobPhase.cancelled + ? 'The Excel import was cancelled before a summary was produced.' + : 'Run an Excel import to populate the summary view.'), ); } diff --git a/apps/decent-bench/lib/features/workspace/presentation/ms_sql_bak_import_dialog.dart b/apps/decent-bench/lib/features/workspace/presentation/ms_sql_bak_import_dialog.dart new file mode 100644 index 0000000..9aa4ef2 --- /dev/null +++ b/apps/decent-bench/lib/features/workspace/presentation/ms_sql_bak_import_dialog.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../import/utils/docker_cli.dart'; +import '../application/workspace_controller.dart'; + +class MsSqlBakImportDialog extends StatefulWidget { + const MsSqlBakImportDialog({ + super.key, + required this.controller, + required this.sourcePath, + }); + + final WorkspaceController controller; + final String sourcePath; + + @override + State createState() => _MsSqlBakImportDialogState(); +} + +class _MsSqlBakImportDialogState extends State { + bool _isCheckingDocker = true; + bool _isDockerAvailable = false; + + @override + void initState() { + super.initState(); + _checkDocker(); + } + + Future _checkDocker() async { + final available = await DockerCli.isDockerAvailable(); + if (mounted) { + setState(() { + _isDockerAvailable = available; + _isCheckingDocker = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isCheckingDocker) { + return const AlertDialog( + title: Text('Checking Environment'), + content: SizedBox( + height: 100, + child: Center(child: CircularProgressContainer()), + ), + ); + } + + if (!_isDockerAvailable) { + return AlertDialog( + title: const Text('Docker Required'), + content: const Text( + 'Importing MS SQL Server Backup (.bak) files requires Docker Desktop ' + 'to be installed and running on your system. We use a temporary container ' + 'running the official Microsoft SQL Server engine to safely restore and ' + 'extract your data.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } + + return AlertDialog( + title: const Text('Import MS SQL Backup'), + content: const SizedBox( + width: 600, + child: Text( + 'Docker is available. The container orchestration is planned for the next iteration (ADR-0027).', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } +} + +class CircularProgressContainer extends StatelessWidget { + const CircularProgressContainer({super.key}); + + @override + Widget build(BuildContext context) { + return const CircularProgressIndicator(); + } +} diff --git a/apps/decent-bench/lib/features/workspace/presentation/shell/app_menu_bar.dart b/apps/decent-bench/lib/features/workspace/presentation/shell/app_menu_bar.dart index 128900e..a282f57 100644 --- a/apps/decent-bench/lib/features/workspace/presentation/shell/app_menu_bar.dart +++ b/apps/decent-bench/lib/features/workspace/presentation/shell/app_menu_bar.dart @@ -152,6 +152,7 @@ class NativeAppMenuHost extends StatelessWidget { ), PlatformMenuItemGroup( members: [ + _platformCommandItem('tools_view_log'), _platformCommandItem('tools_query_history'), _platformCommandItem('tools_snippets'), _platformCommandItem('tools_manage_connections'), @@ -320,6 +321,7 @@ class AppMenuBar extends StatelessWidget { _commandItem('tools_format_sql'), _commandItem('tools_new_query_tab'), const Divider(height: 1), + _commandItem('tools_view_log'), _commandItem('tools_query_history'), _commandItem('tools_snippets'), _commandItem('tools_manage_connections'), diff --git a/apps/decent-bench/lib/features/workspace/presentation/sql_dump_import_dialog.dart b/apps/decent-bench/lib/features/workspace/presentation/sql_dump_import_dialog.dart index 4c9b450..ea8492d 100644 --- a/apps/decent-bench/lib/features/workspace/presentation/sql_dump_import_dialog.dart +++ b/apps/decent-bench/lib/features/workspace/presentation/sql_dump_import_dialog.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; +import 'package:decent_bench/shared/widgets/import_failure_dialog.dart'; import '../application/workspace_controller.dart'; import '../domain/import_target_types.dart'; @@ -23,6 +24,7 @@ class _SqlDumpImportDialogState extends State { TextEditingController(); late final TextEditingController _targetPathController = TextEditingController(); + String? _shownFailureMessage; @override void dispose() { @@ -41,6 +43,7 @@ class _SqlDumpImportDialogState extends State { return const SizedBox.shrink(); } _syncControllers(session); + _maybeShowFailureDialog(session); return AlertDialog( title: const Text('SQL Dump Import Wizard'), @@ -80,6 +83,35 @@ class _SqlDumpImportDialogState extends State { ); } + void _maybeShowFailureDialog(SqlDumpImportSession session) { + if (session.phase != SqlDumpImportJobPhase.failed || + session.step != SqlDumpImportWizardStep.summary || + session.error == null) { + _shownFailureMessage = null; + return; + } + if (_shownFailureMessage == session.error) { + return; + } + _shownFailureMessage = session.error; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + showImportFailureDialog( + context: context, + title: 'SQL dump import failed', + message: session.error!, + onAcknowledged: () { + widget.controller.closeSqlDumpImportSession(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + ); + }); + } + Widget _buildStepHeader(SqlDumpImportSession session) { return Wrap( spacing: 8, @@ -420,9 +452,17 @@ class _SqlDumpImportDialogState extends State { Widget _buildSummaryStep(SqlDumpImportSession session) { final summary = session.summary; if (summary == null) { - return const _DialogEmptyState( - title: 'No summary available', - message: 'Run a SQL dump import to populate the summary view.', + return _DialogEmptyState( + title: session.phase == SqlDumpImportJobPhase.failed + ? 'Import failed' + : session.phase == SqlDumpImportJobPhase.cancelled + ? 'Import cancelled' + : 'Import summary unavailable', + message: + session.error ?? + (session.phase == SqlDumpImportJobPhase.cancelled + ? 'The SQL dump import was cancelled before a summary was produced.' + : 'Run a SQL dump import to populate the summary view.'), ); } diff --git a/apps/decent-bench/lib/features/workspace/presentation/sqlite_import_dialog.dart b/apps/decent-bench/lib/features/workspace/presentation/sqlite_import_dialog.dart index f1a3c33..6c29a84 100644 --- a/apps/decent-bench/lib/features/workspace/presentation/sqlite_import_dialog.dart +++ b/apps/decent-bench/lib/features/workspace/presentation/sqlite_import_dialog.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; +import 'package:decent_bench/shared/widgets/import_failure_dialog.dart'; import '../application/workspace_controller.dart'; import '../domain/import_target_types.dart'; @@ -23,6 +24,7 @@ class _SqliteImportDialogState extends State { TextEditingController(); late final TextEditingController _targetPathController = TextEditingController(); + String? _shownFailureMessage; @override void dispose() { @@ -41,6 +43,7 @@ class _SqliteImportDialogState extends State { return const SizedBox.shrink(); } _syncControllers(session); + _maybeShowFailureDialog(session); return AlertDialog( title: const Text('SQLite Import Wizard'), @@ -79,6 +82,35 @@ class _SqliteImportDialogState extends State { ); } + void _maybeShowFailureDialog(SqliteImportSession session) { + if (session.phase != SqliteImportJobPhase.failed || + session.step != SqliteImportWizardStep.summary || + session.error == null) { + _shownFailureMessage = null; + return; + } + if (_shownFailureMessage == session.error) { + return; + } + _shownFailureMessage = session.error; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + showImportFailureDialog( + context: context, + title: 'SQLite import failed', + message: session.error!, + onAcknowledged: () { + widget.controller.closeSqliteImportSession(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + ); + }); + } + Widget _buildStepHeader(SqliteImportSession session) { return Wrap( spacing: 8, @@ -409,9 +441,58 @@ class _SqliteImportDialogState extends State { ('Imported tables', '${summary.importedTables.length}'), ('Rows copied', '${summary.totalRowsCopied}'), ('Indexes created', '${summary.indexesCreated.length}'), + ('Target tables', '${summary.targetTableCount}'), + ('Target indexes', '${summary.targetIndexCount}'), + ('Target views', '${summary.targetViewCount}'), + ('Target triggers', '${summary.targetTriggerCount}'), + ('Database size', _formatByteCount(summary.databaseFileBytes)), + ( + 'WAL size after checkpoint', + _formatByteCount(summary.walFileBytes), + ), ('Rolled back', summary.rolledBack ? 'Yes' : 'No'), ], ), + const SizedBox(height: 16), + Text( + 'Imported tables', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + if (summary.importedTables.isEmpty) + const Text('No tables were imported.') + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final table in summary.importedTables) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + '$table | ${summary.rowsCopiedByTable[table] ?? 0} rows', + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Indexes created', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + if (summary.indexesCreated.isEmpty) + const Text('No indexes were created.') + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final index in summary.indexesCreated) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text(index), + ), + ], + ), if (summary.warnings.isNotEmpty) ...[ const SizedBox(height: 16), Text('Warnings', style: Theme.of(context).textTheme.titleSmall), @@ -442,6 +523,22 @@ class _SqliteImportDialogState extends State { ); } + String _formatByteCount(int bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + var value = bytes.toDouble(); + var unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + final precision = unitIndex == 0 + ? 0 + : value >= 10 + ? 1 + : 2; + return '${value.toStringAsFixed(precision)} ${units[unitIndex]}'; + } + Widget _buildTableList(SqliteImportSession session) { if (session.tables.isEmpty) { return const _DialogEmptyState( diff --git a/apps/decent-bench/lib/features/workspace/presentation/workspace_screen.dart b/apps/decent-bench/lib/features/workspace/presentation/workspace_screen.dart index 07f5fdd..c0b9eb9 100644 --- a/apps/decent-bench/lib/features/workspace/presentation/workspace_screen.dart +++ b/apps/decent-bench/lib/features/workspace/presentation/workspace_screen.dart @@ -31,6 +31,7 @@ import '../infrastructure/app_lifecycle_service.dart'; import '../infrastructure/shortcut_config_service.dart'; import 'excel_import_dialog.dart'; import 'export_results_csv_dialog.dart'; +import 'ms_sql_bak_import_dialog.dart'; import 'preferences_dialog.dart'; import 'shell/app_menu_bar.dart'; import 'shell/command_toolbar.dart'; @@ -1907,6 +1908,12 @@ class _WorkspaceScreenState extends State { icon: Icons.history_outlined, onInvoke: _showQueryHistoryDialog, ), + command( + id: 'tools_view_log', + label: 'View Log', + icon: Icons.receipt_long_outlined, + onInvoke: controller.openLogDatabase, + ), command( id: 'tools_snippets', label: 'Manage Snippets', @@ -2025,6 +2032,17 @@ class _WorkspaceScreenState extends State { controller.closeSqlDumpImportSession(); } + Future _showMsSqlBakImportDialog({String sourcePath = ''}) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => MsSqlBakImportDialog( + controller: widget.controller, + sourcePath: sourcePath, + ), + ); + } + Future _showGenericImportDialog({ required String sourcePath, required ImportFormatDefinition format, @@ -2121,6 +2139,9 @@ class _WorkspaceScreenState extends State { case ImportFormatKey.sqlDump: await _showSqlDumpImportDialog(sourcePath: path); break; + case ImportFormatKey.msSqlBak: + await _showMsSqlBakImportDialog(sourcePath: path); + break; default: await _showPlaceholderNotice( 'Import unavailable', @@ -2150,7 +2171,7 @@ class _WorkspaceScreenState extends State { case ImportImplementationKind.unknown: await _showPlaceholderNotice( 'Unknown file type', - 'Supported import sources currently include `.csv`, `.tsv`, `.txt`, `.json`, `.jsonl`, `.ndjson`, `.xml`, `.html`, `.db`/`.sqlite`/`.sqlite3`, `.xls`/`.xlsx`, `.sql`, `.zip`, and `.gz`.', + 'Supported import sources currently include `.csv`, `.tsv`, `.txt`, `.json`, `.jsonl`, `.ndjson`, `.xml`, `.html`, `.db`/`.sqlite`/`.sqlite3`, `.xls`/`.xlsx`, `.sql`, `.zip`, `.gz`, and `.bz2` (including `.tar.bz2` and `.tar.gz` archives).', ); break; } diff --git a/apps/decent-bench/lib/shared/widgets/import_failure_dialog.dart b/apps/decent-bench/lib/shared/widgets/import_failure_dialog.dart new file mode 100644 index 0000000..6fe3255 --- /dev/null +++ b/apps/decent-bench/lib/shared/widgets/import_failure_dialog.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +String summarizeImportFailure(String message) { + final normalizedLines = message + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty); + return normalizedLines.isEmpty ? 'The import failed.' : normalizedLines.first; +} + +Future showImportFailureDialog({ + required BuildContext context, + required String title, + required String message, + required VoidCallback onAcknowledged, +}) { + final summary = summarizeImportFailure(message); + return showGeneralDialog( + context: context, + barrierDismissible: false, + barrierLabel: 'Import failure', + transitionDuration: Duration.zero, + pageBuilder: (dialogContext, _, _) { + final theme = Theme.of(dialogContext); + final colorScheme = theme.colorScheme; + return AlertDialog( + title: Text(title), + content: SizedBox( + width: 720, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Summary', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + summary, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ), + const SizedBox(height: 16), + Text('Details', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: SelectableText(message), + ), + ), + ), + ], + ), + ), + actions: [ + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + onAcknowledged(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); +} diff --git a/apps/decent-bench/linux/CMakeLists.txt b/apps/decent-bench/linux/CMakeLists.txt index 98cda39..0638d41 100644 --- a/apps/decent-bench/linux/CMakeLists.txt +++ b/apps/decent-bench/linux/CMakeLists.txt @@ -151,3 +151,14 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() + +install(CODE " + execute_process( + COMMAND \"${DART_EXECUTABLE}\" run tool/stage_decentdb_native.dart --bundle \"${CMAKE_INSTALL_PREFIX}\" + WORKING_DIRECTORY \"${HEADLESS_IMPORT_PROJECT_DIR}\" + RESULT_VARIABLE stage_decentdb_native_result + ) + if(NOT stage_decentdb_native_result EQUAL 0) + message(FATAL_ERROR \"Failed to stage the DecentDB native library into the Linux bundle.\") + endif() + " COMPONENT Runtime) diff --git a/apps/decent-bench/linux/runner/main.cc b/apps/decent-bench/linux/runner/main.cc index b4a6dc4..84a9e29 100644 --- a/apps/decent-bench/linux/runner/main.cc +++ b/apps/decent-bench/linux/runner/main.cc @@ -9,7 +9,7 @@ namespace { constexpr const char *kHelpText = - "Decent Bench 1.0.0\n" + "Decent Bench 1.1.0\n" "\n" "Usage:\n" " dbench\n" @@ -131,7 +131,7 @@ int main(int argc, char **argv) { return 0; } if (HasArg(argc, argv, "-v", "--version")) { - std::cout << "Decent Bench 1.0.0" << std::endl; + std::cout << "Decent Bench 1.1.0" << std::endl; return 0; } if (HasHeadlessArg(argc, argv)) { diff --git a/apps/decent-bench/pubspec.lock b/apps/decent-bench/pubspec.lock index 552b6c9..4e059ed 100644 --- a/apps/decent-bench/pubspec.lock +++ b/apps/decent-bench/pubspec.lock @@ -85,11 +85,11 @@ packages: dependency: "direct main" description: path: "bindings/dart/dart" - ref: "v2.2.1" - resolved-ref: "7168eccd2c88bc6fa61684019e325ebc0957ea28" + ref: "v2.3.0" + resolved-ref: "2adfd725a4cd3951e95802f3af513b9185616db3" url: "https://github.com/sphildreth/decentdb" source: git - version: "2.2.1" + version: "2.3.0" desktop_drop: dependency: "direct main" description: diff --git a/apps/decent-bench/pubspec.yaml b/apps/decent-bench/pubspec.yaml index c83ab7f..e17471a 100644 --- a/apps/decent-bench/pubspec.yaml +++ b/apps/decent-bench/pubspec.yaml @@ -1,7 +1,7 @@ name: decent_bench description: Decent Bench Flutter desktop app. publish_to: none -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: ^3.10.0 @@ -13,7 +13,7 @@ dependencies: git: url: https://github.com/sphildreth/decentdb path: bindings/dart/dart - ref: v2.2.1 + ref: v2.3.0 path: ^1.9.0 sqlite3: ^3.2.0 excel: ^4.0.6 diff --git a/apps/decent-bench/test/app/headless_import_runner_test.dart b/apps/decent-bench/test/app/headless_import_runner_test.dart index 90d30b9..1553c13 100644 --- a/apps/decent-bench/test/app/headless_import_runner_test.dart +++ b/apps/decent-bench/test/app/headless_import_runner_test.dart @@ -3,12 +3,27 @@ import 'dart:io'; import 'package:decent_bench/app/headless_import_runner.dart'; import 'package:decent_bench/app/startup_launch_options.dart'; +import 'package:decent_bench/features/workspace/infrastructure/decentdb_native_release_asset.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { late Directory tempDir; + setUpAll(() async { + // Only attempt a network download when explicitly requested via env var. + // In offline or local developer environments the native library is expected + // to be pre-installed (e.g. via the CI workflow or the tool/stage_decentdb_native.dart + // script). Set DECENT_BENCH_DOWNLOAD_NATIVE=1 to enable the download. + final downloadNative = + Platform.environment['DECENT_BENCH_DOWNLOAD_NATIVE'] == '1'; + if (downloadNative) { + await DecentDbNativeReleaseAsset.ensureAvailableForCurrentProject( + startPath: Directory.current.path, + ); + } + }); + setUp(() async { tempDir = await Directory.systemTemp.createTemp( 'decent-bench-headless-import-test-', @@ -104,46 +119,46 @@ void main() { }, ); - test('imports an Excel fixture headlessly and emits a JSON summary', () async { - final stdoutLines = []; - final stderrLines = []; - final targetPath = p.join(tempDir.path, 'basic_contacts.ddb'); - final sourcePath = p.normalize( - p.join( - Directory.current.path, - '..', - '..', - 'test-data', - 'excel', - 'basic_contacts.xlsx', - ), - ); + test( + 'imports an Excel fixture headlessly and emits a JSON summary', + () async { + final stdoutLines = []; + final stderrLines = []; + final targetPath = p.join(tempDir.path, 'basic_contacts.ddb'); + final sourcePath = p.normalize( + p.join( + Directory.current.path, + '..', + '..', + 'test-data', + 'excel', + 'basic_contacts.xlsx', + ), + ); - final exitCode = await runHeadlessImportCli( - HeadlessImportCliOptions( - sourcePath: sourcePath, - targetPath: targetPath, - silent: true, - ), - stdoutWriter: stdoutLines.add, - stderrWriter: stderrLines.add, - ); + final exitCode = await runHeadlessImportCli( + HeadlessImportCliOptions( + sourcePath: sourcePath, + targetPath: targetPath, + silent: true, + ), + stdoutWriter: stdoutLines.add, + stderrWriter: stderrLines.add, + ); - expect(exitCode, 0); - expect(stderrLines, isEmpty); - expect(stdoutLines, isNotEmpty); + expect(exitCode, 0); + expect(stderrLines, isEmpty); + expect(stdoutLines, isNotEmpty); - final report = jsonDecode(stdoutLines.last) as Map; - final warnings = (report['warnings'] as List).cast(); + final report = jsonDecode(stdoutLines.last) as Map; + final warnings = (report['warnings'] as List).cast(); - expect(report['format_key'], 'xlsx'); - expect(report['imported_tables'], isNotEmpty); - expect( - warnings.join('\n'), - contains('temporary `.xlsx` rewrite'), - ); - expect(File(targetPath).existsSync(), isTrue); - }); + expect(report['format_key'], 'xlsx'); + expect(report['imported_tables'], isNotEmpty); + expect(warnings.join('\n'), contains('temporary `.xlsx` rewrite')); + expect(File(targetPath).existsSync(), isTrue); + }, + ); test('rejects plan files until plan execution is implemented', () async { final stdoutLines = []; diff --git a/apps/decent-bench/test/app/theme_system/theme_manager_test.dart b/apps/decent-bench/test/app/theme_system/theme_manager_test.dart index 6de6fa5..795a090 100644 --- a/apps/decent-bench/test/app/theme_system/theme_manager_test.dart +++ b/apps/decent-bench/test/app/theme_system/theme_manager_test.dart @@ -131,9 +131,9 @@ class _FakeThemeDiscoveryService extends ThemeDiscoveryService { }, resolvedThemesDirectory: '/tmp/themes', logs: const [ - 'Skipping /tmp/themes/classic-dark.toml: Theme classic-dark is incompatible with Decent Bench 1.0.0.', - 'Skipping /tmp/themes/classic-light.toml: Theme classic-light is incompatible with Decent Bench 1.0.0.', - 'Skipping /tmp/themes/custom.toml: Theme custom is incompatible with Decent Bench 1.0.0.', + 'Skipping /tmp/themes/classic-dark.toml: Theme classic-dark is incompatible with Decent Bench 1.1.0.', + 'Skipping /tmp/themes/classic-light.toml: Theme classic-light is incompatible with Decent Bench 1.1.0.', + 'Skipping /tmp/themes/custom.toml: Theme custom is incompatible with Decent Bench 1.1.0.', ], ); } diff --git a/apps/decent-bench/test/features/import/infrastructure/import_detection_tar_test.dart b/apps/decent-bench/test/features/import/infrastructure/import_detection_tar_test.dart new file mode 100644 index 0000000..91d18e0 --- /dev/null +++ b/apps/decent-bench/test/features/import/infrastructure/import_detection_tar_test.dart @@ -0,0 +1,285 @@ +import 'dart:io'; + +import 'package:decent_bench/features/import/domain/import_models.dart'; +import 'package:decent_bench/features/import/infrastructure/import_detection_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('ImportDetectionService tar archive handling', () { + late ImportDetectionService service; + late Directory tempDir; + + setUp(() { + service = ImportDetectionService(); + tempDir = Directory.systemTemp.createTempSync('decent-bench-tar-test-'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('detects .bz2 extension as bzip2Archive wrapper', () async { + final result = await service.detect('/tmp/test.tar.bz2'); + + expect(result.format.key, ImportFormatKey.bzip2Archive); + expect( + result.format.implementationKind, + ImportImplementationKind.wrapper, + ); + }); + + test('detects .gz extension as gzipArchive wrapper', () async { + final result = await service.detect('/tmp/test.tar.gz'); + + expect(result.format.key, ImportFormatKey.gzipArchive); + expect( + result.format.implementationKind, + ImportImplementationKind.wrapper, + ); + }); + + test( + 'lists candidates from a .tar.bz2 archive', + () async { + final tarBz2Path = await _createTarBz2Archive(tempDir); + final result = await service.detect(tarBz2Path); + + expect(result.format.key, ImportFormatKey.bzip2Archive); + expect(result.archiveCandidates, isNotEmpty); + expect( + result.archiveCandidates.any((c) => c.entryPath.contains('artists')), + isTrue, + reason: 'Should find the artists file in the tar archive', + ); + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test( + 'lists candidates from a .tar.gz archive', + () async { + final tarGzPath = await _createTarGzArchive(tempDir); + final result = await service.detect(tarGzPath); + + expect(result.format.key, ImportFormatKey.gzipArchive); + expect(result.archiveCandidates, isNotEmpty); + expect( + result.archiveCandidates.any( + (c) => c.entryPath.contains('customers'), + ), + isTrue, + reason: 'Should find the customers file in the tar archive', + ); + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test( + 'extensionless tar entries default to TSV format', + () async { + final tarBz2Path = await _createTarBz2Archive(tempDir); + final result = await service.detect(tarBz2Path); + + final artistsCandidate = result.archiveCandidates.firstWhere( + (c) => c.entryPath.contains('artists') && !c.entryPath.contains('.'), + orElse: () => + throw StateError('No extensionless artists entry found'), + ); + + expect(artistsCandidate.innerFormatKey, ImportFormatKey.tsv); + expect(artistsCandidate.supportState, ImportSupportState.complete); + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test( + 'tar entries with recognized extensions map correctly', + () async { + final tarBz2Path = await _createTarBz2WithExtensions(tempDir); + final result = await service.detect(tarBz2Path); + + final csvCandidate = result.archiveCandidates.firstWhere( + (c) => c.entryPath.endsWith('.csv'), + orElse: () => throw StateError('No .csv entry found'), + ); + expect(csvCandidate.innerFormatKey, ImportFormatKey.csv); + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test( + 'extracts a single entry from a tar.bz2 archive', + () async { + final tarBz2Path = await _createTarBz2Archive(tempDir); + final result = await service.detect(tarBz2Path); + final candidate = result.archiveCandidates.firstWhere( + (c) => c.entryPath.contains('artists'), + ); + + final extractedPath = await service.extractArchiveCandidate( + archivePath: tarBz2Path, + wrapperKey: ImportFormatKey.bzip2Archive, + candidate: candidate, + ); + + expect(File(extractedPath).existsSync(), isTrue); + final content = File(extractedPath).readAsStringSync(); + expect(content, contains('Radiohead')); + + // Cleanup extracted temp dir + final extractedDir = Directory(p.dirname(extractedPath)); + if (extractedDir.existsSync()) { + extractedDir.deleteSync(recursive: true); + } + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test( + 'extracts a single entry from a tar.gz archive', + () async { + final tarGzPath = await _createTarGzArchive(tempDir); + final result = await service.detect(tarGzPath); + final candidate = result.archiveCandidates.firstWhere( + (c) => c.entryPath.contains('customers'), + ); + + final extractedPath = await service.extractArchiveCandidate( + archivePath: tarGzPath, + wrapperKey: ImportFormatKey.gzipArchive, + candidate: candidate, + ); + + expect(File(extractedPath).existsSync(), isTrue); + final content = File(extractedPath).readAsStringSync(); + expect(content, contains('Alice')); + + final extractedDir = Directory(p.dirname(extractedPath)); + if (extractedDir.existsSync()) { + extractedDir.deleteSync(recursive: true); + } + }, + skip: !_tarAvailable() ? 'tar command not available' : null, + ); + + test('single .bz2 file (non-tar) detects inner format by name', () async { + final result = await service.detect('/tmp/data.csv.bz2'); + + expect(result.format.key, ImportFormatKey.bzip2Archive); + expect(result.archiveCandidates, hasLength(1)); + expect( + result.archiveCandidates.first.innerFormatKey, + ImportFormatKey.csv, + ); + }); + + test('produces warning when tar command is unavailable', () async { + // This test creates a fake tar.bz2 file and tests with a service + // that has a broken tar path. We verify the error handling path. + final fakePath = p.join(tempDir.path, 'test.tar.bz2'); + File(fakePath).writeAsStringSync('not a real archive'); + + // Detection should still work (returns format key) + final result = await service.detect(fakePath); + expect(result.format.key, ImportFormatKey.bzip2Archive); + // May or may not have warnings depending on whether tar is available + }); + }); +} + +bool _tarAvailable() { + try { + final result = Process.runSync('tar', ['--version']); + return result.exitCode == 0; + } catch (_) { + return false; + } +} + +Future _createTarBz2Archive(Directory tempDir) async { + final stagingDir = Directory(p.join(tempDir.path, 'staging-tar-bz2')) + ..createSync(); + final dataDir = Directory(p.join(stagingDir.path, 'mbdump'))..createSync(); + + File(p.join(dataDir.path, 'artists')).writeAsStringSync( + '1\tRadiohead\tUK\n' + '2\tBjörk\tIceland\n' + '3\tDaft Punk\tFrance\n', + ); + File(p.join(dataDir.path, 'releases')).writeAsStringSync( + '1\tOK Computer\t1997\n' + '2\tHomogenic\t1997\n', + ); + + final archivePath = p.join(tempDir.path, 'mbdump.tar.bz2'); + final result = Process.runSync('tar', [ + '-cjf', + archivePath, + '-C', + stagingDir.path, + 'mbdump', + ]); + if (result.exitCode != 0) { + throw StateError('Failed to create test tar.bz2: ${result.stderr}'); + } + return archivePath; +} + +Future _createTarGzArchive(Directory tempDir) async { + final stagingDir = Directory(p.join(tempDir.path, 'staging-tar-gz')) + ..createSync(); + + File(p.join(stagingDir.path, 'customers.csv')).writeAsStringSync( + 'id,name,email\n' + '1,Alice,alice@example.com\n' + '2,Bob,bob@example.com\n', + ); + File(p.join(stagingDir.path, 'orders.csv')).writeAsStringSync( + 'id,customer_id,total\n' + '1,1,99.99\n' + '2,2,149.50\n', + ); + + final archivePath = p.join(tempDir.path, 'export.tar.gz'); + final result = Process.runSync('tar', [ + '-czf', + archivePath, + '-C', + stagingDir.path, + 'customers.csv', + 'orders.csv', + ]); + if (result.exitCode != 0) { + throw StateError('Failed to create test tar.gz: ${result.stderr}'); + } + return archivePath; +} + +Future _createTarBz2WithExtensions(Directory tempDir) async { + final stagingDir = Directory(p.join(tempDir.path, 'staging-ext')) + ..createSync(); + + File( + p.join(stagingDir.path, 'data.csv'), + ).writeAsStringSync('id,value\n1,test\n'); + File( + p.join(stagingDir.path, 'records.json'), + ).writeAsStringSync('[{"id": 1}]'); + + final archivePath = p.join(tempDir.path, 'mixed.tar.bz2'); + final result = Process.runSync('tar', [ + '-cjf', + archivePath, + '-C', + stagingDir.path, + 'data.csv', + 'records.json', + ]); + if (result.exitCode != 0) { + throw StateError('Failed to create test tar.bz2: ${result.stderr}'); + } + return archivePath; +} diff --git a/apps/decent-bench/test/features/import/infrastructure/import_fixture_round_trip_test.dart b/apps/decent-bench/test/features/import/infrastructure/import_fixture_round_trip_test.dart index d706c97..8d3d536 100644 --- a/apps/decent-bench/test/features/import/infrastructure/import_fixture_round_trip_test.dart +++ b/apps/decent-bench/test/features/import/infrastructure/import_fixture_round_trip_test.dart @@ -33,12 +33,7 @@ class _FixedResolver extends NativeLibraryResolver { String? _resolveNativeLib() { final resolver = NativeLibraryResolver(); - final candidates = [ - '/usr/local/lib/${resolver.libraryFileName}', - '/usr/lib/${resolver.libraryFileName}', - '${Platform.environment['HOME']}/.local/lib/${resolver.libraryFileName}', - ]; - for (final candidate in candidates) { + for (final candidate in resolver.candidatePaths()) { if (File(candidate).existsSync()) { return candidate; } @@ -76,7 +71,7 @@ bool _isIgnoredFixturePath(String relativePath) { void main() { final nativeLib = _resolveNativeLib(); final skipReason = nativeLib == null - ? 'DecentDB native library not found in system paths' + ? 'DecentDB native library is unavailable' : null; final registry = ImportFormatRegistry.instance; diff --git a/apps/decent-bench/test/features/workspace/application/workspace_controller_test.dart b/apps/decent-bench/test/features/workspace/application/workspace_controller_test.dart index bed5179..1ba43c8 100644 --- a/apps/decent-bench/test/features/workspace/application/workspace_controller_test.dart +++ b/apps/decent-bench/test/features/workspace/application/workspace_controller_test.dart @@ -395,6 +395,34 @@ void main() { expect((await store.load()).recentFiles, contains(dbPath)); }); + test('openLogDatabase opens the configured application log database', () async { + final logPath = + '${Directory.systemTemp.path}/decent-bench-log-${DateTime.now().microsecondsSinceEpoch}.ddb'; + final file = File(logPath); + await file.parent.create(recursive: true); + await file.writeAsString(''); + + addTearDown(() async { + if (await file.exists()) { + await file.delete(); + } + }); + + final controller = WorkspaceController( + gateway: FakeWorkspaceGateway(), + logger: _FixedPathLogger(logPath), + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + await controller.initialize(); + + await controller.openLogDatabase(); + + expect(controller.databasePath, logPath); + expect(controller.engineVersion, '1.6.1'); + expect(controller.workspaceError, isNull); + }); + test('applyAppConfig persists and reloads TOML-backed preferences', () async { final store = InMemoryConfigStore(); final controller = WorkspaceController( @@ -857,6 +885,30 @@ void main() { expect(controller.excelImportSession?.summary?.rolledBack, isTrue); }); + test('excel import failure updates session state and workspace error', () async { + final gateway = FakeWorkspaceGateway()..failNextExcelImport = true; + final controller = WorkspaceController( + gateway: gateway, + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + await controller.initialize(); + controller.beginExcelImport(); + await controller.loadExcelImportSource('/tmp/phase5-failure.xlsx'); + + await controller.runExcelImport(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(controller.excelImportSession?.step, ExcelImportWizardStep.summary); + expect(controller.excelImportSession?.phase, ExcelImportJobPhase.failed); + expect( + controller.excelImportSession?.error, + 'Excel import failed in the fake gateway.', + ); + expect(controller.workspaceError, 'Excel import failed in the fake gateway.'); + expect(controller.workspaceMessage, isNull); + }); + test( 'import workflows suggest new DecentDB targets beside the source file', () async { @@ -1004,6 +1056,42 @@ void main() { expect(controller.sqlDumpImportSession?.summary?.rolledBack, isTrue); }); + test( + 'sql dump import failure updates session state and workspace error', + () async { + final gateway = FakeWorkspaceGateway()..failNextSqlDumpImport = true; + final controller = WorkspaceController( + gateway: gateway, + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + await controller.initialize(); + controller.beginSqlDumpImport(); + await controller.loadSqlDumpImportSource('/tmp/phase6-failure.sql'); + + await controller.runSqlDumpImport(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + controller.sqlDumpImportSession?.step, + SqlDumpImportWizardStep.summary, + ); + expect( + controller.sqlDumpImportSession?.phase, + SqlDumpImportJobPhase.failed, + ); + expect( + controller.sqlDumpImportSession?.error, + 'SQL dump import failed in the fake gateway.', + ); + expect( + controller.workspaceError, + 'SQL dump import failed in the fake gateway.', + ); + expect(controller.workspaceMessage, isNull); + }, + ); + test( 'sqlite import inspection loads tables, previews, and import summary', () async { @@ -1114,4 +1202,40 @@ void main() { ); expect(controller.sqliteImportSession?.summary?.rolledBack, isTrue); }); + + test('sqlite import failure updates session state and workspace error', () async { + final gateway = FakeWorkspaceGateway()..failNextImport = true; + final controller = WorkspaceController( + gateway: gateway, + configStore: InMemoryConfigStore(), + workspaceStateStore: InMemoryWorkspaceStateStore(), + ); + await controller.initialize(); + controller.beginSqliteImport(); + await controller.loadSqliteImportSource('/tmp/phase4-failure.sqlite'); + + await controller.runSqliteImport(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(controller.sqliteImportSession?.step, SqliteImportWizardStep.summary); + expect(controller.sqliteImportSession?.phase, SqliteImportJobPhase.failed); + expect( + controller.sqliteImportSession?.error, + 'SQLite import failed in the fake gateway.', + ); + expect( + controller.workspaceError, + 'SQLite import failed in the fake gateway.', + ); + expect(controller.workspaceMessage, isNull); + }); +} + +class _FixedPathLogger extends RecordingAppLogger { + _FixedPathLogger(this._logDatabasePath); + + final String _logDatabasePath; + + @override + String get logDatabasePath => _logDatabasePath; } diff --git a/apps/decent-bench/test/features/workspace/domain/sql_dump_import_models_test.dart b/apps/decent-bench/test/features/workspace/domain/sql_dump_import_models_test.dart new file mode 100644 index 0000000..5012a8c --- /dev/null +++ b/apps/decent-bench/test/features/workspace/domain/sql_dump_import_models_test.dart @@ -0,0 +1,252 @@ +import 'package:decent_bench/features/workspace/domain/sql_dump_import_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SqlDumpImportSession', () { + test('initial creates session with idle defaults', () { + final session = SqlDumpImportSession.initial(); + + expect(session.step, SqlDumpImportWizardStep.source); + expect(session.phase, SqlDumpImportJobPhase.idle); + expect(session.tables, isEmpty); + expect(session.warnings, isEmpty); + expect(session.encoding, 'auto'); + }); + + test('canAdvanceFromSource is false when no tables', () { + final session = SqlDumpImportSession.initial(sourcePath: '/test.sql'); + + expect(session.canAdvanceFromSource, isFalse); + }); + + test('canAdvanceFromSource is true when source and tables exist', () { + final session = SqlDumpImportSession.initial(sourcePath: '/test.sql') + .copyWith( + tables: [ + SqlDumpImportTableDraft( + sourceName: 'users', + targetName: 'users', + selected: true, + rowCount: 5, + columns: [], + previewRows: [], + ), + ], + ); + + expect(session.canAdvanceFromSource, isTrue); + }); + + test('canAdvanceFromTarget requires non-empty target', () { + final empty = SqlDumpImportSession.initial(); + expect(empty.canAdvanceFromTarget, isFalse); + + final withTarget = empty.copyWith(targetPath: '/out.ddb'); + expect(withTarget.canAdvanceFromTarget, isTrue); + }); + + test('canAdvanceFromPreview requires selected tables', () { + final withSelected = SqlDumpImportSession.initial().copyWith( + tables: [ + SqlDumpImportTableDraft( + sourceName: 't', + targetName: 't', + selected: true, + rowCount: 1, + columns: [], + previewRows: [], + ), + ], + ); + expect(withSelected.canAdvanceFromPreview, isTrue); + + final withUnselected = SqlDumpImportSession.initial().copyWith( + tables: [ + SqlDumpImportTableDraft( + sourceName: 't', + targetName: 't', + selected: false, + rowCount: 1, + columns: [], + previewRows: [], + ), + ], + ); + expect(withUnselected.canAdvanceFromPreview, isFalse); + }); + + test('canAdvanceFromTransforms rejects duplicate target names', () { + final duplicateTargets = SqlDumpImportSession.initial().copyWith( + tables: [ + SqlDumpImportTableDraft( + sourceName: 'a', + targetName: 'same_name', + selected: true, + rowCount: 1, + columns: [], + previewRows: [], + ), + SqlDumpImportTableDraft( + sourceName: 'b', + targetName: 'same_name', + selected: true, + rowCount: 1, + columns: [], + previewRows: [], + ), + ], + ); + expect(duplicateTargets.canAdvanceFromTransforms, isFalse); + }); + + test('focusedTableDraft returns focused or first table', () { + final session = SqlDumpImportSession.initial().copyWith( + tables: [ + SqlDumpImportTableDraft( + sourceName: 'first', + targetName: 'first', + selected: true, + rowCount: 1, + columns: [], + previewRows: [], + ), + SqlDumpImportTableDraft( + sourceName: 'second', + targetName: 'second', + selected: true, + rowCount: 1, + columns: [], + previewRows: [], + ), + ], + focusedTable: 'second', + ); + + expect(session.focusedTableDraft!.sourceName, 'second'); + }); + + test('copyWith preserves unset optional fields', () { + final original = SqlDumpImportSession.initial().copyWith( + error: 'some error', + jobId: '123', + progress: SqlDumpImportProgress( + jobId: '123', + currentTable: 't', + completedTables: 0, + totalTables: 1, + currentTableRowsCopied: 5, + currentTableRowCount: 10, + totalRowsCopied: 5, + message: 'progress', + ), + ); + + final updated = original.copyWith(phase: SqlDumpImportJobPhase.running); + + expect(updated.error, 'some error'); + expect(updated.jobId, '123'); + expect(updated.progress, isNotNull); + expect(updated.phase, SqlDumpImportJobPhase.running); + }); + }); + + group('SqlDumpImportColumnDraft', () { + test('toMap/fromMap round-trip', () { + final column = SqlDumpImportColumnDraft( + sourceIndex: 0, + sourceName: 'id', + targetName: 'id', + declaredType: 'INT', + inferredTargetType: 'INTEGER', + targetType: 'INTEGER', + notNull: true, + primaryKey: true, + unique: false, + ); + + final map = column.toMap(); + final restored = SqlDumpImportColumnDraft.fromMap(map); + + expect(restored.sourceIndex, 0); + expect(restored.sourceName, 'id'); + expect(restored.declaredType, 'INT'); + expect(restored.inferredTargetType, 'INTEGER'); + expect(restored.notNull, isTrue); + expect(restored.primaryKey, isTrue); + }); + }); + + group('SqlDumpImportSummary', () { + test('totalRowsCopied sums by table', () { + final summary = SqlDumpImportSummary( + jobId: 'j1', + sourcePath: '/src', + targetPath: '/tgt', + importedTables: ['a', 'b'], + rowsCopiedByTable: {'a': 10, 'b': 25}, + skippedStatementCount: 0, + warnings: [], + skippedStatements: [], + statusMessage: 'done', + rolledBack: false, + ); + + expect(summary.totalRowsCopied, 35); + expect(summary.firstImportedTable, 'a'); + }); + + test('firstImportedTable returns null when empty', () { + final summary = SqlDumpImportSummary( + jobId: 'j1', + sourcePath: '/src', + targetPath: '/tgt', + importedTables: [], + rowsCopiedByTable: {}, + skippedStatementCount: 0, + warnings: [], + skippedStatements: [], + statusMessage: 'none', + rolledBack: false, + ); + + expect(summary.firstImportedTable, isNull); + expect(summary.totalRowsCopied, 0); + }); + }); + + group('SqlDumpImportUpdate', () { + test('toMap/fromMap round-trip for progress update', () { + final update = SqlDumpImportUpdate( + kind: SqlDumpImportUpdateKind.progress, + jobId: 'j1', + progress: SqlDumpImportProgress( + jobId: 'j1', + currentTable: 'users', + completedTables: 0, + totalTables: 1, + currentTableRowsCopied: 5, + currentTableRowCount: 100, + totalRowsCopied: 5, + message: 'Importing...', + ), + ); + + final map = update.toMap(); + final restored = SqlDumpImportUpdate.fromMap(map); + + expect(restored.kind, SqlDumpImportUpdateKind.progress); + expect(restored.jobId, 'j1'); + expect(restored.progress, isNotNull); + expect(restored.progress!.currentTable, 'users'); + }); + }); + + group('sqlDumpEncodingLabel', () { + test('returns human-readable labels', () { + expect(sqlDumpEncodingLabel('auto'), 'Auto-detect'); + expect(sqlDumpEncodingLabel('utf8'), 'UTF-8'); + expect(sqlDumpEncodingLabel('latin1'), 'Latin-1'); + expect(sqlDumpEncodingLabel('other'), 'other'); + }); + }); +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/decentdb_bridge_smoke_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/decentdb_bridge_smoke_test.dart index 479d29d..2dd72c9 100644 --- a/apps/decent-bench/test/features/workspace/infrastructure/decentdb_bridge_smoke_test.dart +++ b/apps/decent-bench/test/features/workspace/infrastructure/decentdb_bridge_smoke_test.dart @@ -27,12 +27,7 @@ class _FixedResolver extends NativeLibraryResolver { String? _resolveNativeLib() { final resolver = NativeLibraryResolver(); - final candidates = [ - '/usr/local/lib/${resolver.libraryFileName}', - '/usr/lib/${resolver.libraryFileName}', - '${Platform.environment['HOME']}/.local/lib/${resolver.libraryFileName}', - ]; - for (final candidate in candidates) { + for (final candidate in resolver.candidatePaths()) { if (File(candidate).existsSync()) { return candidate; } @@ -43,7 +38,7 @@ String? _resolveNativeLib() { void main() { final nativeLib = _resolveNativeLib(); final skipReason = nativeLib == null - ? 'DecentDB native library not found in system paths' + ? 'DecentDB native library is unavailable' : null; group('DecentDbBridge smoke tests', () { @@ -94,9 +89,7 @@ void main() { if (lib == null) { throw Exception(skipReason); } - final service = ImportExecutionService( - resolver: _FixedResolver(lib), - ); + final service = ImportExecutionService(resolver: _FixedResolver(lib)); final updates = await service.execute(request: request).toList(); final terminal = updates.last; @@ -198,6 +191,28 @@ CREATE TABLE events ( return sourcePath; } + String createSqliteHighPrecisionTimestampSource(String filename) { + final sourcePath = p.join(tempDir.path, filename); + final source = sqlite.sqlite3.open(sourcePath); + try { + source.execute(''' +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +) +'''); + source.execute(''' +INSERT INTO events VALUES + (1, '2024-12-30T10:02:04.901481207-06:00', '2024-12-29T10:00:03.033627726-06:00'), + (2, '2025-01-01 11:00:42.24883752-06:00', '2024-10-14 16:04:46') +'''); + } finally { + source.close(); + } + return sourcePath; + } + String createSqliteImplicitFkSource(String filename) { final sourcePath = p.join(tempDir.path, filename); final source = sqlite.sqlite3.open(sourcePath); @@ -969,15 +984,22 @@ ORDER BY dept final updates = await bridge.importSqlite(request: request).toList(); final terminal = updates.last; + final summary = terminal.summary!; expect(terminal.kind, SqliteImportUpdateKind.completed); expect(terminal.summary, isNotNull); expect( - terminal.summary!.importedTables, + summary.importedTables, orderedEquals(['users', 'imported_notes']), ); - expect(terminal.summary!.rowsCopiedByTable['users'], 2); - expect(terminal.summary!.rowsCopiedByTable['imported_notes'], 2); + expect(summary.rowsCopiedByTable['users'], 2); + expect(summary.rowsCopiedByTable['imported_notes'], 2); + expect(summary.targetTableCount, 2); + expect(summary.targetIndexCount, greaterThanOrEqualTo(1)); + expect(summary.targetViewCount, 0); + expect(summary.targetTriggerCount, 0); + expect(summary.databaseFileBytes, greaterThan(8192)); + expect(summary.walFileBytes, 0); await bridge.openDatabase(targetPath); final rows = await queryAllRows(''' @@ -1008,6 +1030,42 @@ ORDER BY n.id }, ); + test( + 'imports SQLite datetime text with more than 6 fractional digits', + skip: skipReason, + () async { + final sourcePath = createSqliteHighPrecisionTimestampSource( + 'phase4-high-precision.sqlite', + ); + final inspection = await bridge.inspectSqliteSource( + sourcePath: sourcePath, + ); + final events = inspection.tables.singleWhere( + (table) => table.sourceName == 'events', + ); + final targetPath = p.join(tempDir.path, 'phase4-high-precision.ddb'); + final request = SqliteImportRequest( + jobId: 'smoke-high-precision-import', + sourcePath: sourcePath, + targetPath: targetPath, + importIntoExistingTarget: false, + replaceExistingTarget: true, + tables: [events], + ); + + final updates = await bridge.importSqlite(request: request).toList(); + + expect(updates.last.kind, SqliteImportUpdateKind.completed); + + await bridge.openDatabase(targetPath); + final rows = await queryAllRows( + 'SELECT id, created_at, updated_at FROM events ORDER BY id', + ); + + expect(rows, hasLength(2)); + }, + ); + test( 'inspects and imports SQLite foreign keys that omit the parent column', skip: skipReason, diff --git a/apps/decent-bench/test/features/workspace/infrastructure/decentdb_native_release_asset_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/decentdb_native_release_asset_test.dart new file mode 100644 index 0000000..577c75c --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/decentdb_native_release_asset_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:decent_bench/features/workspace/infrastructure/decentdb_native_release_asset.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + test('parses the pinned DecentDB tag from pubspec.lock', () { + final contents = ''' +packages: + decentdb: + dependency: "direct main" + description: + path: "bindings/dart/dart" + ref: "v2.2.1" + resolved-ref: "abc123" + url: "https://github.com/sphildreth/decentdb" + source: git + version: "2.2.1" +'''; + + expect( + DecentDbNativeReleaseAsset.parsePinnedTagFromPubspecLock(contents), + 'v2.2.1', + ); + }); + + test( + 'discovers cached library candidates from the nearest project lockfile', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'decentdb-native-asset-test-', + ); + addTearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + final projectDir = Directory(p.join(tempDir.path, 'apps', 'decent-bench')) + ..createSync(recursive: true); + await File(p.join(projectDir.path, 'pubspec.lock')).writeAsString(''' +packages: + decentdb: + dependency: "direct main" + description: + ref: "v2.3.0" + url: "https://github.com/sphildreth/decentdb" + source: git + version: "2.3.0" +'''); + + final candidates = DecentDbNativeReleaseAsset.cachedLibraryCandidates( + searchRoots: [p.join(projectDir.path, 'test')], + platform: DecentDbNativeAssetPlatform.linux, + ).toList(); + + expect( + candidates, + contains( + p.join( + projectDir.path, + '.dart_tool', + 'decentdb', + 'native', + 'v2.3.0', + 'Linux-x64', + 'libdecentdb.so', + ), + ), + ); + }, + ); + + test( + 'selects the generic release bundle when dart-native assets are unavailable', + () { + final download = DecentDbNativeReleaseAsset.selectDownload( + metadata: { + 'assets': [ + { + 'name': 'decentdb-jdbc-v2.2.1-Linux.jar', + 'browser_download_url': 'https://example.invalid/jdbc', + }, + { + 'name': 'decentdb-v2.2.1-Linux-x64.tar.gz', + 'browser_download_url': 'https://example.invalid/linux-x64', + }, + ], + }, + tag: 'v2.2.1', + releaseSuffix: 'Linux-x64', + archiveExtension: 'tar.gz', + ); + + expect(download.name, 'decentdb-v2.2.1-Linux-x64.tar.gz'); + expect( + download.downloadUri.toString(), + 'https://example.invalid/linux-x64', + ); + }, + ); + + test('prefers the dart-native release bundle when it exists', () { + final download = DecentDbNativeReleaseAsset.selectDownload( + metadata: { + 'assets': [ + { + 'name': 'decentdb-v2.3.0-Linux-x64.tar.gz', + 'browser_download_url': 'https://example.invalid/generic', + }, + { + 'name': 'decentdb-dart-native-v2.3.0-Linux-x64.tar.gz', + 'browser_download_url': 'https://example.invalid/dart-native', + }, + ], + }, + tag: 'v2.3.0', + releaseSuffix: 'Linux-x64', + archiveExtension: 'tar.gz', + ); + + expect(download.name, 'decentdb-dart-native-v2.3.0-Linux-x64.tar.gz'); + expect( + download.downloadUri.toString(), + 'https://example.invalid/dart-native', + ); + }); +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/excel_source_preparer_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/excel_source_preparer_test.dart new file mode 100644 index 0000000..08bda72 --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/excel_source_preparer_test.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:decent_bench/features/workspace/domain/workspace_models.dart'; +import 'package:decent_bench/features/workspace/infrastructure/excel_source_preparer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('prepareExcelWorkbookSource', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('decent-bench-excel-prep-'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('returns original path for non-xls files with no warnings', () { + final xlsxPath = p.join(tempDir.path, 'test.xlsx'); + File(xlsxPath).writeAsBytesSync(_minimalXlsx()); + + final result = prepareExcelWorkbookSource(xlsxPath); + + expect(result.resolvedPath, xlsxPath); + expect(result.warnings, isEmpty); + result.dispose(); + }); + + test('throws BridgeFailure for missing .xls file', () { + final missingPath = p.join(tempDir.path, 'missing.xls'); + + expect( + () => prepareExcelWorkbookSource(missingPath), + throwsA(isA()), + ); + }); + }); + + group('normalizeExcelWorkbookSource', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('decent-bench-excel-norm-'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('normalizes xlsx with prefixed namespace elements', () { + final rawBytes = _xlsxWithPrefixedNamespaces(); + final xlsxPath = p.join(tempDir.path, 'prefixed.xlsx'); + File(xlsxPath).writeAsBytesSync(rawBytes); + + final result = normalizeExcelWorkbookSource(xlsxPath); + + expect(result.resolvedPath, isNot(equals(xlsxPath))); + expect(result.warnings, isNotEmpty); + expect(result.warnings.first, contains('normalized')); + + final normalizedBytes = File(result.resolvedPath).readAsBytesSync(); + final archive = ZipDecoder().decodeBytes(normalizedBytes); + final sheetEntry = archive.files.firstWhere( + (f) => f.name.contains('sheet'), + ); + final sheetContent = String.fromCharCodes( + sheetEntry.content as List, + ); + expect(sheetContent, isNot(contains('x:'))); + + result.dispose(); + }); + + test('creates a temporary file that dispose cleans up', () { + final rawBytes = _minimalXlsx(); + final xlsxPath = p.join(tempDir.path, 'disposable.xlsx'); + File(xlsxPath).writeAsBytesSync(rawBytes); + + final result = normalizeExcelWorkbookSource(xlsxPath); + expect(File(result.resolvedPath).existsSync(), isTrue); + + result.dispose(); + expect(File(result.resolvedPath).existsSync(), isFalse); + }); + + test('throws BridgeFailure for missing file', () { + expect( + () => + normalizeExcelWorkbookSource(p.join(tempDir.path, 'missing.xlsx')), + throwsA(isA()), + ); + }); + }); +} + +List _minimalXlsx() { + final archive = Archive() + ..addFile( + ArchiveFile( + '[Content_Types].xml', + ''.length, + ''.codeUnits, + ), + ) + ..addFile( + ArchiveFile( + 'xl/workbook.xml', + ''.length, + ''.codeUnits, + ), + ) + ..addFile( + ArchiveFile( + 'xl/worksheets/sheet1.xml', + ''.length, + ''.codeUnits, + ), + ); + return ZipEncoder().encode(archive)!; +} + +List _xlsxWithPrefixedNamespaces() { + final sheetContent = + '' + '' + '' + ''; + final archive = Archive() + ..addFile( + ArchiveFile( + '[Content_Types].xml', + ''.length, + ''.codeUnits, + ), + ) + ..addFile( + ArchiveFile( + 'xl/workbook.xml', + ''.length, + ''.codeUnits, + ), + ) + ..addFile( + ArchiveFile( + 'xl/worksheets/sheet1.xml', + sheetContent.length, + sheetContent.codeUnits, + ), + ); + return ZipEncoder().encode(archive)!; +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/layout_persistence_service_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/layout_persistence_service_test.dart new file mode 100644 index 0000000..d57e5fe --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/layout_persistence_service_test.dart @@ -0,0 +1,74 @@ +import 'package:decent_bench/features/workspace/domain/app_config.dart'; +import 'package:decent_bench/features/workspace/domain/workspace_shell_preferences.dart'; +import 'package:decent_bench/features/workspace/infrastructure/layout_persistence_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('load returns normalized shell preferences from config', () { + final service = const LayoutPersistenceService(); + final config = AppConfig.defaults().copyWith( + shellPreferences: const WorkspaceShellPreferences( + leftColumnFraction: 0.10, + leftTopFraction: 0.99, + rightTopFraction: 0.55, + showSchemaExplorer: true, + showPropertiesPane: false, + showResultsPane: true, + showStatusBar: true, + editorZoom: 2.0, + activeResultsTab: ResultsPaneTab.messages, + ), + ); + + final prefs = service.load(config); + + expect(prefs.leftColumnFraction, closeTo(0.18, 0.001)); + expect(prefs.leftTopFraction, closeTo(0.82, 0.001)); + expect(prefs.showPropertiesPane, isFalse); + expect(prefs.activeResultsTab, ResultsPaneTab.messages); + expect(prefs.editorZoom, closeTo(1.4, 0.001)); + }); + + test('save updates config with normalized preferences', () { + final service = const LayoutPersistenceService(); + final original = AppConfig.defaults(); + final prefs = const WorkspaceShellPreferences( + leftColumnFraction: 0.50, + leftTopFraction: 0.50, + rightTopFraction: 0.50, + showSchemaExplorer: false, + showPropertiesPane: false, + showResultsPane: false, + showStatusBar: false, + editorZoom: 1.0, + activeResultsTab: ResultsPaneTab.executionPlan, + ); + + final updated = service.save(original, prefs); + + expect(updated.shellPreferences.showSchemaExplorer, isFalse); + expect( + updated.shellPreferences.activeResultsTab, + ResultsPaneTab.executionPlan, + ); + expect(updated.shellPreferences.leftColumnFraction, closeTo(0.50, 0.001)); + expect(updated.defaultPageSize, original.defaultPageSize); + }); + + test('round-trip preserves identity when values are in range', () { + final service = const LayoutPersistenceService(); + final config = AppConfig.defaults(); + + final loaded = service.load(config); + final saved = service.save(config, loaded); + + expect( + saved.shellPreferences.leftColumnFraction, + closeTo(loaded.leftColumnFraction, 0.001), + ); + expect( + saved.shellPreferences.showSchemaExplorer, + loaded.showSchemaExplorer, + ); + }); +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/native_library_resolver_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/native_library_resolver_test.dart index 4d6bcf3..c4b5d99 100644 --- a/apps/decent-bench/test/features/workspace/infrastructure/native_library_resolver_test.dart +++ b/apps/decent-bench/test/features/workspace/infrastructure/native_library_resolver_test.dart @@ -1,34 +1,45 @@ +import 'dart:io'; + import 'package:decent_bench/features/workspace/infrastructure/native_library_resolver.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; void main() { - test( - 'runtime resolution checks bundled library locations first', - () async { - final resolver = NativeLibraryResolver( - currentDirectoryPath: '/workspace/apps/decent-bench', - scriptDirectoryPath: '/workspace/apps/decent-bench/tool', - resolvedExecutablePath: '/bundle/decent_bench', - platform: NativeLibraryPlatform.linux, - fileExists: (path) => path == '/bundle/lib/libdecentdb.so', - ); + test('runtime resolution checks bundled library locations first', () async { + final resolver = NativeLibraryResolver( + currentDirectoryPath: '/workspace/apps/decent-bench', + scriptDirectoryPath: '/workspace/apps/decent-bench/tool', + resolvedExecutablePath: '/bundle/decent_bench', + platform: NativeLibraryPlatform.linux, + fileExists: (path) => path == '/bundle/lib/libdecentdb.so', + ); - final result = await resolver.resolveDetailed(); + final result = await resolver.resolveDetailed(); - expect(result.resolvedPath, '/bundle/lib/libdecentdb.so'); - expect(result.checkedPaths.first, '/bundle/lib/libdecentdb.so'); - }, - ); + expect(result.resolvedPath, '/bundle/lib/libdecentdb.so'); + expect(result.checkedPaths.first, '/bundle/lib/libdecentdb.so'); + }); test( - 'packaging resolution checks repo search paths', + 'packaging resolution prefers the cached pinned release asset', () async { + final appDir = Directory.current.path; + final cachedLibraryPath = p.join( + appDir, + '.dart_tool', + 'decentdb', + 'native', + 'v2.3.0', + 'Linux-x64', + 'libdecentdb.so', + ); final resolver = NativeLibraryResolver( - currentDirectoryPath: '/workspace/apps/decent-bench', - scriptDirectoryPath: '/workspace/apps/decent-bench/tool', + currentDirectoryPath: appDir, + scriptDirectoryPath: p.join(appDir, 'tool'), resolvedExecutablePath: '/bundle/decent_bench', platform: NativeLibraryPlatform.linux, fileExists: (path) => + path == cachedLibraryPath || path == '/workspace/decentdb/target/debug/libdecentdb.so', ); @@ -36,17 +47,32 @@ void main() { mode: NativeLibraryResolutionMode.packagingSource, ); - expect( - result.resolvedPath, - '/workspace/decentdb/target/debug/libdecentdb.so', - ); - expect( - result.checkedPaths.any((path) => path == '/bundle/lib/libdecentdb.so'), - isFalse, - ); + expect(result.resolvedPath, cachedLibraryPath); }, ); + test('packaging resolution falls back to repo search paths', () async { + final appDir = Directory.current.path; + final repoFallbackPath = p.join(appDir, 'build', 'libdecentdb.so'); + final resolver = NativeLibraryResolver( + currentDirectoryPath: appDir, + scriptDirectoryPath: p.join(appDir, 'tool'), + resolvedExecutablePath: '/bundle/decent_bench', + platform: NativeLibraryPlatform.linux, + fileExists: (path) => path == repoFallbackPath, + ); + + final result = await resolver.resolveDetailed( + mode: NativeLibraryResolutionMode.packagingSource, + ); + + expect(result.resolvedPath, repoFallbackPath); + expect( + result.checkedPaths.any((path) => path == '/bundle/lib/libdecentdb.so'), + isFalse, + ); + }); + test('bundle relative install path matches platform conventions', () { final linux = NativeLibraryResolver( currentDirectoryPath: '/tmp', @@ -78,31 +104,39 @@ void main() { expect(windows.bundleRelativeInstallPath, 'decentdb.dll'); }); - test( - 'failure includes checked candidates', - () async { - final resolver = NativeLibraryResolver( - currentDirectoryPath: '/workspace/apps/decent-bench', - scriptDirectoryPath: '/workspace/apps/decent-bench/tool', - resolvedExecutablePath: '/bundle/decent_bench', - platform: NativeLibraryPlatform.linux, - fileExists: (_) => false, - ); + test('failure includes checked candidates', () async { + final appDir = Directory.current.path; + final resolver = NativeLibraryResolver( + currentDirectoryPath: appDir, + scriptDirectoryPath: p.join(appDir, 'tool'), + resolvedExecutablePath: '/bundle/decent_bench', + platform: NativeLibraryPlatform.linux, + fileExists: (_) => false, + ); - await expectLater( - resolver.resolve(), - throwsA( - isA() - .having( - (error) => error.toString(), - 'message', - allOf( - contains('/bundle/lib/libdecentdb.so'), - contains('Install DecentDB system-wide'), - ), + await expectLater( + resolver.resolve(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + allOf( + contains('/bundle/lib/libdecentdb.so'), + contains( + p.join( + appDir, + '.dart_tool', + 'decentdb', + 'native', + 'v2.3.0', + 'Linux-x64', + 'libdecentdb.so', ), + ), + contains('Install DecentDB system-wide'), + ), ), - ); - }, - ); + ), + ); + }); } diff --git a/apps/decent-bench/test/features/workspace/infrastructure/shortcut_config_service_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/shortcut_config_service_test.dart new file mode 100644 index 0000000..020406a --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/shortcut_config_service_test.dart @@ -0,0 +1,152 @@ +import 'dart:io' as dart_io; + +import 'package:decent_bench/features/workspace/domain/app_config.dart'; +import 'package:decent_bench/features/workspace/infrastructure/shortcut_config_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ShortcutConfigService', () { + const service = ShortcutConfigService(); + + test('tryParseActivator parses Ctrl+Enter', () { + final activator = service.tryParseActivator('Ctrl+Enter'); + expect(activator, isNotNull); + expect(activator!.trigger, LogicalKeyboardKey.enter); + expect(activator.control, isTrue); + expect(activator.shift, isFalse); + expect(activator.alt, isFalse); + }); + + test('tryParseActivator parses Shift+Ctrl+F5', () { + final activator = service.tryParseActivator('Shift+Ctrl+F5'); + expect(activator, isNotNull); + expect(activator!.trigger, LogicalKeyboardKey.f5); + expect(activator.control, isTrue); + expect(activator.shift, isTrue); + }); + + test('tryParseActivator parses single key T', () { + final activator = service.tryParseActivator('T'); + expect(activator, isNotNull); + expect(activator!.trigger, LogicalKeyboardKey.keyT); + }); + + test('tryParseActivator parses Esc', () { + final activator = service.tryParseActivator('Esc'); + expect(activator, isNotNull); + expect(activator!.trigger, LogicalKeyboardKey.escape); + }); + + test('tryParseActivator returns null for empty string', () { + expect(service.tryParseActivator(''), isNull); + }); + + test('tryParseActivator returns null for modifiers only', () { + expect(service.tryParseActivator('Ctrl+Shift'), isNull); + }); + + test('tryParseActivator returns null for unknown key', () { + expect(service.tryParseActivator('Ctrl+UnknownKey'), isNull); + }); + + test('displayLabel formats Ctrl+Enter', () { + final label = service.displayLabel('Ctrl+Enter'); + if (dart_io.Platform.isMacOS) { + expect(label, contains('Cmd')); + } else { + expect(label, contains('Ctrl')); + } + expect(label, contains('Enter')); + }); + + test('displayLabel formats Alt+Delete', () { + final label = service.displayLabel('Alt+Delete'); + if (dart_io.Platform.isMacOS) { + expect(label, contains('Option')); + } else { + expect(label, contains('Alt')); + } + }); + + test('load builds bindings from config defaults', () { + final config = AppConfig.defaults(); + final bindings = service.load(config); + + expect(bindings, isNotEmpty); + for (final binding in bindings.values) { + expect(binding.commandId, isNotEmpty); + expect(binding.activator, isNotNull); + expect(binding.displayLabel, isNotEmpty); + expect(binding.rawValue, isNotEmpty); + } + }); + + test('load falls back to default when custom binding is invalid', () { + final config = AppConfig.defaults().copyWith( + shortcutBindings: { + ...AppConfig.defaultShortcutBindings(), + 'tools_run_query': 'NotARealKey+ZZZZZ', + }, + ); + final bindings = service.load(config); + + final runQuery = bindings['tools_run_query']; + expect(runQuery, isNotNull); + expect(runQuery!.activator, isNotNull); + expect( + runQuery.rawValue, + AppConfig.defaultShortcutBindings()['tools_run_query'], + ); + }); + + test('load parses valid custom binding', () { + final config = AppConfig.defaults().copyWith( + shortcutBindings: { + ...AppConfig.defaultShortcutBindings(), + 'tools_run_query': 'Ctrl+Shift+Enter', + }, + ); + final bindings = service.load(config); + + final runQuery = bindings['tools_run_query']; + expect(runQuery, isNotNull); + expect(runQuery!.rawValue, 'Ctrl+Shift+Enter'); + expect(runQuery.activator.control, isTrue); + expect(runQuery.activator.shift, isTrue); + expect(runQuery.activator.trigger, LogicalKeyboardKey.enter); + }); + + test('tryParseActivator parses F1-F12', () { + for (var i = 1; i <= 12; i++) { + final activator = service.tryParseActivator('F$i'); + expect(activator, isNotNull, reason: 'Failed to parse F$i'); + } + }); + + test('tryParseActivator parses digit keys', () { + for (var i = 0; i <= 9; i++) { + final activator = service.tryParseActivator('$i'); + expect(activator, isNotNull, reason: 'Failed to parse digit $i'); + } + }); + + test('tryParseActivator parses Tab', () { + expect(service.tryParseActivator('Tab')!.trigger, LogicalKeyboardKey.tab); + }); + + test('tryParseActivator parses Space', () { + expect( + service.tryParseActivator('Space')!.trigger, + LogicalKeyboardKey.space, + ); + }); + + test('tryParseActivator parses Backspace', () { + expect( + service.tryParseActivator('Backspace')!.trigger, + LogicalKeyboardKey.backspace, + ); + }); + }); +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/sql_dump_import_support_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/sql_dump_import_support_test.dart new file mode 100644 index 0000000..58c0998 --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/sql_dump_import_support_test.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:decent_bench/features/workspace/domain/workspace_models.dart'; +import 'package:decent_bench/features/workspace/infrastructure/sql_dump_import_support.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('inspectSqlDumpSourceFile', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('decent-bench-sql-dump-'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + String writeDump(String content, {String encoding = 'utf8'}) { + final path = p.join(tempDir.path, 'dump.sql'); + if (encoding == 'latin1') { + File(path).writeAsBytesSync(latin1.encode(content)); + } else { + File(path).writeAsStringSync(content); + } + return path; + } + + test('parses a simple CREATE TABLE + INSERT', () { + final path = writeDump(''' +CREATE TABLE users ( + id INT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) +); + +INSERT INTO users (id, name, email) VALUES (1, 'Ada', 'ada@example.com'); +INSERT INTO users (id, name, email) VALUES (2, 'Lin', NULL); +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + + expect(inspection.tables, hasLength(1)); + expect(inspection.tables.first.sourceName, 'users'); + expect(inspection.tables.first.columns, hasLength(3)); + expect(inspection.tables.first.columns[0].sourceName, 'id'); + expect(inspection.tables.first.columns[0].inferredTargetType, 'INTEGER'); + expect(inspection.tables.first.columns[1].sourceName, 'name'); + expect(inspection.tables.first.columns[1].inferredTargetType, 'TEXT'); + expect(inspection.tables.first.rowCount, 2); + expect(inspection.tables.first.previewRows, hasLength(2)); + expect(inspection.tables.first.previewRows[0]['name'], 'Ada'); + expect(inspection.tables.first.previewRows[1]['email'], isNull); + expect(inspection.resolvedEncoding, 'utf8'); + expect(inspection.warnings, isEmpty); + }); + + test('skips unsupported SET statements with warnings', () { + final path = writeDump(''' +SET NAMES utf8mb4; +CREATE TABLE items (id INT PRIMARY KEY, label TEXT); +INSERT INTO items VALUES (10, 'widget'); +LOCK TABLES items WRITE; +UNLOCK TABLES; +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + + expect(inspection.tables, hasLength(1)); + expect(inspection.skippedStatements.length, greaterThanOrEqualTo(2)); + expect(inspection.warnings.any((w) => w.contains('SET')), isTrue); + }); + + test('handles backtick-quoted identifiers', () { + final path = writeDump(''' +CREATE TABLE `my table` ( + `col 1` INT, + `col 2` TEXT +); +INSERT INTO `my table` (`col 1`, `col 2`) VALUES (42, 'hello'); +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + + expect(inspection.tables, hasLength(1)); + expect(inspection.tables.first.sourceName, 'my table'); + expect(inspection.tables.first.columns[0].sourceName, 'col 1'); + expect(inspection.tables.first.previewRows.first['col 1'], 42); + }); + + test('handles multiple tables', () { + final path = writeDump(''' +CREATE TABLE orders (id INT PRIMARY KEY, total DECIMAL(10,2)); +CREATE TABLE line_items (id INT PRIMARY KEY, order_id INT, product TEXT); +INSERT INTO orders VALUES (1, 99.99); +INSERT INTO line_items VALUES (1, 1, 'book'); +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + + expect(inspection.tables, hasLength(2)); + expect(inspection.tables[0].sourceName, 'orders'); + expect(inspection.tables[1].sourceName, 'line_items'); + expect(inspection.totalStatements, 4); + }); + + test('handles Latin1 encoding', () { + final content = ''' +CREATE TABLE items (id INT PRIMARY KEY, label TEXT); +INSERT INTO items VALUES (1, 'caf\xe9'); +'''; + final path = writeDump(content, encoding: 'latin1'); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'latin1'); + + expect(inspection.resolvedEncoding, 'latin1'); + expect(inspection.tables.first.previewRows.first['label'], 'caf\xe9'); + }); + + test('auto-detect falls back to Latin1 for non-UTF8 content', () { + final bytes = [ + ...utf8.encode('CREATE TABLE t (id INT);\nINSERT INTO t VALUES (1);\n'), + 0xe9, + ]; + final path = p.join(tempDir.path, 'bad_utf8.sql'); + File(path).writeAsBytesSync(bytes); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + + expect(inspection.resolvedEncoding, 'latin1'); + expect(inspection.warnings, isNotEmpty); + }); + + test('throws for missing file', () { + expect( + () => + inspectSqlDumpSourceFile('/nonexistent/path.sql', encoding: 'auto'), + throwsA(isA()), + ); + }); + }); + + group('mapMySqlDeclaredTypeToDecentDb', () { + test('maps INT to INTEGER', () { + expect(mapMySqlDeclaredTypeToDecentDb('INT'), 'INTEGER'); + }); + + test('maps BIGINT to INTEGER', () { + expect(mapMySqlDeclaredTypeToDecentDb('BIGINT'), 'INTEGER'); + }); + + test('maps VARCHAR(255) to TEXT', () { + expect(mapMySqlDeclaredTypeToDecentDb('VARCHAR(255)'), 'TEXT'); + }); + + test('maps DOUBLE to FLOAT64', () { + expect(mapMySqlDeclaredTypeToDecentDb('DOUBLE'), 'FLOAT64'); + }); + + test('maps DECIMAL(10,2) to DECIMAL(10,2)', () { + expect(mapMySqlDeclaredTypeToDecentDb('DECIMAL(10,2)'), 'DECIMAL(10,2)'); + }); + + test('maps BOOLEAN to BOOLEAN', () { + expect(mapMySqlDeclaredTypeToDecentDb('BOOLEAN'), 'BOOLEAN'); + }); + + test('maps TINYINT(1) to BOOLEAN', () { + expect(mapMySqlDeclaredTypeToDecentDb('TINYINT(1)'), 'BOOLEAN'); + }); + + test('maps BLOB to BLOB', () { + expect(mapMySqlDeclaredTypeToDecentDb('BLOB'), 'BLOB'); + }); + + test('maps DATETIME to TIMESTAMP', () { + expect(mapMySqlDeclaredTypeToDecentDb('DATETIME'), 'TIMESTAMP'); + }); + + test('maps empty string to TEXT', () { + expect(mapMySqlDeclaredTypeToDecentDb(''), 'TEXT'); + }); + + test('maps CHAR(36) to UUID', () { + expect(mapMySqlDeclaredTypeToDecentDb('CHAR(36)'), 'UUID'); + }); + + test('maps DECIMAL without precision to DECIMAL(18,6)', () { + expect(mapMySqlDeclaredTypeToDecentDb('DECIMAL'), 'DECIMAL(18,6)'); + }); + }); + + group('materializeSqlDumpSourceFile', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync( + 'decent-bench-sql-dump-mat-', + ); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('materializes selected tables with rows', () { + final path = p.join(tempDir.path, 'dump.sql'); + File(path).writeAsStringSync(''' +CREATE TABLE users (id INT, name TEXT); +INSERT INTO users VALUES (1, 'Ada'); +INSERT INTO users VALUES (2, 'Lin'); +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + final result = materializeSqlDumpSourceFile( + path, + encoding: 'auto', + tables: inspection.tables, + ); + + expect(result.tables, hasLength(1)); + expect(result.tables.first.sourceName, 'users'); + expect(result.tables.first.rows, hasLength(2)); + expect(result.tables.first.rows[0]['id'], 1); + expect(result.tables.first.rows[0]['name'], 'Ada'); + }); + + test('materializes only selected tables', () { + final path = p.join(tempDir.path, 'dump.sql'); + File(path).writeAsStringSync(''' +CREATE TABLE a (id INT); +CREATE TABLE b (id INT); +INSERT INTO a VALUES (1); +INSERT INTO b VALUES (2); +'''); + + final inspection = inspectSqlDumpSourceFile(path, encoding: 'auto'); + final onlyB = inspection.tables + .where((t) => t.sourceName == 'b') + .toList(); + + final result = materializeSqlDumpSourceFile( + path, + encoding: 'auto', + tables: onlyB, + ); + + expect(result.tables, hasLength(1)); + expect(result.tables.first.sourceName, 'b'); + expect(result.tables.first.rows, hasLength(1)); + }); + }); +} diff --git a/apps/decent-bench/test/features/workspace/infrastructure/sqlite_import_support_test.dart b/apps/decent-bench/test/features/workspace/infrastructure/sqlite_import_support_test.dart new file mode 100644 index 0000000..00dfd8d --- /dev/null +++ b/apps/decent-bench/test/features/workspace/infrastructure/sqlite_import_support_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:decent_bench/features/workspace/infrastructure/sqlite_import_support.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqlite3/sqlite3.dart' as sqlite; + +void main() { + group('tryParseSqliteTimestampValue', () { + test( + 'parses ISO timestamps with more than 6 fractional digits and offsets', + () { + final parsed = tryParseSqliteTimestampValue( + '2024-12-30T10:02:04.901481207-06:00', + ); + + expect(parsed?.toIso8601String(), '2024-12-30T16:02:04.901481Z'); + }, + ); + + test( + 'parses space-separated timestamps with more than 6 fractional digits', + () { + final parsed = tryParseSqliteTimestampValue( + '2025-01-01 11:00:42.24883752-06:00', + ); + + expect(parsed?.toIso8601String(), '2025-01-01T17:00:42.248837Z'); + }, + ); + + test('returns null for non-temporal text', () { + expect(tryParseSqliteTimestampValue('not-a-timestamp'), isNull); + }); + }); + + group('inspectSqliteSourceFile', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync( + 'decent-bench-sqlite-inspect-', + ); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test( + 'keeps text when timestamp-like samples miss later empty string values', + () { + final sourcePath = p.join(tempDir.path, 'sparse-dates.sqlite'); + final database = sqlite.sqlite3.open(sourcePath); + try { + database.execute(''' +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + release_date TEXT NOT NULL +) +'''); + for (var index = 1; index <= 64; index++) { + database.execute( + "INSERT INTO events VALUES ($index, '2026-03-10')", + ); + } + database.execute("INSERT INTO events VALUES (65, '')"); + } finally { + database.close(); + } + + final inspection = inspectSqliteSourceFile(sourcePath); + final column = inspection.tables.single.columns.singleWhere( + (candidate) => candidate.sourceName == 'release_date', + ); + + expect(column.targetType, 'TEXT'); + }, + ); + }); +} diff --git a/apps/decent-bench/test/features/workspace/presentation/shell/app_menu_bar_test.dart b/apps/decent-bench/test/features/workspace/presentation/shell/app_menu_bar_test.dart new file mode 100644 index 0000000..cf167c4 --- /dev/null +++ b/apps/decent-bench/test/features/workspace/presentation/shell/app_menu_bar_test.dart @@ -0,0 +1,39 @@ +import 'package:decent_bench/app/theme.dart'; +import 'package:decent_bench/app/theme_system/theme_presets.dart'; +import 'package:decent_bench/features/workspace/application/menu_command_registry.dart'; +import 'package:decent_bench/features/workspace/presentation/shell/app_menu_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('tools menu includes View Log', (tester) async { + final registry = MenuCommandRegistry( + commands: [ + MenuCommand( + id: 'tools_view_log', + label: 'View Log', + icon: Icons.receipt_long_outlined, + onInvoke: () async {}, + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildDecentBenchTheme(buildEmergencyTheme()), + home: Scaffold( + body: AppMenuBar( + registry: registry, + recentFiles: const [], + onOpenRecent: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.text('Tools')); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.widgetWithText(MenuItemButton, 'View Log'), findsOneWidget); + }); +} diff --git a/apps/decent-bench/test/shared/widgets/import_failure_dialog_test.dart b/apps/decent-bench/test/shared/widgets/import_failure_dialog_test.dart new file mode 100644 index 0000000..b01ce69 --- /dev/null +++ b/apps/decent-bench/test/shared/widgets/import_failure_dialog_test.dart @@ -0,0 +1,17 @@ +import 'package:decent_bench/shared/widgets/import_failure_dialog.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('summarizeImportFailure uses the first non-empty line', () { + expect( + summarizeImportFailure( + '\n Import failed while copying rows.\nDecentDbException(sql): full details', + ), + 'Import failed while copying rows.', + ); + }); + + test('summarizeImportFailure falls back when the message is blank', () { + expect(summarizeImportFailure(' \n\t '), 'The import failed.'); + }); +} diff --git a/apps/decent-bench/test/support/fakes.dart b/apps/decent-bench/test/support/fakes.dart index 561cf9e..7c631a3 100644 --- a/apps/decent-bench/test/support/fakes.dart +++ b/apps/decent-bench/test/support/fakes.dart @@ -764,6 +764,15 @@ class FakeWorkspaceGateway implements WorkspaceDatabaseGateway { for (final table in request.selectedTables) for (final index in table.indexes) index.name, ], + targetTableCount: request.selectedTables.length, + targetIndexCount: request.selectedTables.fold( + 0, + (sum, table) => sum + table.indexes.length, + ), + targetViewCount: 0, + targetTriggerCount: 0, + databaseFileBytes: 8192, + walFileBytes: 0, skippedItems: [ for (final table in request.selectedTables) ...table.skippedItems, ], @@ -784,6 +793,12 @@ class FakeWorkspaceGateway implements WorkspaceDatabaseGateway { importedTables: const [], rowsCopiedByTable: const {}, indexesCreated: const [], + targetTableCount: 0, + targetIndexCount: 0, + targetViewCount: 0, + targetTriggerCount: 0, + databaseFileBytes: 0, + walFileBytes: 0, skippedItems: const [], warnings: const [], statusMessage: 'SQLite import cancelled and rolled back.', @@ -797,6 +812,12 @@ class FakeWorkspaceGateway implements WorkspaceDatabaseGateway { importedTables: const [], rowsCopiedByTable: const {}, indexesCreated: const [], + targetTableCount: 0, + targetIndexCount: 0, + targetViewCount: 0, + targetTriggerCount: 0, + databaseFileBytes: 0, + walFileBytes: 0, skippedItems: const [], warnings: sqliteInspection.warnings, statusMessage: 'SQLite import cancelled and rolled back.', diff --git a/apps/decent-bench/test/support/import_fixture_manifest.dart b/apps/decent-bench/test/support/import_fixture_manifest.dart index e5f7643..771f4a4 100644 --- a/apps/decent-bench/test/support/import_fixture_manifest.dart +++ b/apps/decent-bench/test/support/import_fixture_manifest.dart @@ -619,15 +619,15 @@ const List detectionFixtures = [ ), DetectionFixtureEntry( relativePath: 'test-data/sql_related/mysql_mock_export.bak', - expectedFormatKey: ImportFormatKey.unknown, - expectedSupportState: ImportSupportState.notStarted, - expectedImplementationKind: ImportImplementationKind.unknown, + expectedFormatKey: ImportFormatKey.msSqlBak, + expectedSupportState: ImportSupportState.investigate, + expectedImplementationKind: ImportImplementationKind.recognizedUnsupported, ), DetectionFixtureEntry( relativePath: 'test-data/sql_related/mariadb_mock_export.bak', - expectedFormatKey: ImportFormatKey.unknown, - expectedSupportState: ImportSupportState.notStarted, - expectedImplementationKind: ImportImplementationKind.unknown, + expectedFormatKey: ImportFormatKey.msSqlBak, + expectedSupportState: ImportSupportState.investigate, + expectedImplementationKind: ImportImplementationKind.recognizedUnsupported, ), DetectionFixtureEntry( relativePath: 'test-data/sql_related/postgresql_mock_binary.dump', diff --git a/apps/decent-bench/tool/stage_decentdb_native.dart b/apps/decent-bench/tool/stage_decentdb_native.dart index 7a9a833..a1e3470 100644 --- a/apps/decent-bench/tool/stage_decentdb_native.dart +++ b/apps/decent-bench/tool/stage_decentdb_native.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:decent_bench/features/workspace/infrastructure/decentdb_native_release_asset.dart'; import 'package:decent_bench/features/workspace/infrastructure/native_library_resolver.dart'; import 'package:path/path.dart' as p; @@ -47,7 +48,9 @@ Future main(List args) async { final sourceFile = File( sourcePath?.isNotEmpty == true ? sourcePath! - : await resolver.resolvePackagingSource(), + : await DecentDbNativeReleaseAsset.ensureAvailableForCurrentProject( + startPath: Directory.current.path, + ), ); if (!sourceFile.existsSync()) { stderr.writeln( diff --git a/apps/decent-bench/windows/runner/Runner.rc b/apps/decent-bench/windows/runner/Runner.rc index 892f686..0e6f803 100644 --- a/apps/decent-bench/windows/runner/Runner.rc +++ b/apps/decent-bench/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0,0 +#define VERSION_AS_NUMBER 1,1,0,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "1.0.0" +#define VERSION_AS_STRING "1.1.0" #endif VS_VERSION_INFO VERSIONINFO diff --git a/design/SPEC.md b/design/SPEC.md index 9b3fffb..79a0bad 100644 --- a/design/SPEC.md +++ b/design/SPEC.md @@ -231,6 +231,21 @@ concerns is required. - show a clear notice when the supplied path is missing or not an importable source type +### 4.1a Headless CLI import mode (v1.0.0+) + +Decent Bench also ships a headless import CLI mode that runs without the +desktop UI: + +- `dbench --in --out ` runs a headless import + using inferred defaults +- `dbench --in --out --plan ` runs + a headless import with explicit import options from a versioned JSON plan + file (see `docs/HEADLESS_IMPORT_PLAN_DETAILS.md`) +- `dbench --silent` suppresses non-error console output in headless mode + +This mode is governed by ADR-0022 and is intended for scripting and batch +workflows. It is a shipped v1.0.0 feature and is not considered experimental. + **Multi-drop:** - If more than one file is dropped: - take the first diff --git a/design/adr/0015-custom-sql-editor-surface-rendering.md b/design/adr/0015-custom-sql-editor-surface-rendering.md index d56e288..4f73275 100644 --- a/design/adr/0015-custom-sql-editor-surface-rendering.md +++ b/design/adr/0015-custom-sql-editor-surface-rendering.md @@ -51,6 +51,6 @@ behavior. ### References - `design/THEME_SYSTEM.md` -- `design/adr/0013-external-toml-theme-system.md` +- `design/adr/0023-external-toml-theme-system.md` - `design/adr/0014-desktop-editor-and-context-workflows.md` - `apps/decent-bench/lib/features/workspace/presentation/shell/sql_editor_pane.dart` diff --git a/design/adr/0013-external-toml-theme-system.md b/design/adr/0023-external-toml-theme-system.md similarity index 98% rename from design/adr/0013-external-toml-theme-system.md rename to design/adr/0023-external-toml-theme-system.md index f50f7db..4858b50 100644 --- a/design/adr/0013-external-toml-theme-system.md +++ b/design/adr/0023-external-toml-theme-system.md @@ -1,4 +1,4 @@ -# 0013-external-toml-theme-system +# 0023-external-toml-theme-system - **Status:** Proposed - **Date:** 2026-03-10 diff --git a/design/adr/0024-import-scope-expansion-beyond-prd-mvp.md b/design/adr/0024-import-scope-expansion-beyond-prd-mvp.md new file mode 100644 index 0000000..cd3fbb0 --- /dev/null +++ b/design/adr/0024-import-scope-expansion-beyond-prd-mvp.md @@ -0,0 +1,57 @@ +## Import Scope Expansion Beyond PRD MVP +**Date:** 2026-04-21 +**Status:** Accepted + +### Decision + +Decent Bench v1.0.0 ships with import support for formats beyond those listed +in the original PRD MVP scope. The PRD specified Excel, SQLite, and +MariaDB/MySQL `.sql` dumps. The shipped product additionally supports: + +- CSV, TSV, and generic delimited text (PSV, custom delimiters) +- JSON and NDJSON/JSONL +- XML +- HTML tables +- ZIP and GZip archive wrapper routing + +These formats route through the generic import wizard pipeline introduced by +ADR-0019 rather than the legacy per-format wizards. The PRD MVP formats +(Excel, SQLite, SQL dump) continue using their existing dedicated wizards. + +### Rationale + +The import format registry (ADR-0019) established a shared detection and +routing architecture that made adding new format families low-risk. The +generic import pipeline handles preview, type inference, and execution for +text, structured, and web-markup formats through a single wizard, avoiding +the need for per-format UI code. + +The supported test-data fixtures already included CSV, JSON, XML, and HTML +samples for validation purposes. Extending import support to these formats +was a natural fit for the registry architecture and did not destabilize the +existing MVP import wizards. + +### Alternatives Considered + +- Ship v1.0.0 with only the three PRD MVP formats and defer all others +- Add formats incrementally in minor releases after v1.0.0 +- Rewrite existing MVP wizards into the generic pipeline before shipping + +### Trade-offs + +- The app has two import implementation paths (legacy wizards and generic + wizard) as documented in ADR-0019 +- Documentation must reflect the broader format support matrix +- The PRD import scope section (8.3) does not list these additional formats, + creating a documentation gap that this ADR addresses + +These trade-offs are acceptable because the generic wizard provides a uniform +user experience for the new formats and the legacy wizards remain stable. + +### References + +- `design/PRD.md` (Section 8.3: Supported imports for MVP) +- `design/SPEC.md` (Section 7: Import specifications) +- `design/adr/0019-import-format-registry-and-generic-wizard.md` +- `apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart` +- `docs/IMPORT_FORMATS.md` diff --git a/design/adr/0025-decentdb-git-dependency-rationale.md b/design/adr/0025-decentdb-git-dependency-rationale.md new file mode 100644 index 0000000..6257d4c --- /dev/null +++ b/design/adr/0025-decentdb-git-dependency-rationale.md @@ -0,0 +1,54 @@ +## DecentDB Git Dependency Rationale +**Date:** 2026-04-21 +**Status:** Accepted + +### Decision + +Decent Bench depends on the `decentdb` Dart package via a **Git dependency** +pinned to a specific tag, rather than a pub.dev hosted dependency. + +```yaml +decentdb: + git: + url: https://github.com/sphildreth/decentdb + path: bindings/dart/dart + ref: v2.3.0 +``` + +### Rationale + +The `decentdb` package is maintained by the same organization as Decent Bench +and is not currently published to pub.dev. Using a Git dependency pinned to a +tag provides: + +- Reproducible builds via the locked `ref` +- Direct access to the upstream Dart FFI bindings without a publishing delay +- Alignment between the Dart binding version and the native library version + staged during CI builds + +The `pubspec.lock` file ensures that all CI and developer builds resolve the +same commit. CI extracts the pinned tag from `pubspec.lock` to download the +matching native library release asset. + +### Alternatives Considered + +- Publish `decentdb` to pub.dev as a hosted dependency +- Use a local path dependency during development and Git for CI +- Vendor the binding source into the Decent Bench repository + +### Trade-offs + +- Git dependencies resolve more slowly than pub.dev hosted dependencies +- Some tooling (e.g., `dart pub deps --style=compact`) may produce more + verbose output for Git dependencies +- The project cannot use `pub.dev` automated version solving for `decentdb` + +These trade-offs are acceptable because the upstream package is co-maintained +and the tag-pinning strategy provides deterministic resolution. + +### References + +- `apps/decent-bench/pubspec.yaml` +- `apps/decent-bench/pubspec.lock` +- `.github/workflows/flutter-build.yml` +- `design/adr/0001-decentdb-flutter-binding-strategy.md` diff --git a/design/adr/0026-tar-bzip2-gzip-archive-import.md b/design/adr/0026-tar-bzip2-gzip-archive-import.md new file mode 100644 index 0000000..dc0f95c --- /dev/null +++ b/design/adr/0026-tar-bzip2-gzip-archive-import.md @@ -0,0 +1,69 @@ +## Tar+BZip2 and Tar+GZip Archive Import +**Date:** 2026-04-21 +**Status:** Accepted + +### Decision + +Decent Bench supports `.tar.bz2` and `.tar.gz` archive wrappers for import. +These formats are common for PostgreSQL data dumps and other large tabular +datasets distributed as compressed tar archives. + +The implementation uses the **system `tar` command** for listing and extracting +archive contents rather than the Dart `archive` package. This avoids loading +entire compressed archives into memory, which is essential for files that can +range from hundreds of megabytes to several gigabytes compressed. + +BZip2 single-file decompression (non-tar `.bz2` files) uses the Dart `archive` +package's `BZip2Decoder` for small files, consistent with the existing GZip +single-file path. + +### Rationale + +PostgreSQL dump archives like MusicBrainz `mbdump.tar.bz2` contain many +tab-separated value files without extensions inside a tar archive compressed +with bzip2. These files can exceed 1 GB compressed and 10 GB uncompressed. + +The `archive` package's `TarDecoder` and `BZip2Decoder` both require the full +decompressed data in memory, making them impractical for large archives. The +system `tar` command streams extraction and can handle arbitrary sizes. + +The system command approach is consistent with the existing LibreOffice +conversion pattern in `excel_source_preparer.dart`, which also uses +`Process.runSync` for native tool integration. + +### Inner file handling + +Files inside tar archives may lack file extensions (e.g., `mbdump/artist`). +The detection service infers the format for extensionless entries by defaulting +to TSV (tab-separated values), which is the standard format for PostgreSQL +dump files. When extracting, a `.tsv` extension is appended to the temp file +so that downstream detection routes it to the generic import wizard. + +### Alternatives Considered + +- Use the `archive` package for full in-memory decompression and tar parsing +- Implement a streaming tar parser in Dart +- Require users to extract tar.bz2 externally before importing +- Support only small archives with an in-memory approach + +### Trade-offs + +- Requires the `tar` command to be available on the host system (available on + Linux, macOS, and Windows 10+) +- Single-file bzip2 decompression for large files still uses in-memory + `BZip2Decoder`; this is acceptable for non-tar use cases which are typically + smaller +- Extensionless inner files default to TSV inference, which may not match + all tar archive contents; users can adjust delimiter settings in the + generic import wizard +- The `tar -tjf` listing step decompresses the full archive to read the file + list; this is unavoidable with bzip2's block structure but tar's streaming + output keeps memory bounded on the tar side + +### References + +- `apps/decent-bench/lib/features/import/infrastructure/import_detection_service.dart` +- `apps/decent-bench/lib/features/import/infrastructure/import_format_registry.dart` +- `apps/decent-bench/lib/features/workspace/infrastructure/excel_source_preparer.dart` +- `design/adr/0019-import-format-registry-and-generic-wizard.md` +- `design/adr/0024-import-scope-expansion-beyond-prd-mvp.md` diff --git a/design/adr/0027-container-assisted-proprietary-imports.md b/design/adr/0027-container-assisted-proprietary-imports.md new file mode 100644 index 0000000..2e18287 --- /dev/null +++ b/design/adr/0027-container-assisted-proprietary-imports.md @@ -0,0 +1,43 @@ +## Container-Assisted Proprietary Format Imports (MS SQL .bak) +**Date:** 2026-04-21 +**Status:** Proposed + +### Context + +There is an ongoing user requirement to support importing MS SQL Server backup files (`.bak`). These files are proprietary Microsoft Tape Format archives encapsulating internal raw page structures (MDF/LDF) that have version-specific binary layouts. There are no robust FOSS libraries available in the Dart, Rust, or C++ ecosystems capable of directly parsing these structures correctly without the official engine. + +Attempting to build or maintain a custom parser for such complex and shifting proprietary binary structures represents an enormous engineering burden and is fundamentally unscalable. + +### Decision + +We will implement a **"Container-Assisted Import"** strategy to support proprietary database backups like MS SQL `.bak` files. + +When a user attempts to import a `.bak` file, Decent-Bench will: +1. Verify if the local environment has Docker (or a compatible container runtime) available. +2. Spin up a temporary, isolated Docker container running the official database engine (e.g., `mcr.microsoft.com/mssql/server:2022-latest`). +3. Mount the target `.bak` file into the container. +4. Execute engine-native commands (e.g., `RESTORE DATABASE`) to instantiate the data inside the container. +5. Connect to the containerized engine using standard FOSS drivers (e.g., FreeTDS / JDBC) to query the system catalogs, extract the schema, and stream the data over standard protocols into the target DecentDB instance. +6. Gracefully tear down the container and clean up temporary volumes once the import succeeds or fails. + +### Rationale + +* **Accuracy & Reliability**: Relying on the official engine guarantees 100% accurate reads of the backup files, regardless of complex internal structures or SQL Server versioning nuances. +* **Maintainability**: Offloads the burden of keeping up with proprietary binary changes to the vendor's official Docker images. +* **Reusability**: This pattern can be extended to other historically difficult formats such as Oracle `.dmp` files or PostgreSQL custom dumps (`pg_dump -Fc`), expanding Decent-Bench's import capabilities significantly. +* **Ecosystem Alignment**: Modern developer environments typically already have a container runtime installed, making this a reasonable prerequisite for advanced/proprietary imports. + +### Alternatives Considered + +* **Custom Binary Parsing (e.g., reviving OrcaMDF logic)**: Rejected. High complexity, high risk of data corruption, and significant maintenance overhead trying to reverse-engineer undocumented structures. +* **Requiring Users to Run Local SQL Server**: Rejected. Defeats the purpose of Decent-Bench being a seamless cross-platform desktop application; forces the user into complex manual setup. +* **Cloud-based Conversion Service**: Rejected. Introduces privacy concerns, bandwidth costs, and breaks the "local-first" ethos of Decent-Bench. + +### Trade-offs + +* **Prerequisites**: The user *must* have Docker Desktop or a local container daemon installed and running for this specific feature to work. +* **Resource Overhead**: Spinning up a full SQL Server container requires significant memory (often 2GB+) and temporary disk space. +* **UX Complexity**: We must clearly communicate to the user when Docker is required, handle missing Docker daemon scenarios gracefully with clear error messages, and ensure containers are reliably torn down even if the application crashes. + +### References +* [Decent-Bench PRD: Import Requirements](../PRD.md) \ No newline at end of file diff --git a/test-data/sql_related/mysql_mock_export.bak b/test-data/sql_related/mysql_mock_export.bak index d6b51ad..704a9d9 100644 --- a/test-data/sql_related/mysql_mock_export.bak +++ b/test-data/sql_related/mysql_mock_export.bak @@ -1,3 +1,3 @@ MOCK FILE FOR DETECTION/UI TESTING ONLY This is NOT a real MySQL backup format. -Use it to test .bak handling, unsupported messages, and import workflow branching. +Use it to test .bak handling and import workflow branching.