diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bd4a557 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# Normalize line endings: store text files as LF in the repository, +# regardless of the contributor's OS. Working-tree checkout stays native. +* text=auto eol=lf + +# Windows scripts stay CRLF byte-for-byte (Gradle ships gradlew.bat as CRLF); +# -text means git never converts them, so they are not normalized to LF. +*.bat -text +*.cmd -text +*.ps1 -text + +# Binary artifacts — never touch their bytes. +*.png binary +*.webp binary +*.jpg binary +*.jpeg binary +*.ico binary +*.so binary +*.dylib binary +*.dll binary +*.a binary +*.jar binary +*.keystore binary +*.xcuserstate binary diff --git a/.github/actions/cache-gradle-wrapper/action.yml b/.github/actions/cache-gradle-wrapper/action.yml new file mode 100644 index 0000000..9b75b0c --- /dev/null +++ b/.github/actions/cache-gradle-wrapper/action.yml @@ -0,0 +1,10 @@ +name: Cache Gradle wrapper +description: Cache Gradle wrapper distributions +runs: + using: composite + steps: + - name: Cache Gradle wrapper + uses: actions/cache@v4 + with: + path: ~/.gradle/wrapper/dists + key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} diff --git a/.github/actions/cache-konan/action.yml b/.github/actions/cache-konan/action.yml new file mode 100644 index 0000000..e727ada --- /dev/null +++ b/.github/actions/cache-konan/action.yml @@ -0,0 +1,17 @@ +name: Cache Kotlin/Native (konan) +description: >- + Cache the ~/.konan toolchain (Kotlin/Native compiler, LLVM, platform sysroots) so native jobs do + not re-download it from scratch on every run. +runs: + using: composite + steps: + - name: Cache konan + uses: actions/cache@v4 + with: + path: ~/.konan + # The konan toolchain version tracks the Kotlin version, which lives in libs.versions.toml. + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + # On a miss (e.g. Kotlin bumped) restore the previous konan and let it download only the + # delta, instead of pulling the whole toolchain again. + restore-keys: | + konan-${{ runner.os }}- diff --git a/.github/actions/check-publish-secrets/action.yml b/.github/actions/check-publish-secrets/action.yml new file mode 100644 index 0000000..a9ca2a7 --- /dev/null +++ b/.github/actions/check-publish-secrets/action.yml @@ -0,0 +1,41 @@ +name: Check publish secrets +description: Fail fast when Maven Central or GPG secrets are missing +inputs: + maven_username: + description: Maven Central username + required: true + maven_password: + description: Maven Central password + required: true + gpg_private_key: + description: GPG private key + required: true + gpg_passphrase: + description: GPG passphrase + required: true +runs: + using: composite + steps: + - name: Verify publish secrets + shell: bash + run: | + missing=0 + if [ -z "${{ inputs.maven_username }}" ]; then + echo "Missing secret: MAVEN_CENTRAL_USERNAME" + missing=1 + fi + if [ -z "${{ inputs.maven_password }}" ]; then + echo "Missing secret: MAVEN_CENTRAL_PASSWORD" + missing=1 + fi + if [ -z "${{ inputs.gpg_private_key }}" ]; then + echo "Missing secret: GPG_PRIVATE_KEY" + missing=1 + fi + if [ -z "${{ inputs.gpg_passphrase }}" ]; then + echo "Missing secret: GPG_PASSPHRASE" + missing=1 + fi + if [ "$missing" -ne 0 ]; then + exit 1 + fi diff --git a/.github/actions/setup-android-tools/action.yml b/.github/actions/setup-android-tools/action.yml new file mode 100644 index 0000000..599f555 --- /dev/null +++ b/.github/actions/setup-android-tools/action.yml @@ -0,0 +1,42 @@ +name: Setup Android SDK/NDK/CMake +description: Install Android SDK, required NDK, and CMake for native builds. + +runs: + using: "composite" + steps: + - name: Configure Android SDK environment + shell: bash + run: | + set -euo pipefail + case "${RUNNER_OS}" in + Linux) SDK_ROOT="$HOME/android-sdk" ;; + macOS) SDK_ROOT="$HOME/Library/Android/sdk" ;; + *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;; + esac + echo "ANDROID_SDK_ROOT=${SDK_ROOT}" >> "${GITHUB_ENV}" + echo "ANDROID_HOME=${SDK_ROOT}" >> "${GITHUB_ENV}" + echo "${SDK_ROOT}/cmdline-tools/latest/bin" >> "${GITHUB_PATH}" + echo "${SDK_ROOT}/platform-tools" >> "${GITHUB_PATH}" + + - name: Cache Android SDK + uses: actions/cache@v4 + env: + ANDROID_SDK_CACHE_KEY: >- + android-sdk-${{ runner.os }}-${{ hashFiles( + '.github/actions/setup-android-tools/action.yml', + '.github/actions/setup-android-tools/install_cmdline_tools.sh', + '.github/actions/setup-android-tools/install_sdk_packages.sh' + ) }} + with: + path: ${{ env.ANDROID_SDK_ROOT }} + key: ${{ env.ANDROID_SDK_CACHE_KEY }} + restore-keys: | + android-sdk-${{ runner.os }}- + + - name: Install Android commandline tools + shell: bash + run: bash ./.github/actions/setup-android-tools/install_cmdline_tools.sh + + - name: Install Android SDK packages + shell: bash + run: bash ./.github/actions/setup-android-tools/install_sdk_packages.sh diff --git a/.github/actions/setup-android-tools/install_cmdline_tools.sh b/.github/actions/setup-android-tools/install_cmdline_tools.sh new file mode 100644 index 0000000..6377301 --- /dev/null +++ b/.github/actions/setup-android-tools/install_cmdline_tools.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v sdkmanager >/dev/null 2>&1; then + exit 0 +fi + +mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" +case "${RUNNER_OS}" in + Linux) TOOLS_ZIP="commandlinetools-linux-14742923_latest.zip" ;; + macOS) TOOLS_ZIP="commandlinetools-mac-14742923_latest.zip" ;; + *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;; +esac +export TOOLS_ZIP + +curl -fsSL "https://dl.google.com/android/repository/${TOOLS_ZIP}" -o "${RUNNER_TEMP}/${TOOLS_ZIP}" +if command -v unzip >/dev/null 2>&1; then + unzip -q "${RUNNER_TEMP}/${TOOLS_ZIP}" -d "${ANDROID_SDK_ROOT}/cmdline-tools" +else + python3 -c 'import os, zipfile; sdk_root=os.environ["ANDROID_SDK_ROOT"]; zip_path=os.path.join(os.environ["RUNNER_TEMP"], os.environ["TOOLS_ZIP"]); dest_dir=os.path.join(sdk_root, "cmdline-tools"); zipfile.ZipFile(zip_path, "r").extractall(dest_dir)' +fi +mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" diff --git a/.github/actions/setup-android-tools/install_sdk_packages.sh b/.github/actions/setup-android-tools/install_sdk_packages.sh new file mode 100644 index 0000000..7f34917 --- /dev/null +++ b/.github/actions/setup-android-tools/install_sdk_packages.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null || true +# API 37 is published only as the versioned package platforms;android-37.0 — there is no bare +# platforms;android-37 in the SDK repo (see actions/runner-images#13859). +sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" "platform-tools" "platforms;android-37.0" "ndk;29.0.14206865" "cmake;4.1.2" +# AGP resolves compileSdk 37 to the target hash "android-37", but the platform installs into +# platforms/android-37.0. Alias it so Gradle can find the platform. -sfn keeps it idempotent across +# cache-restored runs (overwrites a stale link instead of nesting one inside the target dir). +ln -sfn android-37.0 "${ANDROID_SDK_ROOT}/platforms/android-37" diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..73cbf68 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,16 @@ +name: LevelDB Multiplatform CodeQL Config + +paths-ignore: + - "**/.gradle/**" + - "**/build/**" + - "**/out/**" + - "**/bin/**" + - "**/.idea/**" + - "**/.kotlin/**" + - "**/node_modules/**" + - "android-example/**" + - "ios-example/**" + - "native/leveldb/**" + - "native/.dockcross*/**" + - "native/prebuilt/**" + - "third_party/**" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index 572a354..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Android CI - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: set up NDK - uses: nttld/setup-ndk@v1.0.6 - with: - ndk-version: r23 - - name: set up JDK 11 - uses: actions/setup-java@v2 - with: - java-version: '11' - distribution: 'adopt' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5186c3e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: PR Check +permissions: + contents: read + pull-requests: write +on: + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + gradle_args: ":leveldb:check :leveldb:koverXmlReport" + - os: macos-latest + gradle_args: ":leveldb:check" + - os: windows-latest + gradle_args: ":leveldb:mingwX64Test" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Set up Android tools + if: runner.os == 'Linux' + uses: ./.github/actions/setup-android-tools + + - name: Cache Gradle wrapper + uses: ./.github/actions/cache-gradle-wrapper + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Cache konan + uses: ./.github/actions/cache-konan + + - name: Run tests + env: + JAVA_TOOL_OPTIONS: "-XX:+CreateCoredumpOnCrash -XX:ErrorFile=leveldb/hs_err_pid%p.log" + run: ./gradlew ${{ matrix.gradle_args }} + + - name: Post coverage to PR + if: matrix.os == 'ubuntu-22.04' + uses: mi-kas/kover-report@v1 + with: + path: leveldb/build/reports/kover/report.xml + token: ${{ secrets.GITHUB_TOKEN }} + title: Code coverage + update-comment: true + + - name: Upload JVM crash logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: jvm-crash-${{ matrix.os }} + path: | + leveldb/hs_err_pid*.log + leveldb/core* diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a10ee21 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,63 @@ +name: CodeQL + +on: + pull_request: + push: + branches: [ master ] + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: c-cpp + build-mode: manual + # kotlin 2.3 is not supported by CodeQL yet + # - language: java-kotlin + # build-mode: manual + - language: actions + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Setup Java + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Build native C/C++ (for CodeQL) + if: matrix.language == 'c-cpp' + working-directory: native + run: | + cmake --preset linux-x86_64-debug + cmake --build --preset linux-x86_64-debug + + - name: Build Java/Kotlin + if: matrix.language == 'java-kotlin' + run: | + ./gradlew :leveldb:check --no-daemon + + - name: Analyze + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..684e47c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,147 @@ +name: Publish +permissions: + contents: read +on: + push: + tags: + - "*.*.*" + - "*.*.*-*" + workflow_dispatch: + inputs: + snapshot: + description: "Publish a -SNAPSHOT from the selected ref (overwritable; ignores 'version')" + required: false + default: true + type: boolean + version: + description: "Release version override (e.g. 2.0.1). Used only when snapshot = false" + required: false + type: string + full_tests: + description: "Run tests on all OS before publishing" + required: false + default: false + type: boolean + +jobs: + test: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.full_tests == 'true') && '["ubuntu-22.04","macos-latest"]' || '["ubuntu-22.04"]') }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Set up Android tools + if: runner.os == 'Linux' + uses: ./.github/actions/setup-android-tools + + - name: Cache Gradle wrapper + uses: ./.github/actions/cache-gradle-wrapper + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Cache konan + uses: ./.github/actions/cache-konan + + - name: Run tests + run: ./gradlew :leveldb:check + + publish: + name: Publish (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: test + environment: release + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + install_mingw: true + publish_tasks: >- + :leveldb:publishKotlinMultiplatformPublicationToMavenCentralRepository + :leveldb:publishJvmPublicationToMavenCentralRepository + :leveldb:publishAndroidPublicationToMavenCentralRepository + :leveldb-android-native:publishReleasePublicationToMavenCentralRepository + :leveldb:publishLinuxX64PublicationToMavenCentralRepository + :leveldb:publishMingwX64PublicationToMavenCentralRepository + :leveldb:publishLinuxArm64PublicationToMavenCentralRepository + - os: macos-latest + install_mingw: false + publish_tasks: >- + :leveldb:publishMacosArm64PublicationToMavenCentralRepository + :leveldb:publishMacosX64PublicationToMavenCentralRepository + :leveldb:publishIosArm64PublicationToMavenCentralRepository + :leveldb:publishIosSimulatorArm64PublicationToMavenCentralRepository + + steps: + - name: Verify publish secrets + uses: ./.github/actions/check-publish-secrets + with: + maven_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + maven_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Install mingw-w64 + if: matrix.install_mingw + run: sudo apt-get update && sudo apt-get install -y mingw-w64 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Set up Android tools + if: runner.os == 'Linux' + uses: ./.github/actions/setup-android-tools + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Cache konan + uses: ./.github/actions/cache-konan + + - name: Publish + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_PASSPHRASE }} + run: | + VERSION_ARG="" + if [ "${{ github.event.inputs.snapshot }}" = "true" ]; then + # workflow_dispatch snapshot: the build derives -SNAPSHOT and vanniktech + # routes it to the Central snapshots repo. Overwritable, no staging/release step. + VERSION_ARG="-Psnapshot=true" + elif [ -n "${{ github.event.inputs.version }}" ]; then + # workflow_dispatch release: explicit version override wins. + VERSION_ARG="-Pversion=${{ github.event.inputs.version }}" + elif [ "${{ github.ref_type }}" = "tag" ]; then + # tag push: the tag (e.g. 2.0.1) IS the version — otherwise the build falls back to the + # hardcoded libVersion and would publish the wrong coordinates. + VERSION_ARG="-Pversion=${{ github.ref_name }}" + fi + # -PsignPublications: CI has the signing key (secrets above), so sign everything we push. + ./gradlew ${VERSION_ARG} -PsignPublications ${{ matrix.publish_tasks }} diff --git a/.gitignore b/.gitignore index 272cd06..dab0336 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,17 @@ build local.properties *.iml - +.kotlin leveldb/.cxx .idea .cxx .extNativeBuild cmake-build* +*.hprof + +# Review notes — kept locally, not tracked +/REVIEW.md + +# Xcode per-user state +**/xcuserdata/ +*.xcuserstate diff --git a/.run/all_checks.run.xml b/.run/all_checks.run.xml new file mode 100644 index 0000000..f989da9 --- /dev/null +++ b/.run/all_checks.run.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf3b103 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Agents Guide + +Use this project primer when making automated edits. + +## What this repo is + +- Kotlin Multiplatform bindings for LevelDB with JVM/Android JNI and Kotlin/Native (desktop/iOS). +- Modules: `leveldb` (KMP core), `leveldb-android-native` (JNI AAR), examples: `android-example`, `ios-example`. + +## Build and test quickstart + +- JDK 17 expected. Android SDK/NDK/CMake from `gradle/libs.versions.toml` (AGP 9.0.0, NDK 29.0.14206865, CMake 4.1.2). +- Primary CI tasks: `./gradlew :leveldb:check` (Linux, macOS), `./gradlew :leveldb:mingwX64Test` (Windows). See `.github/workflows/ci.yml`. +- Native JNI debug build example: `./gradlew :leveldb-android-native:externalNativeBuildDebug` (prebuilts normally used; build only if needed). + +## Native prebuilts + +- Prebuilt libs live under `native/prebuilt` (see `native/README.md`). Avoid regenerating unless required. +- Wrapper scripts: `native/build_prebuilt.sh [release|debug] [preset]` and `native/make_libs.py` manage CMake presets; use only when you must rebuild + host/cross artifacts. +- Don’t edit generated/prebuilt outputs (`native/build`, `native/prebuilt`, `leveldb-android-native/build`, `leveldb/build`); prefer source or CMake + input changes. + +## Android/AGP 9.0.0 caveats + +- AGP 9 uses the new Android DSL; legacy `android {}` accessors may be deprecated/removed. Prefer `com.android.kotlin.multiplatform.library` plugin + for Android KMP. +- AGP 9 is not compatible with applying `com.android.library` alongside `org.jetbrains.kotlin.multiplatform` (see AGP release notes). Keep Android + wiring inside the KMP Android target blocks or dedicated Android-only modules. +- When editing Gradle files, favor the versions/catalog values in `gradle/libs.versions.toml` and keep new DSL patterns. + +## Publishing guardrails + +- Publishing is CI-driven (`.github/workflows/publish.yml`); requires Maven Central and GPG secrets. Do not run publish locally without creds. +- Local scripts `publish_local.sh` / `publish_remote.sh` expect the same env vars (see workflow for names) and will sign artifacts. + +## Safe-change checklist + +- Leave generated/prebuilt outputs untouched; commit only source/Gradle/CMake definitions. +- Keep Kotlin/Native cinterop def files and JNI headers in `native/cinterop` and `native/binding` aligned with native libs. +- Maintain tests: `leveldb` module has common/jvm/native tests; add/adjust tests when changing APIs or native interactions. +- Respect API contracts (e.g., return null for missing properties instead of throwing) and keep throwable filters in sync across expect/actuals. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65373a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +Copyright 2025 Eduard Maximovich + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom +the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR +A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 6f463e2..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,39 +0,0 @@ -Stojan Dimitrovski - -2014 - -In the original BSD license, the occurrence of "copyright holder" in the 3rd -clause read "ORGANIZATION", placeholder for "University of California". In the -original BSD license, both occurrences of the phrase "COPYRIGHT HOLDERS AND -CONTRIBUTORS" in the disclaimer read "REGENTS AND CONTRIBUTORS". - -Here is the license template: - -Copyright (c) 2014, Stojan Dimitrovski - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 9e70625..665b959 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,145 @@ -[![Android Release](https://img.shields.io/maven-central/v/com.edwardstock/leveldb-android?style=flat-square)]\ -[![Kotlin Release](https://img.shields.io/maven-central/v/com.edwardstock/leveldb-kt?style=flat-square)] +# LevelDB Multiplatform -# LevelDB for Android and Kotlin -This Repository based on https://github.com/hf/leveldb-android. +[![CI](https://github.com/edwardstock/leveldb-multiplatform/actions/workflows/ci.yml/badge.svg)](https://github.com/edwardstock/leveldb-multiplatform/actions/workflows/ci.yml) +[![Publish](https://github.com/edwardstock/leveldb-multiplatform/actions/workflows/publish.yml/badge.svg)](https://github.com/edwardstock/leveldb-multiplatform/actions/workflows/publish.yml) +[![Latest release](https://img.shields.io/github/v/release/edwardstock/leveldb-multiplatform)](https://github.com/edwardstock/leveldb-multiplatform/releases/latest) +Kotlin Multiplatform bindings for [LevelDB](https://github.com/google/leveldb) — the same key-value +store, one API, across JVM, Android, and Kotlin/Native. JVM and Android go through JNI; desktop and +iOS use Kotlin/Native directly. -This is a Java wrapper for the amazing -[LevelDB](https://github.com/google/leveldb) by Google. +Forked from [hf/leveldb-android](https://github.com/hf/leveldb-android), rewritten as a multiplatform +library. -## Usage +## Features -Add this to your build.gradle: +- One API for JVM, Android, and Kotlin/Native +- Snapshots and cursor-style iterators +- Typed values via pluggable adapters (primitives out of the box, plus `BigInteger`/`BigDecimal` on JVM) +- `LevelDBInstance` — a managed handle per path: one open database per directory, reentrant `use {}`, + configurable idle-close, and `use {}` that stays main-safe by default +- Schema migrations (`LevelDBSchema` / `LevelDBMigration`) with backup/staging safety policies and + crash-resume +- `useExclusively` for backup, restore, and other whole-directory operations +- In-memory `MockLevelDB` for testing without the native library -```groovy -repositories { - mavenCentral() -} -``` - -And then this as a dependency:\n For Android\n - -```groovy -dependencies { - implementation 'com.edwardstock:leveldb-android:1.0.0' -} -``` - -For Kotlin\n - -```groovy -dependencies { - implementation 'com.edwardstock:leveldb-kt:1.0.0' -} -``` - -## Example - -### Opening, Closing, Putting, Deleting - -```kotlin -val levelDb = LevelDB.open("path/to/leveldb", LevelDB.configure().createIfMissing(true)) -// or u can just - -levelDb.put("leveldb".getBytes(), "Is awesome!") -val result: String? = levelDb.getString("leveldb") -val resultBytes: ByteArray? = levelDb.get("leveldb") +## Supported targets -levelDb.put("magic", byteArrayOf(0, 1, 2, 3, 4)) -val magic: ByteArray? = levelDB.get("magic") +- Android +- JVM +- macOS (x86_64, arm64) +- Linux (x86_64, arm64) +- Windows (x86_64) +- iOS (arm64, simulator arm64) -// !IMPORTANT! you must close it -levelDb.close() -``` - -For Android almost the same, but with other class - -```kotlin -import com.edwardstock.leveldb.* - -// context can be used for place db file: context.filesDir.toString() + File.separator + (dbName ?: LevelDB.DEFAULT_DBNAME) -val levelDb = AndroidLevelDB.open(context, LevelDB.configure().createIfMissing(true)) - -// or also you can use default instance -val levelDb = LevelDB.open(context, LevelDB.configure().createIfMissing(true)) -``` - -### The same, but using try-with-resource - -```java -class Main { - public static myHandler() { +## Installation - try (LevelDB levelDb = LevelDB.open("path/to/leveldb", LevelDB.configure().createIfMissing(true))) { - levelDB.put("leveldb".getBytes(), "Is awesome!".getBytes()); - String value = levelDB.get("leveldb".getBytes()); - - leveldb.put("magic".getBytes(), new byte[]{0, 1, 2, 3, 4}); - byte[] magic = levelDB.getBytes("magic".getBytes()); - } - } -} -``` +> [!IMPORTANT] +> `2.0.0` is not released yet. It's the in-progress multiplatform rewrite and exists only as a +> snapshot for now — don't pin a release version until one ships. To try it, add the Central +> snapshots repository and depend on `2.0.0-SNAPSHOT`: ```kotlin -val levelDb = LevelDB.open("path/to/leveldb") { - createIfMissing(true) +repositories { + maven("https://central.sonatype.com/repository/maven-snapshots/") } -levelDb.use { - levelDb.put("leveldb", "Is awesome!") - val result: String? = levelDb.getString("leveldb") - val resultBytes: ByteArray? = levelDb["leveldb"] - - levelDb.put("magic", byteArrayOf(0, 1, 2, 3, 4)) - val magic: ByteArray? = levelDB["magic"] +dependencies { + implementation("com.edwardstock.leveldb:leveldb:2.0.0-SNAPSHOT") } ``` -### Open using android context - -```kotlin -val context: context - -// it writes db file to: `context.filesDir.toString() + File.separator + DEFAULT_DBNAME` -val levelDb = LevelDB.open(context) { - createIfMissing(true) -} - -levelDb.use { - levelDb.put("leveldb", "Is awesome!") - val result: String? = levelDb.getString("leveldb") - val resultBytes: ByteArray? = levelDb["leveldb"] +Snapshots move — each publish overwrites the last, so re-resolve to pick up changes. The native +libraries load themselves on first use, so there is nothing else to wire up. - levelDb.put("magic", byteArrayOf(0, 1, 2, 3, 4)) - val magic: ByteArray? = levelDB["magic"] -} -``` +## Quick start -### WriteBatch (a.k.a. Transactions) +`LevelDBInstance` is the recommended entry point. Build it once per path and run operations inside +`use {}`: -```java -class My { - public static void myFun() { - LevelDB levelDB = LevelDB.open("path/to/leveldb"); // createIfMissing == true - - levelDB.put("sql".getBytes(), "is lovely!".getBytes()); +```kotlin +import com.edwardstock.leveldb.LevelDBInstance +import com.edwardstock.leveldb.api.getString +import com.edwardstock.leveldb.api.putString +import kotlinx.coroutines.runBlocking - levelDB.writeBatch() - .put("leveldb".getBytes(), "Is awesome!".getBytes()) - .put("magic".getBytes(), new byte[]{0, 1, 2, 3, 4}) - .del("sql".getBytes()) - .write(); // commit transaction +val db = LevelDBInstance.builder("/path/to/db").build() - levelDB.close(); // closing is a must! +runBlocking { + db.use { + putString("hello", "world") + println(getString("hello")) // world } - } - ``` -### Iteration Over Key-Value Pairs - -LevelDB is a key-value store, but it has some nice iteration features. - -Every key-value pair inside LevelDB is ordered. Until the comparator wrapper API is finished you can iterate over your LevelDB in the key's -lexicographical order. - -```java -LevelDB levelDB=LevelDB.open("path/to/leveldb"); - - Iterator iterator=levelDB.iterator(); - - for(iterator.seekToFirst();iterator.isValid();iterator.next()){ - byte[]key=iterator.key(); - byte[]value=iterator.value(); - } - - iterator.close(); // closing is a must! -``` - -#### Reverse Iteration - -*It is somewhat slower than forward iteration.* +`use {}` is a `suspend` function, so it lives inside a coroutine. By default it moves the blocking +database work off your thread, so calling it from `Dispatchers.Main` on Android is safe. -```java -LevelDB levelDB=LevelDB.open("path/to/leveldb"); +On Android, resolve the path under the app's files directory with `AndroidLevelDBInstance`: - Iterator iterator=levelDB.iterator(); - - for(iterator.seekToLast();iterator.isValid();iterator.previous()){ - String key=iterator.key(); - String value=iterator.value(); - } - - iterator.close(); // closing is a must! +```kotlin +val db = AndroidLevelDBInstance.builder(context, dbName = "app.ldb").build() ``` -#### Iterate from a Starting Position - -```java -LevelDB levelDB=LevelDB.open("path/to/leveldb"); - - Iterator iterator=levelDB.iterator(); +Need a quick, synchronous handle without coroutines? Open the raw database directly: - for(iterator.seek("leveldb".getBytes());iterator.isValid();iterator.next()){ - String key=iterator.key(); - String value=iterator.value(); - } - - iterator.close(); // closing is a must! +```kotlin +val db = LevelDB.open("/path/to/db") +db.putString("hello", "world") +println(db.getString("hello")) +db.close() ``` -This will start from the key `leveldb` if it exists, or from the one that follows (eg. `sql`, i.e. `l` < `s`). - -#### Snapshots - -Snapshots give you a consistent view of the data in the database at a given time. +This call is synchronous and runs on the calling thread — close it yourself when you're done. -Here's a simple example demonstrating their use: +## Which API should I use? -```java -LevelDB levelDB=LevelDB.open("path/to/leveldb"); +There are two entry points, and they don't overlap: - levelDB.put("hello".getBytes(),"world".getBytes()); +- **`LevelDBInstance`** — the managed layer. It guarantees one open handle per path, supports + reentrant `use {}`, closes the handle when idle, runs migrations, and keeps `use {}` main-safe. + Reach for this on Android and in any long-running or concurrent process. +- **`LevelDB.open()`** — raw access to a single LevelDB handle. Synchronous, no coroutines, no + lifecycle management. Good for scripts, tests, and short-lived deterministic work. - Snapshot helloWorld=levelDB.obtainSnapshot(); +Pick one per database directory. LevelDB allows a single writer per directory, so opening the same +path through both at once will fail with a lock error. See +[docs/concepts.md](docs/concepts.md) for the full picture. - levelDB.put("hello".getBytes(),"brave-new-world".getBytes()); +## Documentation - levelDB.get("hello".getBytes(),helloWorld); // == "world" +- [Getting started](docs/getting-started.md) — a step-by-step first database, from open to iterate. +- [Concepts](docs/concepts.md) — the two APIs, lifecycle, the threading model, one-owner-per-path. +- [How-to guides](docs/how-to.md) — adapters, scans, batches, snapshots, exclusive access, recovery. +- [Migrations](docs/migrations.md) — schema versions, migration steps, safety policies, crash-resume. - levelDB.get("hello".getBytes()); // == "brave-new-world" +## Native binaries (JVM) - levelDB.releaseSnapshot(helloWorld); // release the snapshot +The JVM artifact bundles the native libraries under `natives/`: - levelDB.close(); // snapshots will automatically be released after this -``` - -### Mock LevelDB - -The implementation also supplies a mock LevelDB implementation that is an in-memory equivalent of the native LevelDB. It is meant to be used in -testing environments, especially non-Android ones like Robolectric. +- `natives/linux_64`, `natives/linux_arm64` +- `natives/osx_64`, `natives/osx_arm64` +- `natives/windows_64` -There are a few of differences from the native implementation: +They load on first use. To load eagerly (for example in `Application.onCreate`), call +`LevelDB.loadNative()`. Contributors building from source will find prebuilt JNI binaries in +`native/prebuilt`. -+ it is not configurable -+ it does not support properties (as in `LevelDB#getProperty()`) -+ it does not support paths, i.e. always returns `:MOCK:` +## Publishing -Use it like so: - -```java -LevelDB.mock(); -``` +The Publish workflow supports manual runs with a custom version. By default it runs tests on Linux +only; pass `full_tests=true` to run the full matrix. -## Building +## Licenses -Until Google (or someone else) fixes the Android Gradle build tools to properly support NDK, this is the way to build this project. - -1. Install the [NDK](https://developer.android.com/ndk) -2. Build with Gradle (leveldb::assembleRelease) - -Or u can build to local maven repository: - -```bash -cd /path/to/project -sh publish_local.sh -``` +This project ships under three licenses: -## License +- BSD-3-Clause for the original LevelDB wrapper code +- Apache 2.0 for `third_party/stojan` +- MIT for the project itself (see `LICENSE`) -This wrapper library is licensed under the -[BSD 3-Clause License](http://opensource.org/licenses/BSD-3-Clause), same as the code from Google. +## Attribution -See `LICENSE.txt` for the full Copyright. +Includes code derived from Stojan Dimitrovski's original LevelDB wrapper. The original BSD-3-Clause +license text is preserved in the source headers and under `third_party`. diff --git a/RELEASE.md b/RELEASE.md index be7ec0a..acc2cec 100755 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,10 +1,32 @@ # Release notes +## 2.0.0 + +- Major Kotlin Multiplatform refactor with new `leveldb` KMP module, APIs, and expanded tests. +- Restructured examples: new `android-example` and `ios-example`, removed legacy `example`. +- Build system updates: Gradle wrapper, version catalog, and project settings cleanup. +- Android updates: support for NDK `29.0.14206865`. +- Native build overhaul: new CMake presets/layout, host-compatible build scripts, and refreshed prebuilt output structure. +- JNI/native bindings reorganized with new `binding`, `cinterop`, and `shared` sources. +- Updated LevelDB submodule and licensing cleanup (consolidated LICENSE and added third_party notices). +- Added schema migration support (`LevelDBSchema`, `LevelDBMigration`) with safety policies (in-place, backup, staging) and manual/auto migration + controls. +- Added lifecycle controls for shared instances (idle-delayed close strategy, exclusive path access) to manage native handles safely. +- Introduced a dedicated adapter registry and new instance/driver config types to centralize adapters, logging, and LevelDB options. + +## 1.0.2 + +- Updated leveldb to the latest master +- Added `@Synchronized` to levelDbContext +- Added `coLevelDbContext` with mutex lock + ## 1.0.1 - Added `forEachKeys` and `forEachValues` extensions to `LevelDB` ## 1.0.0 - - Initial release after refactoring + - Initial release after migration to new repo + + # Old release notes (stacked) ## 2.1.0 @@ -25,4 +47,3 @@ - Fully refactored build process - Added latest version of leveldb (as it's stable) - More api variation - \ No newline at end of file diff --git a/android-example/build.gradle.kts b/android-example/build.gradle.kts new file mode 100644 index 0000000..054397d --- /dev/null +++ b/android-example/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.core) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) +} + +group = rootProject.group +version = rootProject.version + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +android { + namespace = "${group}.example" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.compileSdk.get().toInt() + versionCode = 1 + versionName = version as String + } + + buildFeatures { + buildConfig = true + viewBinding = false + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + debug { + isMinifyEnabled = false + } + } +} + +dependencies { + implementation(projects.leveldb) + + implementation(libs.android.core.ktx) + implementation(libs.android.lifecycle.runtime.ktx) + implementation(libs.android.lifecycle.service) + implementation(libs.android.compose.activity) + + implementation(platform(libs.android.compose.bom)) + implementation(libs.bundles.composeBom) + + implementation(libs.android.compose.activity) + implementation(libs.android.compose.viewmodel) + + implementation(libs.di.hilt.core) + implementation(libs.di.hilt.navigationCompose) + ksp(libs.di.hilt.compiler) + ksp(libs.di.hilt.daggerCompiler) + + // Hilt/Dagger 2.59.2 bundles a kotlin-metadata-jvm that only reads Kotlin metadata up to 2.3.0, + // but the Kotlin 2.4.0 compiler emits 2.4.0 metadata. Pin the parser to the compiler version so + // the Hilt annotation processor can read our classes. Remove once Hilt ships a 2.4.0-aware parser. + ksp(libs.kotlin.metadata.jvm) +} diff --git a/example/src/main/AndroidManifest.xml b/android-example/src/main/AndroidManifest.xml similarity index 88% rename from example/src/main/AndroidManifest.xml rename to android-example/src/main/AndroidManifest.xml index 6277f8a..731473a 100644 --- a/example/src/main/AndroidManifest.xml +++ b/android-example/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + - + android:theme="@style/Theme.LevelDB"> { + override fun decode(value: ByteArray): TextItem { + val fields = value + .decodeToString() + .split(";") + .associate { + val (key, v) = it.split("=") + key to v + } + + val id = checkNotNull(fields["id"]?.toIntOrNull()) + val text = checkNotNull(fields["text"]) + return TextItem(text, id) + } + + override fun encode(value: TextItem): ByteArray { + return "id=${value.id};text=${value.text}".toByteArray() + } + }) + } + } + } + } +} diff --git a/android-example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt b/android-example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt new file mode 100644 index 0000000..5b11bfa --- /dev/null +++ b/android-example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt @@ -0,0 +1,146 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.edwardstock.leveldb.example + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.fragment.app.FragmentActivity +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : FragmentActivity() { + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + val viewModel: MainViewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + Content( + state = state, + onAddItem = viewModel::addItem, + onRemoveItem = viewModel::removeItem + ) + + + } + } + } + + @Composable + private fun Content( + state: MainUiState, + onAddItem: (String) -> Unit, + onRemoveItem: (TextItem) -> Unit, + ) { + var newItemText by remember { mutableStateOf("") } + + Column { + TopAppBar( + title = { Text("LevelDB") }, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = newItemText, + label = { Text("Type something") }, + onValueChange = { + newItemText = it + }, + trailingIcon = { + IconButton( + onClick = { + onAddItem(newItemText) + newItemText = "" + } + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null + ) + } + } + ) + + if (state.items.isEmpty()) { + ListItem( + headlineContent = { Text("Empty") }, + ) + } + + LazyColumn { + items(state.items) { item -> + ListItem( + modifier = Modifier.clickable( + enabled = false, + onClick = { + + }, + ), + headlineContent = { Text(item.text) }, + supportingContent = { Text(item.id.toString()) }, + trailingContent = { + IconButton( + onClick = { + onRemoveItem(item) + } + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } + } + ) + } + } + + } + } + + @Composable + @PreviewLightDark + private fun ContentPreview() { + MaterialTheme { + Content( + state = MainUiState( + items = List(3) { + TextItem("Hello #$it", it) + } + ), + onAddItem = {}, + onRemoveItem = {} + ) + } + } +} diff --git a/android-example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt b/android-example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt new file mode 100644 index 0000000..362807f --- /dev/null +++ b/android-example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt @@ -0,0 +1,71 @@ +package com.edwardstock.leveldb.example + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.edwardstock.leveldb.LevelDBInstance +import com.edwardstock.leveldb.api.del +import com.edwardstock.leveldb.api.forEachAllValueT +import com.edwardstock.leveldb.api.putValue +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MainUiState( + val items: List, +) + +@HiltViewModel +class MainViewModel @Inject constructor( + private val db: LevelDBInstance, +) : ViewModel() { + + private val _state = MutableStateFlow(MainUiState(emptyList())) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + db.use { + val data = ArrayList() + forEachAllValueT { value -> + data.add(value) + } + _state.update { + it.copy( + items = data + ) + } + } + } + } + + fun addItem(text: String) { + viewModelScope.launch { + val id = System.currentTimeMillis().toInt() + val item = TextItem(text, id) + db.use { + putValue(id.toString(), item, TextItem::class) + } + _state.update { + it.copy( + items = it.items + item + ) + } + } + } + + fun removeItem(item: TextItem) { + viewModelScope.launch { + db.use { + del(item.id.toString()) + } + _state.update { oldState -> + oldState.copy( + items = oldState.items.filterNot { it.id == item.id } + ) + } + } + } +} diff --git a/example/src/main/java/com/edwardstock/leveldb/example/TextItem.kt b/android-example/src/main/java/com/edwardstock/leveldb/example/TextItem.kt similarity index 100% rename from example/src/main/java/com/edwardstock/leveldb/example/TextItem.kt rename to android-example/src/main/java/com/edwardstock/leveldb/example/TextItem.kt diff --git a/example/src/main/res/drawable/ic_launcher_background.xml b/android-example/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from example/src/main/res/drawable/ic_launcher_background.xml rename to android-example/src/main/res/drawable/ic_launcher_background.xml diff --git a/example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android-example/src/main/res/drawable/ic_launcher_foreground.webp similarity index 100% rename from example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to android-example/src/main/res/drawable/ic_launcher_foreground.webp diff --git a/example/src/main/res/menu/menu_main.xml b/android-example/src/main/res/menu/menu_main.xml similarity index 100% rename from example/src/main/res/menu/menu_main.xml rename to android-example/src/main/res/menu/menu_main.xml diff --git a/example/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-example/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from example/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android-example/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/example/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-example/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from example/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to android-example/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/example/src/main/res/mipmap-hdpi/ic_launcher.webp b/android-example/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from example/src/main/res/mipmap-hdpi/ic_launcher.webp rename to android-example/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/example/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android-example/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from example/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to android-example/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/example/src/main/res/mipmap-mdpi/ic_launcher.webp b/android-example/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from example/src/main/res/mipmap-mdpi/ic_launcher.webp rename to android-example/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/example/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android-example/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from example/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to android-example/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/example/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android-example/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from example/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to android-example/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android-example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to android-example/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/example/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android-example/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from example/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to android-example/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android-example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to android-example/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/android-example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android-example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/android-example/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android-example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to android-example/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/example/src/main/res/values/colors.xml b/android-example/src/main/res/values/colors.xml similarity index 100% rename from example/src/main/res/values/colors.xml rename to android-example/src/main/res/values/colors.xml diff --git a/example/src/main/res/values/strings.xml b/android-example/src/main/res/values/strings.xml similarity index 100% rename from example/src/main/res/values/strings.xml rename to android-example/src/main/res/values/strings.xml diff --git a/android-example/src/main/res/values/themes.xml b/android-example/src/main/res/values/themes.xml new file mode 100644 index 0000000..ec45c95 --- /dev/null +++ b/android-example/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts index a2e5149..469dd9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,36 +1,22 @@ -buildscript { - repositories { - mavenLocal() - google() - maven(url = uri("https://plugins.gradle.org/m2/")) - maven(url = uri("https://jitpack.io")) - mavenCentral() - } - dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${deps.versions.kotlin.base.get()}") - classpath("com.android.tools.build:gradle:${deps.versions.agp.get()}") - classpath("com.google.dagger:hilt-android-gradle-plugin:${deps.versions.hilt.base.get()}") - classpath("com.edwardstock:cmakebuild:0.2.2") - } -} - -allprojects { - repositories { - mavenLocal() - mavenCentral() - google() - maven(url = uri("https://repo1.maven.org/maven2/")) - maven(url = uri("https://clojars.org/repo/")) - maven(url = uri("https://oss.sonatype.org/content/repositories/snapshots/")) - maven(url = uri("https://jitpack.io")) - maven(url = uri("https://oss.jfrog.org/libs-snapshot/")) - maven(url = uri("https://oss.jfrog.org/artifactory/oss-snapshot-local/")) - } -} +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.core) apply false -tasks.withType(Delete::class.java) { - delete(rootProject.buildDir) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.atomicfu) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.kover) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.vanniktech.mavenPublish) apply false + alias(libs.plugins.android.lint) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.ktjni) apply false } -group = "com.edwardstock" -version = "1.0.1" \ No newline at end of file +val libVersion = "2.0.0" +group = "com.edwardstock.leveldb" +version = if (providers.gradleProperty("snapshot").isPresent) "$libVersion-SNAPSHOT" else libVersion diff --git a/build_native.sh b/build_native.sh index 1137386..2014168 100755 --- a/build_native.sh +++ b/build_native.sh @@ -16,7 +16,7 @@ _BUILD_DIR=${PWD}/.cxx CMAKE_BIN="${_ANDROID_HOME}/cmake/${_CMAKE_VERSION}/bin/cmake" if [ ! -f "${CMAKE_BIN}" ]; then - echo "CMake didn't found in ${CMAKE_BIN}" + echo "CMake not found in ${CMAKE_BIN}" exit 1 fi diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..08c42d5 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,153 @@ +# Concepts + +This page explains how the library is put together — the two entry points, how the managed handle +lives and dies, and the threading rules. Read it once and the rest of the API stops being surprising. + +## Two entry points + +There are two ways into the database, and they're meant for different jobs. + +### `LevelDBInstance` — the managed layer + +`LevelDBInstance` is bound to one directory. It doesn't open the database when you build it; it opens +on the first `use {}` and manages the handle from there: + +```kotlin +val db = LevelDBInstance.builder("/path/to/db").build() + +db.use { + putString("k", "v") +} +``` + +What it gives you: + +- **One handle per path.** Every `LevelDBInstance` for the same directory shares a single open + database, tracked in a process-wide registry. Build ten instances for one path and you still get + one handle. +- **Reentrant `use {}`.** Calling `use {}` from inside another `use {}` on the same coroutine reuses + the handle instead of deadlocking. +- **Idle-close.** When the last `use {}` finishes, the handle can close — immediately, or after a + delay (see [Lifecycle](#lifecycle-and-idle-close)). +- **Migrations.** If you attach a schema, the first `use {}` runs any pending migration under an + exclusive lock. See [Migrations](migrations.md). +- **Main-safe operations.** `use {}` moves blocking work off your thread by default. + +This is the right default for Android and for any process that lives longer than a single operation. + +### `LevelDB.open()` — raw access + +`LevelDB.open()` hands you a single LevelDB handle and gets out of the way: + +```kotlin +val db = LevelDB.open("/path/to/db") +db.putString("k", "v") +db.close() +``` + +It's synchronous, has no coroutines, and does no lifecycle management — you opened it, you close it. +It also takes a `LevelDBInstanceConfig` but ignores the `instanceFactory` field on it (that field +belongs to the managed layer); only the `driver` settings apply here. Use this for scripts, tests, +and short deterministic tasks where you control the lifetime yourself. + +If you forget to `close()`, the native handle stays open and you can hit lock errors or leak +resources. That's the trade-off for skipping the managed layer. + +## Lifecycle and idle-close + +Opening a LevelDB directory is not free, so `LevelDBInstance` doesn't necessarily close the handle the +moment a `use {}` block exits. A close strategy decides what happens: + +```kotlin +import com.edwardstock.leveldb.config.LevelDBInstanceConfig.CloseStrategy +import kotlin.time.Duration.Companion.seconds + +val db = LevelDBInstance.builder("/path/to/db") + .closeStrategy(CloseStrategy.IdleDelayed(10.seconds)) + .build() +``` + +- `CloseStrategy.Immediate` (the default) closes the handle as soon as the last user leaves. +- `CloseStrategy.IdleDelayed(duration)` keeps the handle warm for `duration` after the last user + leaves. If a new `use {}` arrives in that window, it reuses the open handle and the timer resets. + +`IdleDelayed` is worth it when you read and write in bursts — you skip repeated open/close cycles +between them. For a one-shot operation, `Immediate` is fine. + +When you need the handle closed right now (a test tearing down, a service shutting down), don't wait +for the timer: + +```kotlin +db.closeAndAwait() // suspends until the handle is closed +``` + +There's also a fire-and-forget `db.close()`, but it returns before the close actually happens. Prefer +`closeAndAwait()` when you care about ordering. + +## The threading model + +`use {}` is a `suspend` function for two reasons: its internal locking is coroutine-based, and it +moves the blocking native I/O onto a worker so it doesn't tie up your caller. + +### Which dispatcher runs the work + +The dispatcher for operations inside `use {}` is resolved in this order: + +1. an explicit `dispatcher(...)` set on the builder, otherwise +2. the dispatcher of the instance's `scope`, otherwise +3. `Dispatchers.IO`. + +The default scope uses `Dispatchers.IO`, so out of the box `use {}` is **main-safe**: you can call it +from `Dispatchers.Main` and the blocking work still runs on IO. + +```kotlin +// On Android — safe, the put runs on IO, not on the main thread: +viewModelScope.launch { + db.use { putString("k", "v") } +} +``` + +If you want operations on a specific dispatcher regardless of the caller, set it explicitly: + +```kotlin +val db = LevelDBInstance.builder("/path/to/db") + .dispatcher(myDispatcher) + .build() +``` + +A reentrant `use {}` (nested in another on the same coroutine) inherits the outer dispatcher rather +than switching again. + +The raw `LevelDB.open()` path makes no such promise — it's synchronous and runs on whatever thread +calls it. If you open raw on the main thread, the I/O is on the main thread. That's by design: the +managed layer owns threading, the raw layer leaves it to you. + +### What is and isn't thread-safe + +- **Database operations are thread-safe.** Concurrent reads and writes are fine. +- **Iterators and snapshots are not.** Don't share a single iterator or snapshot across threads + without your own synchronization. Each thread that iterates should get its own iterator. + +## One owner per path + +LevelDB allows a single writer per directory. It enforces this with a `LOCK` file: the second attempt +to open the same directory fails with a lock error. + +`LevelDBInstance` respects this for you — every instance for a path shares one handle. But that +guarantee only covers the managed layer. `LevelDB.open()` opens directly, outside the registry, so +mixing the two on one directory defeats it: + +```kotlin +val managed = LevelDBInstance.builder("/data/db").build() +managed.use { putString("k", "v") } // managed layer holds the handle + +val raw = LevelDB.open("/data/db") // second open on the same dir -> lock error +``` + +With `IdleDelayed`, the managed handle is sometimes open and sometimes not, so this kind of clash can +be intermittent — which is worse, because it looks like it works until it doesn't. + +The rule is simple: **one owner per directory per process.** Pick `LevelDBInstance` or `LevelDB.open()` +for a given path and stay with it. If you need to touch the directory at the filesystem level (copy, +swap, restore) while the managed layer owns it, use [`useExclusively`](how-to.md#exclusive-access-for-backup-and-restore) +instead of opening a second handle. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d0481d7 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,131 @@ +# Getting started + +This walks you through your first database end to end: open it, write a few values, read them back, +store a typed value, scan a range, and close up. By the end you'll have a working `LevelDBInstance` +and know the handful of calls you'll use every day. + +You need a Kotlin Multiplatform (or plain JVM/Android) project and basic familiarity with coroutines. +That's it — the native library loads itself. + +## 1. Add the dependency + +```kotlin +dependencies { + implementation("com.edwardstock.leveldb:leveldb:") +} +``` + +## 2. Open a database + +A `LevelDBInstance` is tied to one directory on disk. Build it once and keep it around — it manages +the underlying handle for you. + +```kotlin +import com.edwardstock.leveldb.LevelDBInstance + +val db = LevelDBInstance.builder("/path/to/db").build() +``` + +On Android, you usually want the database under the app's private files directory. Use +`AndroidLevelDBInstance`, which resolves the path for you: + +```kotlin +import com.edwardstock.leveldb.AndroidLevelDBInstance + +val db = AndroidLevelDBInstance.builder(context, dbName = "app.ldb").build() +``` + +Building the instance does not open the handle yet. The first `use {}` does. + +## 3. Write and read + +Operations run inside `use {}`. It's a `suspend` function, so call it from a coroutine. Inside the +block, `this` is the database, and the `putString` / `getString` extensions cover the common case: + +```kotlin +import com.edwardstock.leveldb.api.getString +import com.edwardstock.leveldb.api.putString +import kotlinx.coroutines.runBlocking + +runBlocking { + db.use { + putString("user:1:name", "Ada") + putString("user:1:role", "admin") + + val name = getString("user:1:name") // "Ada" + println(name) + } +} +``` + +`runBlocking` is just here to give the example a coroutine to run in. In a real app you already have +one — a `viewModelScope`, a `launch`, a suspend function up the call stack. + +Deleting a key is the same idea. Either call `del`, or assign `null` — both mean "remove this key": + +```kotlin +db.use { + del("user:1:role") + // equivalent: + this["user:1:role"] = null +} +``` + +That bracket syntax is an operator. `this["k"] = "v"` writes, `this["k"]` reads raw bytes. Handy for +quick access; the named methods are clearer for typed values. + +## 4. Store something other than a string + +LevelDB stores bytes. The library converts common types for you through adapters, so you can put and +get an `Int`, `Long`, `Double`, `Boolean`, and so on directly: + +```kotlin +import com.edwardstock.leveldb.api.getValue +import com.edwardstock.leveldb.api.putValue + +db.use { + putValue("user:1:logins", 42) + + val logins = getValue("user:1:logins") // 42 + println(logins) +} +``` + +`putValue(key, null)` deletes the key, same as everywhere else. For your own types, register a custom +adapter — see [How-to guides](how-to.md#store-a-custom-type). + +## 5. Scan a range + +LevelDB keeps keys in sorted (lexicographic) order, which makes prefix scans cheap. The `user:1:` +prefix above wasn't an accident — here's how to read everything under it: + +```kotlin +db.use { + forEachPrefix("user:1:") { entry -> + println("${entry.keyString()} = ${entry.valueString()}") + } +} +``` + +`forEachPrefix` seeks to the prefix and walks forward until the keys stop matching, so it touches only +the rows you care about. There's also `forEachAll` for a full scan — use it sparingly, it reads the +whole database. + +## 6. Close when you're done + +`LevelDBInstance` closes the handle on its own once nobody is using it, so for most apps you don't +close anything. When you need a deterministic shutdown — a test, a service stopping — await it: + +```kotlin +db.closeAndAwait() +``` + +## What you've got + +You've covered the calls that make up most day-to-day use: `builder`, `use {}`, `putString` / +`getString`, typed `putValue` / `getValue`, and `forEachPrefix`. From here: + +- [Concepts](concepts.md) explains why `use {}` is a coroutine, how the idle-close works, and when to + reach for the raw `LevelDB.open()` instead. +- [How-to guides](how-to.md) has recipes for batches, snapshots, custom adapters, and backups. +- [Migrations](migrations.md) covers evolving your schema over time. diff --git a/docs/how-to.md b/docs/how-to.md new file mode 100644 index 0000000..3dc3e25 --- /dev/null +++ b/docs/how-to.md @@ -0,0 +1,167 @@ +# How-to guides + +Short recipes for specific tasks. Each one assumes you already have a `LevelDBInstance` (see +[Getting started](getting-started.md)) and that you're inside a `use {}` block unless noted otherwise. + +- [Store a custom type](#store-a-custom-type) +- [Scan keys by prefix or in full](#scan-keys-by-prefix-or-in-full) +- [Write several changes atomically](#write-several-changes-atomically) +- [Read a consistent view with a snapshot](#read-a-consistent-view-with-a-snapshot) +- [Exclusive access for backup and restore](#exclusive-access-for-backup-and-restore) +- [Recover a broken database](#recover-a-broken-database) + +## Store a custom type + +Primitives (`Int`, `Long`, `Double`, `Boolean`, and friends) already have adapters. For your own +types, implement `ValueAdapter` — it's just encode/decode to and from bytes: + +```kotlin +import com.edwardstock.leveldb.api.ValueAdapter + +data class User(val id: Int, val name: String) + +class UserAdapter : ValueAdapter { + override fun encode(value: User): ByteArray = + "${value.id}:${value.name}".encodeToByteArray() + + override fun decode(value: ByteArray): User { + val (id, name) = value.decodeToString().split(":", limit = 2) + return User(id.toInt(), name) + } +} +``` + +Register it on the builder, then use `putValue` / `getValue` with your type: + +```kotlin +val db = LevelDBInstance.builder("/path/to/db") + .adapters { addAdapter(UserAdapter()) } + .build() + +db.use { + putValue("user:1", User(1, "Ada")) + val user = getValue("user:1") // User(1, "Ada") +} +``` + +Real apps usually back the adapter with a serializer (kotlinx.serialization, protobuf, JSON) instead +of hand-rolled string splitting. The interface is the same — encode to bytes, decode from bytes. + +## Scan keys by prefix or in full + +LevelDB stores keys in sorted order, so a prefix scan is cheap: it seeks straight to the prefix and +stops as soon as the keys stop matching. + +```kotlin +db.use { + forEachPrefix("user:") { entry -> + println("${entry.keyString()} -> ${entry.valueString()}") + } +} +``` + +Each `entry` can hand you the key and value as bytes, as a string, or as a typed value +(`entry.valueT()`). There are also shortcut overloads when you only want one side — +`forEachPrefix` has siblings like `forEachAllKeyString` and `forEachAllValueString` for full scans. + +A full scan reads the entire database, so reach for it deliberately: + +```kotlin +db.use { + forEachAll { entry -> /* ... */ } +} +``` + +`forEachAll` defaults to `fillCache = false` so a full sweep doesn't evict your hot data from +LevelDB's block cache. Pass `fillCache = true` only if you actually want to warm the cache. + +## Write several changes atomically + +A write batch applies all of its operations together or not at all. Use `withBatch` inside `use {}`; +the receiver is the batch: + +```kotlin +db.use { + withBatch(sync = true) { + putString("user:1:name", "Ada") + putValue("user:1:logins", 0) + del("user:1:legacy") + } +} +``` + +`sync = true` flushes to disk before returning, so the data survives a system crash. `sync = false` +is faster but only guarantees durability against a process crash, not a power loss. As with single +writes, a `null` value in a batch put is a delete. + +## Read a consistent view with a snapshot + +A snapshot is a read-only view frozen at a point in time. Reads against it ignore later writes, which +is what you want when you need several reads to agree: + +```kotlin +db.use { + putString("k", "v1") + + val snapshot = obtainSnapshot() + putString("k", "v2") // changes the live database... + + println(getString("k", snapshot)) // "v1" — the snapshot still sees the old value + println(getString("k")) // "v2" — live read + + snapshot.close() +} +``` + +Close the snapshot when you're done with it. A snapshot belongs to the database that created it; +passing it to a different database throws. + +> Snapshots and iterators are not thread-safe. Don't share one across threads. + +## Exclusive access for backup and restore + +Copying, swapping, or restoring the database directory while it's in use is dangerous. `useExclusively` +gives you a safe window: it blocks new `use {}` calls for the path, waits for active ones to drain, +and lets you touch the directory. + +```kotlin +instance.useExclusively { + // New use {} for this path is blocked; active ones have finished. + // Safe to copy / swap / restore the directory here. + + open { + // Need database access during exclusivity? Use open {}, not use {}. + putString("backup-marker", "done") + } +} +``` + +The rules that keep this from deadlocking: + +- Don't call `useExclusively {}` from inside `use {}` for the same path. +- Don't nest `useExclusively {}` on the same instance. +- Inside the exclusive block, reach the database with `open {}`, never `use {}` — `useExclusively` + is waiting for `use {}` to drain, so calling `use {}` there waits on yourself. +- To run concurrent work inside the block, use the provided scope and call `open {}` from its child + coroutines. + +This is also the correct way to touch the directory at the filesystem level while the managed layer +owns the handle — see [one owner per path](concepts.md#one-owner-per-path). + +## Recover a broken database + +A crash, a bad filesystem state, or a stale lock can leave a database that won't open. As a last +resort, ask LevelDB to rebuild what it can: + +```kotlin +LevelDB.repair("/path/to/db") +``` + +This calls `leveldb::RepairDB()` under the hood. It can lose data, so treat it as a recovery tool, not +routine maintenance — and tell your users when it runs. + +To wipe a database directory completely: + +```kotlin +LevelDB.destroy("/path/to/db") +``` diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..53a9c1f --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,171 @@ +# Migrations + +LevelDB has no schema of its own. This library adds a versioning layer on top so you can evolve your +data over time: a version number stored inside the database, plus a list of steps that move it from +one version to the next. + +## The model + +You describe the target state with a `LevelDBSchema` and attach it to the instance: + +```kotlin +import com.edwardstock.leveldb.migration.LevelDBSchema +import com.edwardstock.leveldb.migration.SoftMigration + +val schema = LevelDBSchema( + targetVersion = 2, + migrations = listOf( + SoftMigration(0, 1), // additive change, no data rewrite + RewriteUserValues(1, 2), // your own step (below) + ), +) + +val db = LevelDBInstance.builder("/path/to/db") + .schema(schema) + .build() +``` + +The stored version starts at `0` for a fresh database. When the schema's `targetVersion` is higher, +the library runs the steps in order to close the gap. + +### Migration steps are linear + +The plan is strictly linear: one step per hop, `N -> N+1`. To reach version 3 from 0 you need steps +`0->1`, `1->2`, and `2->3` — exactly one each. A missing hop or a non-linear jump fails with +`LevelDBMigrationException` rather than guessing. (Silently bumping the version without a declared step +is how you ship corruption to production.) + +A step is a `LevelDBMigration`: + +```kotlin +import com.edwardstock.leveldb.api.LevelDB +import com.edwardstock.leveldb.api.forEachPrefix +import com.edwardstock.leveldb.migration.LevelDBMigration + +class RewriteUserValues( + override val from: Int, + override val to: Int, +) : LevelDBMigration { + override val name = "Rewrite user values to v2" + + override suspend fun migrate(db: LevelDB) { + db.withBatch(sync = true) { + db.forEachPrefix("user:") { entry -> + put(entry.keyBytes(), transform(entry.valueBytes())) + } + } + } +} +``` + +For an additive change that needs no data rewrite — new keys, new optional fields, a lazily-rebuilt +index — use `SoftMigration(from, to)`. It only bumps the version, which keeps the version graph legal +without touching data. + +### Write steps so they can re-run + +A migration can be interrupted partway — process death, cancellation, an I/O error. The next run +retries the same step, so write steps to be idempotent: + +- rebuild derived keys from a source of truth rather than mutating in place +- write the new key first, then delete the old one +- make writes conditional ("if already migrated, skip") +- prefer existence checks over trusting "this version implies this key exists" + +## When migrations run + +### Automatically (default) + +With `migrateAutomatically = true` (the default), the first `use {}` that finds the version out of +date runs the migration before your block executes. It runs under path exclusivity: new `use {}` calls +for the path wait, active ones drain first, and normal access resumes once the database is migrated +and reopened. + +### Manually + +Set `migrateAutomatically = false` to take control, then run it yourself: + +```kotlin +val schema = LevelDBSchema( + targetVersion = 2, + migrateAutomatically = false, + migrations = listOf(/* ... */), +) + +// later, at a moment you choose: +instance.migrateIfNeeded() +``` + +## Crash safety + +Migration tracks progress with an in-database marker (`inProgressKey`) alongside the version: + +1. Before a step: write `inProgress = step.to`. +2. After the step succeeds: set `version = step.to`. +3. After the whole migration succeeds: delete the marker. + +So after a crash mid-step the next run sees `version = N` and `inProgress = N+1`, and retries that +step. Any other combination — an `inProgress` that's neither `version + 1` nor the target — is treated +as invalid and fails rather than charging ahead. + +## The failed-migration guard + +If a step throws, the instance records the failure **in memory** and stops retrying for the rest of +the process: later `use {}` calls throw `LevelDBCorruptedMigrationException`. This prevents an infinite +retry loop where a deterministic failure runs on every app start. + +The guard does not survive a full restart. To retry after your own recovery logic, clear it +explicitly: + +```kotlin +instance.migrateIfNeeded(ignorePreviousFailure = true) +``` + +Use that escape hatch carefully. If you always reset the failure, a migration that keeps failing will +keep looping. + +## Safety policies + +The `safety` setting controls how much of a safety net the migration runs with: + +```kotlin +import com.edwardstock.leveldb.migration.LevelDBMigrationSafetyPolicy + +val schema = LevelDBSchema( + targetVersion = 2, + safety = LevelDBMigrationSafetyPolicy.BACKUP_DIR, + migrations = listOf(/* ... */), +) +``` + +| Policy | What it does | Cost | +|---|---|---| +| `NONE` (default) | Migrates the live database in place. | Fastest, no safety net. | +| `BACKUP_DIR` | Copies the database to a backup directory first, migrates in place, restores the backup on failure. | Extra disk and copy time. | +| `STAGING_DB` | Migrates a staging copy, then swaps directories; rolls the swap back on failure. | Most overhead, strongest rollback. | + +Pick based on how much you'd regret a half-finished migration. For small, additive changes `NONE` is +usually fine; for a risky data rewrite on important data, `BACKUP_DIR` or `STAGING_DB` buys you a way +back. + +## Full example + +```kotlin +val schema = LevelDBSchema( + targetVersion = 2, + safety = LevelDBMigrationSafetyPolicy.BACKUP_DIR, + migrations = listOf( + SoftMigration(0, 1), + RewriteUserValues(1, 2), + ), +) + +val db = LevelDBInstance.builder("/path/to/db") + .schema(schema) + .build() + +// The first use {} migrates 0 -> 1 -> 2 if needed, then runs your block. +db.use { + val user = getValue("user:1") +} +``` diff --git a/example/build.gradle.kts b/example/build.gradle.kts deleted file mode 100644 index ab6dbcd..0000000 --- a/example/build.gradle.kts +++ /dev/null @@ -1,70 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - kotlin("kapt") - id("dagger.hilt.android.plugin") -} - -group = rootProject.group -version = rootProject.version - -android { - buildToolsVersion = deps.versions.buildTools.get() - compileSdk = deps.versions.maxSdk.get().toInt() - - defaultConfig { - minSdk = deps.versions.minSdk.get().toInt() - targetSdk = deps.versions.maxSdk.get().toInt() - versionCode = 1 - versionName = version as String - } - - buildFeatures { - buildConfig = true - viewBinding = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - buildTypes { - debug { - isMinifyEnabled = false - } - } -} - -kapt { - correctErrorTypes = true -} - -dependencies { - compileOnly("javax.annotation:jsr250-api:1.0") - compileOnly("javax.inject:javax.inject:1") - - implementation(project(":leveldb-android")) - - implementation(deps.base.android.core) - implementation(deps.base.android.core) - implementation(deps.base.android.annotations) - implementation(deps.base.android.appcompat) - implementation(deps.base.android.appcompatResources) - implementation(deps.base.android.recyclerview) - implementation(deps.base.android.material) - implementation(deps.base.android.lifecycle.viewmodel) - implementation(deps.base.android.lifecycle.runtime) - kapt(deps.base.android.lifecycle.compiler) - implementation(deps.base.android.ktx.activity) - implementation(deps.base.android.ktx.fragment) - - implementation(deps.base.kotlin.coroutines) - - implementation(deps.base.hilt.core) - kapt(deps.base.hilt.compiler) - - -} - - diff --git a/example/src/main/java/com/edwardstock/leveldb/example/LevelDbModule.kt b/example/src/main/java/com/edwardstock/leveldb/example/LevelDbModule.kt deleted file mode 100644 index 742ef01..0000000 --- a/example/src/main/java/com/edwardstock/leveldb/example/LevelDbModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.edwardstock.leveldb.example - -import android.content.Context -import com.edwardstock.leveldb.AndroidLevelDBInstance -import com.edwardstock.leveldb.implementation.LevelDBInstance -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object LevelDbModule { - @Provides - fun provideLevelDB(@ApplicationContext context: Context): LevelDBInstance { - return AndroidLevelDBInstance(context) { - createIfMissing = true - } - } -} diff --git a/example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt b/example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt deleted file mode 100644 index aecf2ca..0000000 --- a/example/src/main/java/com/edwardstock/leveldb/example/MainActivity.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.edwardstock.leveldb.example - -import android.os.Bundle -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.edwardstock.leveldb.example.databinding.ActivityMainBinding -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class MainActivity : AppCompatActivity() { - private val binding: ActivityMainBinding by lazy { - ActivityMainBinding.inflate(layoutInflater) - } - - private val viewModel: MainViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - setContentView(binding.root) - super.onCreate(savedInstanceState) - - binding.save.setOnClickListener { - lifecycleScope.launch { - viewModel.addItemFlow.emit(binding.input.text?.toString()) - binding.input.text = null - } - Toast.makeText(this, "Added", Toast.LENGTH_SHORT).show() - } - - binding.list.layoutManager = LinearLayoutManager(this) - binding.list.adapter = viewModel.adapter - - binding.toolbar.inflateMenu(R.menu.menu_main) - } -} diff --git a/example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt b/example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt deleted file mode 100644 index 33b8a3f..0000000 --- a/example/src/main/java/com/edwardstock/leveldb/example/MainViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.edwardstock.leveldb.example - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.edwardstock.leveldb.implementation.LevelDBInstance -import com.edwardstock.leveldb.implementation.forEachAll -import com.edwardstock.leveldb.implementation.leveldbContext -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MainViewModel @Inject constructor( - private val db: LevelDBInstance -) : ViewModel() { - - val addItemFlow = MutableSharedFlow() - val adapter = RowsAdapter(this::onItemDelete) - - init { - viewModelScope.launch { - addItemFlow - .filterNotNull() - .filterNot { it.isEmpty() } - .map { TextItem(it) } - .collect { - leveldbContext(db) { - put(it.id.toString(), it.text) - } - - adapter.addItem(it) - } - } - - val data = ArrayList() - - leveldbContext(db) { - forEachAll { key, value -> - val item = TextItem(value, key.toInt()) - data.add(item) - } - adapter.setData(data) - } - - } - - private fun onItemDelete(item: TextItem) { - leveldbContext(db) { - del(item.id.toString()) - } - adapter.removeItem(item) - } - -} diff --git a/example/src/main/java/com/edwardstock/leveldb/example/RowsAdapter.kt b/example/src/main/java/com/edwardstock/leveldb/example/RowsAdapter.kt deleted file mode 100755 index 6b3c423..0000000 --- a/example/src/main/java/com/edwardstock/leveldb/example/RowsAdapter.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.edwardstock.leveldb.example - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.edwardstock.leveldb.example.databinding.DbItemBinding - - -class RowsAdapter( - private val onDelete: (TextItem) -> Unit -) : RecyclerView.Adapter() { - - private var inflater: LayoutInflater? = null - private var items: MutableList = ArrayList() - - class ViewHolder(val binding: DbItemBinding) : RecyclerView.ViewHolder(binding.root) - - fun setData(data: List) { - if (items.isNotEmpty()) { - notifyItemRangeRemoved(0, items.size) - } - items = data.toMutableList() - notifyItemRangeInserted(0, items.size) - } - - fun addItem(item: TextItem) { - items.add(item) - notifyItemInserted(items.size - 1) - } - - fun removeItem(item: TextItem) { - var idx: Int = -1 - items.forEachIndexed { index, textItem -> - if (textItem == item) { - idx = index - } - } - if (idx >= 0) { - items.removeAt(idx) - notifyItemRemoved(idx) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - if (inflater == null) { - inflater = LayoutInflater.from(parent.context) - } - - return ViewHolder( - DbItemBinding.inflate(inflater!!, parent, false) - ) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val data = items[position] - holder.binding.text.text = data.text - holder.binding.actionDelete.setOnClickListener { - onDelete(items[holder.bindingAdapterPosition]) - } - } - - override fun getItemCount(): Int { - return items.size - } -} diff --git a/example/src/main/res/drawable-v24/ic_launcher_foreground.xml b/example/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 7706ab9..0000000 --- a/example/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - diff --git a/example/src/main/res/drawable/ic_delete.xml b/example/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 79372b1..0000000 --- a/example/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml deleted file mode 100644 index 2bb2715..0000000 --- a/example/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - -